Update flyerimport. flutter timeout 300 sek
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 4m58s
Test Suite / flutter-quality (push) Failing after 1m41s

This commit is contained in:
Nils-Johan Gynther
2026-05-19 20:53:39 +02:00
parent 33190bd8e0
commit 8b8f8b7b6f
7 changed files with 523 additions and 411 deletions
+2
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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();
+2
View File
@@ -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}',
); );
} }
} }
} }