From fa7f225ee5f4631f2bfbc7f0dfc20f910dbe9909 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 3 May 2026 16:34:15 +0200 Subject: [PATCH] feat(receipt-import): enhance bread category detection and improve session management Co-authored-by: Copilot --- NEXT_STEPS.md | 1 + .../receipt-import/receipt-import.service.ts | 64 ++++++++ db/seeds/seed_all.sql | 4 + .../import/data/receipt_import_session.dart | 138 +++++++++++++++++- .../import/domain/parsed_receipt_item.dart | 23 +++ .../presentation/receipt_import_tab.dart | 53 ++++++- .../import/utils/receipt_import_utils.dart | 28 +++- 7 files changed, 299 insertions(+), 12 deletions(-) diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index f7e70421..d42c194a 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -57,6 +57,7 @@ ## Status — senast genomgånget: 2026-05-02 ### Nyheter och förbättringar +- **Kvittoimport — brödregler och guardrails utökade (2026-05-03)** — `ruleBasedCategorySuggestion()` och `applyContradictionGuard()` täcker nu fler brödsignaler (t.ex. roast'n toast, toastbröd, formbröd, lantbröd, fullkornsbröd, franska, limpan/brod) och remappar felaktiga AI-träffar till `Bröd & Kakor > Bröd`/`Rostbröd`. - **Prisma-schema justerat för Product (2026-05-03)** — `Product.brand` är borttaget från Prisma-modellen och från produktuppdatering i backend eftersom kolumnen saknas i aktuell databas. Detta förhindrar Prisma-felet `The column recipe_app.Product.brand does not exist in the current database` vid t.ex. `findUnique`. - **Produkter user-scoped — ny databasarkitektur (2026-05-02)** — `Product.ownerId` är nu obligatorisk (non-nullable). Alla globala seed-produkter är borttagna. Varje produkt ägs av en enskild användare och raderas vid kontoradering (CASCADE). `seed_all.sql` innehåller nu enbart kategorier. Kvittoimportens matchning filtrerar på `ownerId = userId` från JWT. Se TEKNISK_BESKRIVNING.md för fullständig beskrivning. - **Kategorier utökade (2026-05-02)** — Nya L2/L3-noder: `Bröd & Kakor > Kondis & fika > Kaffebröd` (wienerbröd, donuts, munkar m.m.) och `Dryck > Te & choklad > Te` (chai, vanilla chai, ceylon te m.m.). Nya L3-noder under `Mejeri, ost & ägg > Allergi mejeri`: Laktosfri mjölk, Filmjölk & Yoghurt, Kvarg & Cottage cheese, Matfett, Allergi matlagning. diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 7d0f6a51..3354c55d 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -60,6 +60,22 @@ function hasPorkLikeSignal(normalized: string): boolean { ); } +function hasBreadLikeSignal(normalized: string): boolean { + return ( + /\brostbrod\b/.test(normalized) || + /\brost\s*n\s*toast\b/.test(normalized) || + /\broast\s*n\s*toast\b/.test(normalized) || + /\btoastbrod\b/.test(normalized) || + /\bformbrod\b/.test(normalized) || + /\blantbrod\b/.test(normalized) || + /\bfullkornsbrod\b/.test(normalized) || + /\bfranska\b/.test(normalized) || + /\blimpa\b/.test(normalized) || + /\bbrod\b/.test(normalized) || + /\btoast\b/.test(normalized) + ); +} + function inferPackageDebugFromRawName(rawName: string): { packageCount: number; packQuantity: number | null; @@ -465,6 +481,24 @@ export class ReceiptImportService { ); } + private resolveBreadCategory( + categories: Awaited>, + ) { + return ( + categories.find( + (c) => + c.name.toLowerCase() === 'rostbröd' && + c.path.toLowerCase().startsWith('bröd & kakor > bröd > '), + ) || + categories.find( + (c) => + c.name.toLowerCase() === 'bröd' && + c.path.toLowerCase() === 'bröd & kakor > bröd', + ) || + categories.find((c) => c.path.toLowerCase() === 'bröd & kakor') + ); + } + private applyHardCategoryOverrides( signalText: string, suggestion: CategorySuggestion, @@ -531,6 +565,14 @@ export class ReceiptImportService { // ── Regel: Kött/chark (bacon/fläsk m.m.) ──────────────────────────── const hasPorkSignal = hasPorkLikeSignal(normalized); + const hasToastBreadSignal = hasBreadLikeSignal(normalized); + + if (hasToastBreadSignal) { + const bread = this.resolveBreadCategory(categories); + const hit = toSuggestion(bread, 'high'); + if (hit) return hit; + } + if (hasPorkSignal) { const l3Pork = this.resolvePorkCategory(categories); const hit = toSuggestion(l3Pork, 'high'); @@ -975,6 +1017,28 @@ export class ReceiptImportService { }; } + const hasToastBreadSignal = hasBreadLikeSignal(normalized); + + if (hasToastBreadSignal) { + const aiPath = suggestion.path.toLowerCase(); + const isOutsideBread = !aiPath.startsWith('bröd & kakor > bröd'); + if (!isOutsideBread) return suggestion; + + const bread = this.resolveBreadCategory(categories); + if (!bread) return suggestion; + + this.logger.log( + `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${bread.path}"`, + ); + return { + categoryId: bread.id, + categoryName: bread.name, + path: bread.path, + confidence: 'high', + usedFallback: true, + }; + } + return suggestion; } } diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index da5c0589..7ca2efa1 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -134,6 +134,10 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Hamburgerbröd', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Fastfoodbröd' WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Korvbröd', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Fastfoodbröd' + WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL; -- ── NIVÅ 3: under Bröd & Kakor > Kex & Kakor ─────────────── INSERT INTO `Category` (`name`, `parentId`) diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart index 1beb69ab..9c61c1b3 100644 --- a/flutter/lib/features/import/data/receipt_import_session.dart +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -1,5 +1,8 @@ import 'dart:typed_data'; +import 'dart:convert'; +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../domain/parsed_receipt_item.dart'; // ── Destination-enum ────────────────────────────────────────────────────────── @@ -43,6 +46,7 @@ class ItemEdit { class ReceiptImportSession { final Uint8List? fileBytes; final String? fileExtension; + final String? fileName; final List? items; // null = ej parsad än final Map edits; final Map selected; @@ -50,6 +54,7 @@ class ReceiptImportSession { const ReceiptImportSession({ this.fileBytes, this.fileExtension, + this.fileName, this.items, this.edits = const {}, this.selected = const {}, @@ -58,6 +63,7 @@ class ReceiptImportSession { ReceiptImportSession copyWith({ Uint8List? fileBytes, String? fileExtension, + String? fileName, List? items, Map? edits, Map? selected, @@ -65,42 +71,168 @@ class ReceiptImportSession { ReceiptImportSession( fileBytes: fileBytes ?? this.fileBytes, fileExtension: fileExtension ?? this.fileExtension, + fileName: fileName ?? this.fileName, items: items ?? this.items, edits: edits ?? this.edits, selected: selected ?? this.selected, ); + + Map toJson() => { + 'fileBytes': fileBytes == null ? null : base64Encode(fileBytes!), + 'fileExtension': fileExtension, + 'fileName': fileName, + 'items': items?.map((e) => e.toJson()).toList(), + 'edits': edits.map((key, value) => MapEntry(key.toString(), { + 'productId': value.productId, + 'productName': value.productName, + 'categoryId': value.categoryId, + 'categoryPath': value.categoryPath, + 'categorySource': value.categorySource?.name, + 'quantity': value.quantity, + 'unit': value.unit, + 'packQuantity': value.packQuantity, + 'packUnit': value.packUnit, + 'packageCount': value.packageCount, + 'destination': value.destination.name, + })), + 'selected': selected.map((key, value) => MapEntry(key.toString(), value)), + }; + + factory ReceiptImportSession.fromJson(Map json) { + final rawItems = json['items'] as List?; + final items = rawItems + ?.whereType>() + .map(ParsedReceiptItem.fromJson) + .toList(); + + final rawEdits = (json['edits'] as Map? ?? {}); + final edits = {}; + for (final entry in rawEdits.entries) { + final idx = int.tryParse(entry.key); + final value = entry.value; + if (idx == null || value is! Map) continue; + edits[idx] = ItemEdit( + productId: (value['productId'] as num?)?.toInt(), + productName: value['productName'] as String?, + categoryId: (value['categoryId'] as num?)?.toInt(), + categoryPath: value['categoryPath'] as String?, + categorySource: switch (value['categorySource']) { + 'ai' => CategorySelectionSource.ai, + 'manual' => CategorySelectionSource.manual, + _ => null, + }, + quantity: (value['quantity'] as num?)?.toDouble(), + unit: value['unit'] as String?, + packQuantity: (value['packQuantity'] as num?)?.toDouble(), + packUnit: value['packUnit'] as String?, + packageCount: (value['packageCount'] as num?)?.toDouble(), + destination: (value['destination'] as String?) == ImportDestination.pantry.name + ? ImportDestination.pantry + : ImportDestination.inventory, + ); + } + + final rawSelected = (json['selected'] as Map? ?? {}); + final selected = {}; + for (final entry in rawSelected.entries) { + final idx = int.tryParse(entry.key); + if (idx == null) continue; + selected[idx] = entry.value == true; + } + + Uint8List? fileBytes; + final fileBytesRaw = json['fileBytes']; + if (fileBytesRaw is String && fileBytesRaw.isNotEmpty) { + try { + fileBytes = base64Decode(fileBytesRaw); + } catch (_) { + fileBytes = null; + } + } + + return ReceiptImportSession( + fileBytes: fileBytes, + fileExtension: json['fileExtension'] as String?, + fileName: json['fileName'] as String?, + items: items, + edits: edits, + selected: selected, + ); + } } // ── Notifier ────────────────────────────────────────────────────────────────── class ReceiptImportSessionNotifier extends Notifier { + static const _storageKey = 'receipt_import_session_v1'; + @override ReceiptImportSession? build() => null; /// Ny fil vald — återställer items/edits/selected, behåller ingenting gammalt. - void setFile(Uint8List bytes, String extension) { - state = ReceiptImportSession(fileBytes: bytes, fileExtension: extension); + void setFile(Uint8List bytes, String extension, {String? fileName}) { + state = ReceiptImportSession( + fileBytes: bytes, + fileExtension: extension, + fileName: fileName, + ); + unawaited(_persist()); } void setItems(List items) { // Bevara filinformationen när items sätts state = (state ?? const ReceiptImportSession()).copyWith(items: items); + unawaited(_persist()); } void setEdit(int index, ItemEdit edit) { if (state == null) return; final edits = Map.from(state!.edits)..[index] = edit; state = state!.copyWith(edits: edits); + unawaited(_persist()); } void setSelected(int index, bool value) { if (state == null) return; final selected = Map.from(state!.selected)..[index] = value; state = state!.copyWith(selected: selected); + unawaited(_persist()); } - void clear() => state = null; + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) return; + + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + state = ReceiptImportSession.fromJson(decoded); + } + } catch (_) { + await prefs.remove(_storageKey); + } + } + + void clear() { + state = null; + unawaited(_removePersisted()); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + if (state == null) { + await prefs.remove(_storageKey); + return; + } + await prefs.setString(_storageKey, jsonEncode(state!.toJson())); + } + + Future _removePersisted() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_storageKey); + } } final receiptImportSessionProvider = diff --git a/flutter/lib/features/import/domain/parsed_receipt_item.dart b/flutter/lib/features/import/domain/parsed_receipt_item.dart index de7419da..bd3ddaaa 100644 --- a/flutter/lib/features/import/domain/parsed_receipt_item.dart +++ b/flutter/lib/features/import/domain/parsed_receipt_item.dart @@ -51,4 +51,27 @@ class ParsedReceiptItem { categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(), ); } + + Map toJson() { + return { + 'rawName': rawName, + 'quantity': quantity, + 'unit': unit, + 'price': price, + 'brand': brand, + 'origin': origin, + 'matchedProductId': matchedProductId, + 'matchedProductName': matchedProductName, + 'suggestedProductId': suggestedProductId, + 'suggestedProductName': suggestedProductName, + if (categorySuggestionId != null || + categorySuggestionName != null || + categorySuggestionPath != null) + 'categorySuggestion': { + 'categoryId': categorySuggestionId, + 'categoryName': categorySuggestionName, + 'path': categorySuggestionPath, + }, + }; + } } diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index c2e815b9..cec4f5d5 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -66,6 +66,30 @@ class _ReceiptImportTabState extends ConsumerState { void initState() { super.initState(); _loadProducts(); + _restoreSession(); + } + + Future _restoreSession() async { + final notifier = ref.read(receiptImportSessionProvider.notifier); + await notifier.restore(); + final session = ref.read(receiptImportSessionProvider); + if (!mounted || session?.fileBytes == null) return; + + final fileName = + session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}'; + final bytes = session!.fileBytes!; + setState(() { + _pickedFile = PlatformFile( + name: fileName, + size: bytes.length, + bytes: bytes, + extension: session.fileExtension, + ); + }); + + if (session.items != null) { + await _loadInventory(); + } } int? _categoryIdForProduct(int? productId) { @@ -244,11 +268,18 @@ class _ReceiptImportTabState extends ConsumerState { ref.read(receiptImportSessionProvider.notifier).setFile( file.bytes!, file.extension?.toLowerCase() ?? '', + fileName: file.name, ); } Future _submit() async { - if (_pickedFile == null) return; + final session = ref.read(receiptImportSessionProvider); + final submitBytes = _pickedFile?.bytes ?? session?.fileBytes; + if (submitBytes == null) return; + + final submitFileName = + _pickedFile?.name ?? session?.fileName ?? 'kvitto.${session?.fileExtension ?? 'pdf'}'; + setState(() { _isLoading = true; }); // Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här @@ -256,8 +287,8 @@ class _ReceiptImportTabState extends ConsumerState { final token = await ref.read(authStateProvider.future); final repo = ref.read(importRepositoryProvider); final items = await repo.importReceiptFile( - bytes: _pickedFile!.bytes!, - filename: _pickedFile!.name, + bytes: submitBytes, + filename: submitFileName, token: token, ); if (!mounted) return; @@ -508,7 +539,12 @@ class _ReceiptImportTabState extends ConsumerState { } } - bool get _canSubmit => !_isLoading && _pickedFile?.bytes != null; + bool get _canSubmit { + if (_isLoading) return false; + if (_pickedFile?.bytes != null) return true; + final session = ref.read(receiptImportSessionProvider); + return session?.fileBytes != null; + } int get _selectedCount => _selected.values.where((v) => v).length; // ── Kvittobild / PDF-förhandsvisning ─────────────────────────────────────── @@ -570,6 +606,9 @@ class _ReceiptImportTabState extends ConsumerState { final session = ref.watch(receiptImportSessionProvider); final theme = Theme.of(context); final items = session?.items; + final selectedFileName = _pickedFile?.name ?? session?.fileName; + final selectedFileSizeBytes = + _pickedFile?.size ?? session?.fileBytes?.length; return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -584,12 +623,12 @@ class _ReceiptImportTabState extends ConsumerState { OutlinedButton.icon( onPressed: _isLoading ? null : _pickFile, icon: const Icon(Icons.attach_file), - label: Text(_pickedFile == null ? 'Välj kvittofil' : _pickedFile!.name), + label: Text(selectedFileName == null ? 'Välj kvittofil' : selectedFileName), ), - if (_pickedFile != null) ...[ + if (selectedFileSizeBytes != null) ...[ const SizedBox(height: 8), Text( - '${(_pickedFile!.size / 1024).round()} KB', + '${(selectedFileSizeBytes / 1024).round()} KB', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), ), ], diff --git a/flutter/lib/features/import/utils/receipt_import_utils.dart b/flutter/lib/features/import/utils/receipt_import_utils.dart index e5ab44a6..582456e6 100644 --- a/flutter/lib/features/import/utils/receipt_import_utils.dart +++ b/flutter/lib/features/import/utils/receipt_import_utils.dart @@ -83,6 +83,15 @@ double? extractMultipackCountFromRawName(String rawName) { return double.tryParse(match.group(1)!); } +double? extractPieceCountFromRawName(String rawName) { + final match = RegExp( + r'(\d+(?:[\.,]\d+)?)\s*st\b', + caseSensitive: false, + ).firstMatch(rawName); + if (match == null) return null; + return double.tryParse(match.group(1)!.replaceAll(',', '.')); +} + // ── Paketfältsinferens ──────────────────────────────────────────────────────── typedef PackageFields = ({ @@ -102,6 +111,18 @@ PackageFields inferPackageFields({ final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0; final extracted = extractPackageSizeFromRawName(rawName); final multipackCount = extractMultipackCountFromRawName(rawName); + final pieceCount = extractPieceCountFromRawName(rawName); + + // Exempel: "SALAMI PEPPAR 150G 2st" ska ge pack=150g och antal förpackningar=2. + if (extracted != null && pieceCount != null && pieceCount > 0) { + return ( + packQuantity: extracted.packQuantity, + packUnit: extracted.packUnit, + packageCount: pieceCount, + totalQuantity: extracted.packQuantity * pieceCount, + totalUnit: extracted.packUnit, + ); + } // Om rånamnet innehåller storlek (t.ex. "5dl") och enhet saknas eller är // paketliknande — använd extraherad storlek. @@ -132,11 +153,14 @@ PackageFields inferPackageFields({ } if (isPackageLikeUnit(normalizedUnit) && extracted != null) { + final packageCount = pieceCount != null && pieceCount > 0 + ? pieceCount + : quantity; return ( packQuantity: extracted.packQuantity, packUnit: extracted.packUnit, - packageCount: quantity, - totalQuantity: extracted.packQuantity * quantity, + packageCount: packageCount, + totalQuantity: extracted.packQuantity * packageCount, totalUnit: extracted.packUnit, ); }