Update flyerimport. flutter timeout 300 sek
This commit is contained in:
@@ -21,3 +21,5 @@ JWT_SECRET=uK9yRQpyyWOcHYcYbpAdsJ7NJcEsyCYZcgF82OnBz2k=
|
|||||||
MISTRAL_API_KEY=JGPjLuNnzaLSYMxKbexLZohUOegrSLye
|
MISTRAL_API_KEY=JGPjLuNnzaLSYMxKbexLZohUOegrSLye
|
||||||
FLYER_AI_TIMEOUT_MS=45000
|
FLYER_AI_TIMEOUT_MS=45000
|
||||||
FLYER_AI_RETRIES=2
|
FLYER_AI_RETRIES=2
|
||||||
|
FLYER_AI_DEBUG=1
|
||||||
|
FLYER_AI_DEBUG_DIR=/app/debug
|
||||||
|
|||||||
+40
-37
@@ -1,42 +1,45 @@
|
|||||||
# Kopiera till .env och fyll i riktiga värden
|
# Kopiera till .env och fyll i riktiga värden
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
|
||||||
# MariaDB
|
# MariaDB
|
||||||
MARIADB_ROOT_PASSWORD=byt-ut-mig
|
MARIADB_ROOT_PASSWORD=byt-ut-mig
|
||||||
MARIADB_DATABASE=recipe_app
|
MARIADB_DATABASE=recipe_app
|
||||||
MARIADB_USER=recipe_user
|
MARIADB_USER=recipe_user
|
||||||
MARIADB_PASSWORD=byt-ut-mig
|
MARIADB_PASSWORD=byt-ut-mig
|
||||||
|
|
||||||
# Auth.js / NextAuth
|
# Auth.js / NextAuth
|
||||||
# Generera med: openssl rand -base64 32
|
# Generera med: openssl rand -base64 32
|
||||||
AUTH_SECRET=byt-ut-mig
|
AUTH_SECRET=byt-ut-mig
|
||||||
|
|
||||||
# JWT (NestJS backend)
|
# JWT (NestJS backend)
|
||||||
# Generera med: openssl rand -base64 32
|
# Generera med: openssl rand -base64 32
|
||||||
# OBS: Appen vägrar starta om detta saknas.
|
# OBS: Appen vägrar starta om detta saknas.
|
||||||
JWT_SECRET=byt-ut-mig
|
JWT_SECRET=byt-ut-mig
|
||||||
|
|
||||||
# Mistral AI
|
# Mistral AI
|
||||||
# Hämtas från: https://console.mistral.ai/
|
# Hämtas från: https://console.mistral.ai/
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
FLYER_AI_TIMEOUT_MS=45000
|
FLYER_AI_TIMEOUT_MS=45000
|
||||||
FLYER_AI_RETRIES=2
|
FLYER_AI_RETRIES=2
|
||||||
|
FLYER_AI_DEBUG=0
|
||||||
# Publik URL (används av frontend)
|
# Linux-container: /app/debug, lokalt: ./debug
|
||||||
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
|
FLYER_AI_DEBUG_DIR=/app/debug
|
||||||
NEXT_PUBLIC_API_URL=https://recept.gynther.se
|
|
||||||
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
|
# Publik URL (används av frontend)
|
||||||
ALLOWED_ORIGIN=https://recept.gynther.se
|
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
|
||||||
|
NEXT_PUBLIC_API_URL=https://recept.gynther.se
|
||||||
# Importer integration
|
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
|
||||||
IMPORTER_SERVICE_URL=http://importer-api:3001
|
ALLOWED_ORIGIN=https://recept.gynther.se
|
||||||
RECEIPT_TRACE_DECISIONS=0
|
|
||||||
|
# Importer integration
|
||||||
# Optional webhook hardening
|
IMPORTER_SERVICE_URL=http://importer-api:3001
|
||||||
GITEA_WEBHOOK_SECRET=
|
RECEIPT_TRACE_DECISIONS=0
|
||||||
|
|
||||||
# Bootstrap-användare (skapas/uppdateras vid appstart)
|
# Optional webhook hardening
|
||||||
ADMIN_NADMIN_PASSWORD=byt-ut-mig
|
GITEA_WEBHOOK_SECRET=
|
||||||
ADMIN_PADMIN_PASSWORD=byt-ut-mig
|
|
||||||
SEED_USER1_PASSWORD=byt-ut-mig
|
# Bootstrap-användare (skapas/uppdateras vid appstart)
|
||||||
SEED_USER2_PASSWORD=byt-ut-mig
|
ADMIN_NADMIN_PASSWORD=byt-ut-mig
|
||||||
|
ADMIN_PADMIN_PASSWORD=byt-ut-mig
|
||||||
|
SEED_USER1_PASSWORD=byt-ut-mig
|
||||||
|
SEED_USER2_PASSWORD=byt-ut-mig
|
||||||
|
|||||||
Generated
+4
-4
@@ -39,7 +39,7 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.19.19",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdf-parse": "^1.1.5",
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
@@ -2783,9 +2783,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "22.19.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.19.19",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdf-parse": "^1.1.5",
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
export interface AiFlyerParseResult {
|
export interface AiFlyerParseResult {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
@@ -26,6 +28,8 @@ export class AiFlyerParserService {
|
|||||||
private readonly chunkSizeChars: number;
|
private readonly chunkSizeChars: number;
|
||||||
private readonly chunkOverlapChars: number;
|
private readonly chunkOverlapChars: number;
|
||||||
private readonly maxChunks: number;
|
private readonly maxChunks: number;
|
||||||
|
private readonly debugEnabled: boolean;
|
||||||
|
private readonly debugDirectory: string;
|
||||||
private mistral: any;
|
private mistral: any;
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
|
||||||
@@ -40,6 +44,8 @@ export class AiFlyerParserService {
|
|||||||
this.chunkSizeChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_SIZE_CHARS', 3_000);
|
this.chunkSizeChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_SIZE_CHARS', 3_000);
|
||||||
this.chunkOverlapChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_OVERLAP_CHARS', 300);
|
this.chunkOverlapChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_OVERLAP_CHARS', 300);
|
||||||
this.maxChunks = this.readPositiveIntEnv('FLYER_AI_MAX_CHUNKS', 8);
|
this.maxChunks = this.readPositiveIntEnv('FLYER_AI_MAX_CHUNKS', 8);
|
||||||
|
this.debugEnabled = this.readBooleanEnv('FLYER_AI_DEBUG', false);
|
||||||
|
this.debugDirectory = process.env.FLYER_AI_DEBUG_DIR?.trim() || path.join(process.cwd(), 'debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getClient(): Promise<any> {
|
private async getClient(): Promise<any> {
|
||||||
@@ -60,19 +66,61 @@ export class AiFlyerParserService {
|
|||||||
throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.');
|
throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugSession = this.createDebugSession('AI-flyerimporter');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-input.txt`,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
const chunks = this.splitIntoChunks(text);
|
const chunks = this.splitIntoChunks(text);
|
||||||
this.logger.debug(`Parsing flyer text in ${chunks.length} chunk(s)`);
|
this.logger.debug(`Parsing flyer text in ${chunks.length} chunk(s)`);
|
||||||
|
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-chunks.json`,
|
||||||
|
JSON.stringify(chunks, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allItems: AiFlyerParseResult[] = [];
|
const allItems: AiFlyerParseResult[] = [];
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunkItems = await this.parseChunkWithRetry(client, chunks[i], i + 1, chunks.length);
|
const chunkItems = await this.parseChunkWithRetry(
|
||||||
|
client,
|
||||||
|
chunks[i],
|
||||||
|
i + 1,
|
||||||
|
chunks.length,
|
||||||
|
debugSession,
|
||||||
|
);
|
||||||
allItems.push(...chunkItems);
|
allItems.push(...chunkItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupeItems(allItems);
|
const deduped = this.dedupeItems(allItems);
|
||||||
|
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-result.json`,
|
||||||
|
JSON.stringify(deduped, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-error.txt`,
|
||||||
|
this.toErrorMessage(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (err instanceof SyntaxError) {
|
if (err instanceof SyntaxError) {
|
||||||
this.logger.error(`JSON parse error: ${String(err)}`);
|
this.logger.error(`JSON parse error: ${String(err)}`);
|
||||||
throw new BadRequestException('AI returnerade ogiltigt JSON. Försök igen.');
|
throw new BadRequestException('AI returnerade ogiltigt JSON. Försök igen.');
|
||||||
@@ -155,11 +203,9 @@ Exempel på utdata:
|
|||||||
* Rensa AI-svaret för att kunna parse som JSON.
|
* Rensa AI-svaret för att kunna parse som JSON.
|
||||||
*/
|
*/
|
||||||
private sanitizeJsonResponse(content: string): string {
|
private sanitizeJsonResponse(content: string): string {
|
||||||
// Ta bort markdown fences
|
|
||||||
let cleaned = content.replace(/```json\n?/g, '').replace(/```\n?/g, '');
|
let cleaned = content.replace(/```json\n?/g, '').replace(/```\n?/g, '');
|
||||||
cleaned = cleaned.trim();
|
cleaned = cleaned.trim();
|
||||||
|
|
||||||
// Försök att extrahera JSON om det finns omgivande text
|
|
||||||
const jsonMatch = cleaned.match(/\[[\s\S]*\]/);
|
const jsonMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
cleaned = jsonMatch[0];
|
cleaned = jsonMatch[0];
|
||||||
@@ -205,14 +251,11 @@ Exempel på utdata:
|
|||||||
comparisonPrice: toNumber(item.comparisonPrice),
|
comparisonPrice: toNumber(item.comparisonPrice),
|
||||||
comparisonUnit: toString(item.comparisonUnit),
|
comparisonUnit: toString(item.comparisonUnit),
|
||||||
offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null),
|
offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null),
|
||||||
confidence: 0.85, // AI-parse får medelhög confidence
|
confidence: 0.85,
|
||||||
reasonCodes: ['ai_parsed'],
|
reasonCodes: ['ai_parsed'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Enkel normalisering av produktnamn.
|
|
||||||
*/
|
|
||||||
private normalizeName(name: string): string {
|
private normalizeName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -247,6 +290,7 @@ Exempel på utdata:
|
|||||||
chunkText: string,
|
chunkText: string,
|
||||||
chunkIndex: number,
|
chunkIndex: number,
|
||||||
totalChunks: number,
|
totalChunks: number,
|
||||||
|
debugSession: { dirPath: string; baseName: string } | null,
|
||||||
): Promise<AiFlyerParseResult[]> {
|
): Promise<AiFlyerParseResult[]> {
|
||||||
const textWindows = [3000, 2200, 1600];
|
const textWindows = [3000, 2200, 1600];
|
||||||
const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length));
|
const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length));
|
||||||
@@ -261,6 +305,14 @@ Exempel på utdata:
|
|||||||
`Sending request to Mistral Tiny (chunk ${chunkIndex}/${totalChunks}, attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`,
|
`Sending request to Mistral Tiny (chunk ${chunkIndex}/${totalChunks}, attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-prompt.txt`,
|
||||||
|
prompt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.withTimeout<any>(
|
const response = await this.withTimeout<any>(
|
||||||
client.chat({
|
client.chat({
|
||||||
model: 'mistral-tiny',
|
model: 'mistral-tiny',
|
||||||
@@ -278,6 +330,14 @@ Exempel på utdata:
|
|||||||
|
|
||||||
this.logger.debug(`Mistral response length: ${content.length} chars`);
|
this.logger.debug(`Mistral response length: ${content.length} chars`);
|
||||||
|
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-response.txt`,
|
||||||
|
String(content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const jsonString = this.sanitizeJsonResponse(content);
|
const jsonString = this.sanitizeJsonResponse(content);
|
||||||
const items = JSON.parse(jsonString) as Array<Record<string, unknown>>;
|
const items = JSON.parse(jsonString) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -288,6 +348,13 @@ Exempel på utdata:
|
|||||||
return items.map((item, idx) => this.normalizeAiItem(item, idx));
|
return items.map((item, idx) => this.normalizeAiItem(item, idx));
|
||||||
} catch (attemptErr) {
|
} catch (attemptErr) {
|
||||||
lastError = attemptErr;
|
lastError = attemptErr;
|
||||||
|
if (debugSession) {
|
||||||
|
await this.writeDebugFile(
|
||||||
|
debugSession,
|
||||||
|
`${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-error.txt`,
|
||||||
|
this.toErrorMessage(attemptErr),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!this.isRetryableError(attemptErr) || i === attempts - 1) {
|
if (!this.isRetryableError(attemptErr) || i === attempts - 1) {
|
||||||
throw attemptErr;
|
throw attemptErr;
|
||||||
}
|
}
|
||||||
@@ -332,6 +399,44 @@ Exempel på utdata:
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readBooleanEnv(key: string, fallback: boolean): boolean {
|
||||||
|
const raw = process.env[key];
|
||||||
|
if (!raw) return fallback;
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDebugSession(prefix: string): { dirPath: string; baseName: string } | null {
|
||||||
|
if (!this.debugEnabled) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const y = String(now.getFullYear()).slice(-2);
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
const datePart = `${y}${m}${d}`;
|
||||||
|
const timePart = `${hh}${mm}${ss}`;
|
||||||
|
const baseName = `${prefix}-${datePart}-${timePart}`;
|
||||||
|
const dirPath = path.join(this.debugDirectory, baseName);
|
||||||
|
return { dirPath, baseName };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeDebugFile(
|
||||||
|
debugSession: { dirPath: string; baseName: string } | null,
|
||||||
|
filename: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!debugSession) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(debugSession.dirPath, { recursive: true });
|
||||||
|
const filePath = path.join(debugSession.dirPath, filename);
|
||||||
|
await fs.promises.writeFile(filePath, content, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to write flyer debug file ${filename}: ${this.toErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private isRetryableError(err: unknown): boolean {
|
private isRetryableError(err: unknown): boolean {
|
||||||
if (err instanceof ServiceUnavailableException) return true;
|
if (err instanceof ServiceUnavailableException) return true;
|
||||||
const message = this.toErrorMessage(err).toLowerCase();
|
const message = this.toErrorMessage(err).toLowerCase();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
|
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
|
||||||
FLYER_AI_TIMEOUT_MS: "${FLYER_AI_TIMEOUT_MS:-30000}"
|
FLYER_AI_TIMEOUT_MS: "${FLYER_AI_TIMEOUT_MS:-30000}"
|
||||||
FLYER_AI_RETRIES: "${FLYER_AI_RETRIES:-2}"
|
FLYER_AI_RETRIES: "${FLYER_AI_RETRIES:-2}"
|
||||||
|
FLYER_AI_DEBUG: "${FLYER_AI_DEBUG:-0}"
|
||||||
|
FLYER_AI_DEBUG_DIR: "${FLYER_AI_DEBUG_DIR:-/app/debug}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
JWT_SECRET: "${JWT_SECRET}"
|
||||||
ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}"
|
ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}"
|
||||||
ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"
|
ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"
|
||||||
|
|||||||
@@ -1,148 +1,148 @@
|
|||||||
import '../domain/parsed_receipt_item.dart';
|
import '../domain/parsed_receipt_item.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
import '../domain/flyer_import_result.dart';
|
import '../domain/flyer_import_result.dart';
|
||||||
import '../domain/help_text_content.dart';
|
import '../domain/help_text_content.dart';
|
||||||
import '../domain/quick_import_result.dart';
|
import '../domain/quick_import_result.dart';
|
||||||
|
|
||||||
/// Handles communication with the quick-import API endpoint.
|
/// Handles communication with the quick-import API endpoint.
|
||||||
///
|
///
|
||||||
/// Two modes:
|
/// Two modes:
|
||||||
/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB).
|
/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB).
|
||||||
/// • [importUrl] — JSON body with `{ input: url }`.
|
/// • [importUrl] — JSON body with `{ input: url }`.
|
||||||
class ImportRepository {
|
class ImportRepository {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String _baseUrl;
|
final String _baseUrl;
|
||||||
|
|
||||||
ImportRepository({http.Client? client})
|
ImportRepository({http.Client? client})
|
||||||
: _client = client ?? http.Client(),
|
: _client = client ?? http.Client(),
|
||||||
_baseUrl = const String.fromEnvironment(
|
_baseUrl = const String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: '/api',
|
defaultValue: '/api',
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<HelpTextContent> fetchHelpTextByKey(
|
Future<HelpTextContent> fetchHelpTextByKey(
|
||||||
String key, {
|
String key, {
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
|
final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}');
|
||||||
final response = await _client.get(
|
final response = await _client.get(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Kunde inte hämta hjälptext: ${response.body}',
|
message: 'Kunde inte hämta hjälptext: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final parsed = _parseResponse(response);
|
final parsed = _parseResponse(response);
|
||||||
if (parsed is! Map<String, dynamic>) {
|
if (parsed is! Map<String, dynamic>) {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.unknown,
|
type: ApiErrorType.unknown,
|
||||||
message: 'Felaktigt svar vid hämtning av hjälptext.',
|
message: 'Felaktigt svar vid hämtning av hjälptext.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return HelpTextContent.fromJson(parsed);
|
return HelpTextContent.fromJson(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a receipt file for parsing (Fas 6b).
|
/// Upload a receipt file for parsing (Fas 6b).
|
||||||
/// Returns a list of parsed receipt items.
|
/// Returns a list of parsed receipt items.
|
||||||
Future<List<ParsedReceiptItem>> importReceiptFile({
|
Future<List<ParsedReceiptItem>> importReceiptFile({
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String filename,
|
required String filename,
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
developer.log('Starting receipt import for file: $filename', name: 'ImportRepository');
|
developer.log('Starting receipt import for file: $filename', name: 'ImportRepository');
|
||||||
|
|
||||||
final uri = Uri.parse('$_baseUrl/receipt-import');
|
final uri = Uri.parse('$_baseUrl/receipt-import');
|
||||||
final request = http.MultipartRequest('POST', uri);
|
final request = http.MultipartRequest('POST', uri);
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
request.headers['Authorization'] = 'Bearer $token';
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
request.files.add(
|
request.files.add(
|
||||||
http.MultipartFile.fromBytes('file', bytes, filename: filename),
|
http.MultipartFile.fromBytes('file', bytes, filename: filename),
|
||||||
);
|
);
|
||||||
|
|
||||||
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
|
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
|
||||||
|
|
||||||
final streamed = await _client.send(request).timeout(
|
final streamed = await _client.send(request).timeout(
|
||||||
const Duration(seconds: 120),
|
const Duration(seconds: 120),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network,
|
||||||
message: 'Importen tog för lång tid. Försök igen.',
|
message: 'Importen tog för lång tid. Försök igen.',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await http.Response.fromStream(streamed);
|
final response = await http.Response.fromStream(streamed);
|
||||||
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Fel vid import: ${response.body}',
|
message: 'Fel vid import: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final parsed = _parseResponse(response);
|
final parsed = _parseResponse(response);
|
||||||
developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository');
|
developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository');
|
||||||
|
|
||||||
// Backend returns ParsedReceiptItem[] directly as a JSON array
|
// Backend returns ParsedReceiptItem[] directly as a JSON array
|
||||||
if (parsed is List) {
|
if (parsed is List) {
|
||||||
final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository');
|
developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository');
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed is Map<String, dynamic>) {
|
if (parsed is Map<String, dynamic>) {
|
||||||
// Wrapped format: { items: [...] }
|
// Wrapped format: { items: [...] }
|
||||||
if (parsed.containsKey('items')) {
|
if (parsed.containsKey('items')) {
|
||||||
final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
if (items != null) {
|
if (items != null) {
|
||||||
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
|
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Markdown-based receipt fallback — parse lines into items
|
// Markdown-based receipt fallback — parse lines into items
|
||||||
if (parsed.containsKey('markdown')) {
|
if (parsed.containsKey('markdown')) {
|
||||||
developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository');
|
developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository');
|
||||||
final lines = (parsed['markdown'] as String).split('\n');
|
final lines = (parsed['markdown'] as String).split('\n');
|
||||||
final items = lines
|
final items = lines
|
||||||
.where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-'))
|
.where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-'))
|
||||||
.map((l) => ParsedReceiptItem(rawName: l.trim()))
|
.map((l) => ParsedReceiptItem(rawName: l.trim()))
|
||||||
.toList();
|
.toList();
|
||||||
developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository');
|
developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository');
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
|
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
|
||||||
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
|
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<FlyerImportResult> importFlyerFile({
|
Future<FlyerImportResult> importFlyerFile({
|
||||||
@@ -162,7 +162,7 @@ class ImportRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final streamed = await _client.send(request).timeout(
|
final streamed = await _client.send(request).timeout(
|
||||||
const Duration(seconds: 120),
|
const Duration(seconds: 300),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network,
|
||||||
@@ -220,225 +220,225 @@ class ImportRepository {
|
|||||||
if (parsed is! List) return const [];
|
if (parsed is! List) return const [];
|
||||||
return parsed.cast<Map<String, dynamic>>();
|
return parsed.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a file (PDF or image) for recipe extraction.
|
/// Upload a file (PDF or image) for recipe extraction.
|
||||||
///
|
///
|
||||||
/// [bytes] — raw file bytes from file_picker.
|
/// [bytes] — raw file bytes from file_picker.
|
||||||
/// [filename] — original filename (used for MIME detection on the server).
|
/// [filename] — original filename (used for MIME detection on the server).
|
||||||
/// [token] — JWT bearer token.
|
/// [token] — JWT bearer token.
|
||||||
Future<QuickImportResult> importFile({
|
Future<QuickImportResult> importFile({
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String filename,
|
required String filename,
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
developer.log('Starting file import for file: $filename', name: 'ImportRepository');
|
developer.log('Starting file import for file: $filename', name: 'ImportRepository');
|
||||||
|
|
||||||
final uri = Uri.parse('$_baseUrl/quick-import');
|
final uri = Uri.parse('$_baseUrl/quick-import');
|
||||||
final request = http.MultipartRequest('POST', uri);
|
final request = http.MultipartRequest('POST', uri);
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
request.headers['Authorization'] = 'Bearer $token';
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
request.files.add(
|
request.files.add(
|
||||||
http.MultipartFile.fromBytes('file', bytes, filename: filename),
|
http.MultipartFile.fromBytes('file', bytes, filename: filename),
|
||||||
);
|
);
|
||||||
|
|
||||||
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
|
developer.log('Sending request to: ${request.url}', name: 'ImportRepository');
|
||||||
|
|
||||||
final streamed = await _client.send(request).timeout(
|
final streamed = await _client.send(request).timeout(
|
||||||
const Duration(seconds: 120),
|
const Duration(seconds: 120),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network,
|
||||||
message: 'Importen tog för lång tid. Försök igen.',
|
message: 'Importen tog för lång tid. Försök igen.',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await http.Response.fromStream(streamed);
|
final response = await http.Response.fromStream(streamed);
|
||||||
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Fel vid import: ${response.body}',
|
message: 'Fel vid import: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Successfully imported file', name: 'ImportRepository');
|
developer.log('Successfully imported file', name: 'ImportRepository');
|
||||||
return QuickImportResult.fromJson(_parseResponse(response));
|
return QuickImportResult.fromJson(_parseResponse(response));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Exception during file import: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during file import: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import a recipe from a URL.
|
/// Import a recipe from a URL.
|
||||||
Future<QuickImportResult> importUrl({
|
Future<QuickImportResult> importUrl({
|
||||||
required String url,
|
required String url,
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
developer.log('Starting URL import for: $url', name: 'ImportRepository');
|
developer.log('Starting URL import for: $url', name: 'ImportRepository');
|
||||||
|
|
||||||
final uri = Uri.parse('$_baseUrl/quick-import');
|
final uri = Uri.parse('$_baseUrl/quick-import');
|
||||||
final response = await _client
|
final response = await _client
|
||||||
.post(
|
.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
},
|
},
|
||||||
body: jsonEncode({'input': url}),
|
body: jsonEncode({'input': url}),
|
||||||
)
|
)
|
||||||
.timeout(
|
.timeout(
|
||||||
const Duration(seconds: 120),
|
const Duration(seconds: 120),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network,
|
||||||
message: 'Importen tog för lång tid. Försök igen.',
|
message: 'Importen tog för lång tid. Försök igen.',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository');
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Fel vid import: ${response.body}',
|
message: 'Fel vid import: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Successfully imported URL', name: 'ImportRepository');
|
developer.log('Successfully imported URL', name: 'ImportRepository');
|
||||||
return QuickImportResult.fromJson(_parseResponse(response));
|
return QuickImportResult.fromJson(_parseResponse(response));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> upsertUnitMapping({
|
Future<void> upsertUnitMapping({
|
||||||
required int productId,
|
required int productId,
|
||||||
required String originalUnit,
|
required String originalUnit,
|
||||||
required String preferredUnit,
|
required String preferredUnit,
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
final normalizedOriginalUnit = originalUnit.trim().toLowerCase();
|
final normalizedOriginalUnit = originalUnit.trim().toLowerCase();
|
||||||
final normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
|
final normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
|
||||||
|
|
||||||
if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) {
|
if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normalizedOriginalUnit == normalizedPreferredUnit) {
|
if (normalizedOriginalUnit == normalizedPreferredUnit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}');
|
final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}');
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'productId': productId,
|
'productId': productId,
|
||||||
'originalUnit': normalizedOriginalUnit,
|
'originalUnit': normalizedOriginalUnit,
|
||||||
'preferredUnit': normalizedPreferredUnit,
|
'preferredUnit': normalizedPreferredUnit,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Kunde inte spara enhetsmappning: ${response.body}',
|
message: 'Kunde inte spara enhetsmappning: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save receipt items in a single atomic transaction.
|
/// Save receipt items in a single atomic transaction.
|
||||||
///
|
///
|
||||||
/// This endpoint handles:
|
/// This endpoint handles:
|
||||||
/// - Creating/validating products
|
/// - Creating/validating products
|
||||||
/// - Creating/merging inventory items
|
/// - Creating/merging inventory items
|
||||||
/// - Adding to pantry
|
/// - Adding to pantry
|
||||||
/// - Learning aliases
|
/// - Learning aliases
|
||||||
/// - Learning unit mappings
|
/// - Learning unit mappings
|
||||||
Future<Map<String, dynamic>> saveReceipt({
|
Future<Map<String, dynamic>> saveReceipt({
|
||||||
required List<Map<String, dynamic>> items,
|
required List<Map<String, dynamic>> items,
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository');
|
developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository');
|
||||||
|
|
||||||
final uri = Uri.parse('$_baseUrl/receipt-import/save');
|
final uri = Uri.parse('$_baseUrl/receipt-import/save');
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'items': items,
|
'items': items,
|
||||||
}),
|
}),
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 60),
|
const Duration(seconds: 60),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout');
|
developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.network,
|
type: ApiErrorType.network,
|
||||||
message: 'Sparandet tok för lång tid. Försök igen.',
|
message: 'Sparandet tok för lång tid. Försök igen.',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository');
|
developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository');
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||||
message: 'Fel vid sparande: ${response.body}',
|
message: 'Fel vid sparande: ${response.body}',
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = _parseResponse(response) as Map<String, dynamic>;
|
final result = _parseResponse(response) as Map<String, dynamic>;
|
||||||
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
|
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to map HTTP status codes to [ApiErrorType].
|
/// Helper method to map HTTP status codes to [ApiErrorType].
|
||||||
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
|
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
|
||||||
if (statusCode == 401) return ApiErrorType.unauthorized;
|
if (statusCode == 401) return ApiErrorType.unauthorized;
|
||||||
if (statusCode == 403) return ApiErrorType.forbidden;
|
if (statusCode == 403) return ApiErrorType.forbidden;
|
||||||
if (statusCode >= 500) return ApiErrorType.server;
|
if (statusCode >= 500) return ApiErrorType.server;
|
||||||
return ApiErrorType.unknown;
|
return ApiErrorType.unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the HTTP response and handle potential errors.
|
/// Parse the HTTP response and handle potential errors.
|
||||||
dynamic _parseResponse(http.Response response) {
|
dynamic _parseResponse(http.Response response) {
|
||||||
try {
|
try {
|
||||||
return jsonDecode(response.body);
|
return jsonDecode(response.body);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e);
|
developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e);
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
type: ApiErrorType.unknown,
|
type: ApiErrorType.unknown,
|
||||||
message: 'Felaktigt svar från servern: ${response.body}',
|
message: 'Felaktigt svar från servern: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user