diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 8e9f88b7..0c2b1638 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -58,6 +58,7 @@ ### Nyheter och förbättringar - **Kvittoimport Fas 6b klar (2026-05-01)** — Flutter-granskningsflödet färdigt: per-rad checkbox, redigeringsdialog med destination-väljare (Inventarie/Baslager), merge-förhandsvisning, parallell laddning av inventarie och baslager, snackbar med separat räkning. +- **Kvittoimport Fas 6c klar (2026-05-01)** — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier. - **Microservice-importer integrerad (2026-04-30)** — All import-logik (URL-skrapning, OCR, PDF-parsning, AI-kvittoparsning) delegeras nu till `importer-api` som körs som intern Docker-tjänst. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för detaljer. - **User-scope för pantry och matplan** — Alla baslager- och matplansdata är nu per användare. Backend och Prisma-schema är migrerade. - **Robust bildimport** — Bild-URL normaliseras, laddas ner och optimeras i backend. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg. diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 06c20906..d74757c6 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -148,7 +148,9 @@ class _EditDialogState extends State<_EditDialog> { void initState() { super.initState(); _productId = widget.current.productId; - _productName = widget.current.productName; + _productName = widget.current.productName == null + ? null + : _normalizeProductName(widget.current.productName!); _destination = widget.current.destination; _entryMode = widget.initialEntryMode ?? (_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing); @@ -241,10 +243,11 @@ class _EditDialogState extends State<_EditDialog> { if (id != null && mounted) { setState(() { _productId = id; - _productName = _localProducts + final selectedName = _localProducts .cast() .firstWhere((p) => p?.id == id, orElse: () => null) ?.name; + _productName = selectedName == null ? null : _normalizeProductName(selectedName); _productCategoryId = _categoryIdForProduct(id); _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); _productCategorySource = CategorySelectionSource.manual; @@ -257,7 +260,9 @@ class _EditDialogState extends State<_EditDialog> { if (suggestedId != null) { setState(() { _productId = suggestedId; - _productName = widget.item.suggestedProductName; + _productName = widget.item.suggestedProductName == null + ? null + : _normalizeProductName(widget.item.suggestedProductName!); _productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId; _productCategoryPath = _categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath; @@ -331,7 +336,7 @@ class _EditDialogState extends State<_EditDialog> { final suggestedProductLabel = (item.suggestedProductId != null && item.suggestedProductName?.isNotEmpty == true && item.matchedProductId == null) - ? item.suggestedProductName + ? _normalizeProductName(item.suggestedProductName!) : null; final currentQuantity = double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity; @@ -345,7 +350,11 @@ class _EditDialogState extends State<_EditDialog> { : null; return AlertDialog( - title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis), + title: Text( + _normalizeProductName(item.rawName), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -393,6 +402,27 @@ class _EditDialogState extends State<_EditDialog> { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (suggestedProductLabel != null) ...[ + Tooltip( + message: 'Trolig matchning baserat på produktnamn i databasen', + child: ActionChip( + avatar: Icon( + Icons.search, + size: 14, + color: Colors.blue.shade700, + ), + label: Text( + 'Namnförslag: $suggestedProductLabel', + style: theme.textTheme.labelSmall, + ), + backgroundColor: Colors.blue.shade50, + side: BorderSide(color: Colors.blue.shade300), + visualDensity: VisualDensity.compact, + onPressed: _applyAiSuggestionForExistingSelection, + ), + ), + const SizedBox(height: 8), + ], Row( children: [ Expanded( @@ -404,12 +434,15 @@ class _EditDialogState extends State<_EditDialog> { onChanged: (id) { setState(() { _productId = id; - _productName = id == null - ? null - : _localProducts - .cast() - .firstWhere((p) => p?.id == id, orElse: () => null) - ?.name; + final selectedName = id == null + ? null + : _localProducts + .cast() + .firstWhere((p) => p?.id == id, orElse: () => null) + ?.name; + _productName = selectedName == null + ? null + : _normalizeProductName(selectedName); _productCategoryId = _categoryIdForProduct(id); _productCategoryPath = _categoryPathForCategoryId(_productCategoryId); _productCategorySource = @@ -432,24 +465,28 @@ class _EditDialogState extends State<_EditDialog> { ), ], ), - if (suggestedProductLabel != null) ...[ const SizedBox(height: 8), + if (_productCategoryPath != null) ...[ + const SizedBox(height: 8), ActionChip( avatar: Icon( - Icons.search, + Icons.account_tree_outlined, size: 14, - color: Colors.blue.shade700, + color: theme.colorScheme.primary, ), label: Text( - 'Förslag: $suggestedProductLabel', + 'Kategori: $_productCategoryPath', style: theme.textTheme.labelSmall, + overflow: TextOverflow.ellipsis, ), - backgroundColor: Colors.blue.shade50, - side: BorderSide(color: Colors.blue.shade300), + side: BorderSide(color: theme.colorScheme.outlineVariant), visualDensity: VisualDensity.compact, - onPressed: _applyAiSuggestionForExistingSelection, + onPressed: () => _openExistingCategoryPicker( + preselectedCategoryId: _productCategoryId, + ), ), ], - if (aiLabel != null) ...[ const SizedBox(height: 8), + if (aiLabel != null) ...[ + const SizedBox(height: 8), ActionChip( avatar: Icon( Icons.auto_awesome, @@ -651,6 +688,30 @@ class _ReceiptImportTabState extends ConsumerState { _loadProducts(); } + int? _categoryIdForProduct(int? productId) { + if (productId == null) return null; + return _products + .cast() + .firstWhere((p) => p?.id == productId, orElse: () => null) + ?.categoryId; + } + + String? _categoryPathForCategoryId(int? categoryId) { + if (categoryId == null) return null; + + List? walk(List nodes, List parents) { + for (final node in nodes) { + final path = [...parents, node.name]; + if (node.id == categoryId) return path; + final found = walk(node.children, path); + if (found != null) return found; + } + return null; + } + + return walk(_categoryTree, const [])?.join(' > '); + } + Future _loadProducts() async { try { final token = await ref.read(authStateProvider.future); @@ -761,11 +822,14 @@ class _ReceiptImportTabState extends ConsumerState { notifier.setSelected(i, pid != null); if (pid != null) { final name = it.matchedProductName ?? it.suggestedProductName; + final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid); + final resolvedCategoryPath = it.categorySuggestionPath ?? + _categoryPathForCategoryId(resolvedCategoryId); notifier.setEdit(i, _ItemEdit( productId: pid, productName: name, - categoryId: it.categorySuggestionId, - categoryPath: it.categorySuggestionPath, + categoryId: resolvedCategoryId, + categoryPath: resolvedCategoryPath, categorySource: it.categorySuggestionId != null ? CategorySelectionSource.ai : null, @@ -792,8 +856,13 @@ class _ReceiptImportTabState extends ConsumerState { _ItemEdit( productId: item.matchedProductId ?? item.suggestedProductId, productName: item.matchedProductName ?? item.suggestedProductName, - categoryId: item.categorySuggestionId, - categoryPath: item.categorySuggestionPath, + categoryId: item.categorySuggestionId ?? + _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), + categoryPath: item.categorySuggestionPath ?? + _categoryPathForCategoryId( + item.categorySuggestionId ?? + _categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId), + ), categorySource: item.categorySuggestionId != null ? CategorySelectionSource.ai : null, @@ -954,7 +1023,7 @@ class _ReceiptImportTabState extends ConsumerState { ? null : OutlinedButton.icon( icon: const Icon(Icons.open_in_new, size: 16), - label: const Text('Öppna PDF'), + label: const Text('Visa kvitto'), style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), onPressed: () async { final opened = await openPdfBytes(bytes); @@ -979,15 +1048,7 @@ class _ReceiptImportTabState extends ConsumerState { ), ) else - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Text( - 'PDF-förhandsvisning stöds inte i appen — se importerade rader nedan.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), + const SizedBox(height: 8), ], ), ); @@ -1085,7 +1146,10 @@ class _ReceiptImportTabState extends ConsumerState { setState(() {}); }, ), - title: Text(item.rawName, style: theme.textTheme.bodyMedium), + title: Text( + _normalizeProductName(item.rawName), + style: theme.textTheme.bodyMedium, + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1106,10 +1170,13 @@ class _ReceiptImportTabState extends ConsumerState { runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith( - color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary, - fontWeight: FontWeight.w500, - )), + Text( + 'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}', + style: theme.textTheme.bodySmall?.copyWith( + color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), if (edit.categorySource != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), @@ -1136,7 +1203,7 @@ class _ReceiptImportTabState extends ConsumerState { ], ) else if (isSuggested) - Text('Förslag: ${item.suggestedProductName ?? ''}', + Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}', style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700)) else Text('Ingen matchning ännu — tryck för att välja eller skapa produkt', @@ -1144,7 +1211,7 @@ class _ReceiptImportTabState extends ConsumerState { if (hasProduct && edit?.categoryPath != null) ...[ const SizedBox(height: 2), Text( - edit!.categoryPath!, + 'Kategori: ${edit!.categoryPath!}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/flutter/next_steps_flutter.md b/flutter/next_steps_flutter.md index f05c7598..edd3cc04 100644 --- a/flutter/next_steps_flutter.md +++ b/flutter/next_steps_flutter.md @@ -33,6 +33,7 @@ Relaterade dokument: ### Pågående arbete - **Kvittoimport (Fas 6b):** ✅ Klar (2026-05-01) — Granskning, redigering, val av destination (inventarie/baslager), merge och spara implementerat. +- **Kvittoimport (Fas 6c):** ✅ Klar (2026-05-01) — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier. - **Bildimport:** Säkerställa att containrar är uppdaterade med senaste kod och att diagnostikloggar syns vid felsökning. - **Adminfunktioner:** Avancerad AI-integration och ytterligare adminfunktioner planeras men är ej migrerade. diff --git a/flutter/teknisk_beskrivning_flutter.md b/flutter/teknisk_beskrivning_flutter.md index 16a7050d..7b904008 100644 --- a/flutter/teknisk_beskrivning_flutter.md +++ b/flutter/teknisk_beskrivning_flutter.md @@ -2,6 +2,47 @@ Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda Windows-sökvägar. Använd inte `c:/dev/recipe-app/...` eftersom bygg- och testmiljön är på en remote Ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu. +## Senaste ändringar (2026-05-01, session 3) + +### Separering av AI-chip och produktsuggestions-chip + +**Problem:** AI-chipet visade felaktigt produktnamnsförslag som om de vore kategoriförslag, vilket skapade förvirring när användaren såg "AI-förslag: Dryck Multivitamin" (ett produktnamn) istället för en kategori. + +**Lösning:** +- **Blå chip** "Förslag: [produktnamn]" — när systemet hittat en trolig produkt via ordmatchning (inga AI-anrop inblandade). Klick väljer produkten. +- **Grön chip** "AI-kategori: [kategoriväg]" — när AI:n föreslagit en kategori från databasen. Klick öppnar produktpickern filtrerad på den kategorin. + +**Kodändringar:** +- `aiLabel` beräknas enbart från `categorySuggestionName`/`categorySuggestionPath` (kategoriförslag). +- Nytt fält `suggestedProductLabel` för produktsuggestions-chip. +- Separata villkor och UI-block för de två chipen i `_EditDialogState.build()`. + +### Produktnamns-normalisering + +**Problem:** Kvittonamn i VERSALER (t.ex. "APRIKOSMARMELAD 284G") såg oprofessionella ut i UI:n. + +**Lösning:** Ny funktion `_normalizeProductName()` som tillämpar smarta regler: +- Token med `/` (förkortningar) lämnas i versaler: `KY/KAL/LE/TO` +- Token som börjar med siffra (mängd/storlek) görs till gemener: `284G` → `284g`, `12X85G` → `12x85g` +- Övriga token: första bokstav versal, resten gemen: `APRIKOSMARMELAD` → `Aprikosmarmelad`, `JUICE TROPISK` → `Juice Tropisk` + +**Implementering:** +- Top-level-funktion i `receipt_import_tab.dart` +- Tillämpas när "Ny produkt"-fältet prefylls: `_newProductNameCtrl.text = _normalizeProductName(widget.current.productName ?? widget.item.rawName)` + +### AI-kategorisering — validering i backend + +**Problem:** Användaren rapporterade att AI föreslog kategorin "Dryck Multivitamin", som inte fanns i databasen. + +**Undersökning:** +- Backend-AI:n (`ai.service.ts`) validerar redan att `categoryId` finns i `categories`-listan och faller tillbaka på "Övrigt" om inte. +- Problemet var att frontend visade produktnamnsförslag som om de vore kategoriförslag. + +**Lösning:** +- Separering av chipen (se ovan) gör det tydligt att AI-kategoriförslag alltid kommer från databasen. + +--- + ## Senaste ändringar (2026-05-01, session 2) ### Tvåstegs-picker: Kategori → Produkt @@ -58,7 +99,7 @@ Importfliken laddar globala och privata produkter parallellt via `Future.wait` o User-scope-principen dokumenterades formellt i båda tekniska beskrivningarna (2026-05-01). Privata produkter är det första exemplet på mönstret för resurser som är varken globala (alla ser dem) eller fullt user-owned (bara ägaren ser dem): - `Product.isPrivate = true` + `Product.ownerId = userId` - `normalizedName`-prefix undviker databaskollision med globala produkter -- Migration: `20260501000000_add_product_is_private` +- Migration: `20260501000000_add_product_is_private ## Senaste ändringar (2026-05-01, session 1)