feat(receipt-import): enhance bread category detection and improve session management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-03 16:34:15 +02:00
parent a1c4a2f24d
commit fa7f225ee5
7 changed files with 299 additions and 12 deletions
+1
View File
@@ -57,6 +57,7 @@
## Status — senast genomgånget: 2026-05-02 ## Status — senast genomgånget: 2026-05-02
### Nyheter och förbättringar ### 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`. - **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. - **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. - **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.
@@ -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): { function inferPackageDebugFromRawName(rawName: string): {
packageCount: number; packageCount: number;
packQuantity: number | null; packQuantity: number | null;
@@ -465,6 +481,24 @@ export class ReceiptImportService {
); );
} }
private resolveBreadCategory(
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
) {
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( private applyHardCategoryOverrides(
signalText: string, signalText: string,
suggestion: CategorySuggestion, suggestion: CategorySuggestion,
@@ -531,6 +565,14 @@ export class ReceiptImportService {
// ── Regel: Kött/chark (bacon/fläsk m.m.) ──────────────────────────── // ── Regel: Kött/chark (bacon/fläsk m.m.) ────────────────────────────
const hasPorkSignal = hasPorkLikeSignal(normalized); 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) { if (hasPorkSignal) {
const l3Pork = this.resolvePorkCategory(categories); const l3Pork = this.resolvePorkCategory(categories);
const hit = toSuggestion(l3Pork, 'high'); 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; return suggestion;
} }
} }
+4
View File
@@ -134,6 +134,10 @@ INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Hamburgerbröd', c2.id FROM `Category` c1 SELECT 'Hamburgerbröd', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Fastfoodbröd' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Fastfoodbröd'
WHERE c1.name = 'Bröd & Kakor' AND c1.parentId IS NULL; 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 ─────────────── -- ── NIVÅ 3: under Bröd & Kakor > Kex & Kakor ───────────────
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
@@ -1,5 +1,8 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:convert';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/parsed_receipt_item.dart'; import '../domain/parsed_receipt_item.dart';
// ── Destination-enum ────────────────────────────────────────────────────────── // ── Destination-enum ──────────────────────────────────────────────────────────
@@ -43,6 +46,7 @@ class ItemEdit {
class ReceiptImportSession { class ReceiptImportSession {
final Uint8List? fileBytes; final Uint8List? fileBytes;
final String? fileExtension; final String? fileExtension;
final String? fileName;
final List<ParsedReceiptItem>? items; // null = ej parsad än final List<ParsedReceiptItem>? items; // null = ej parsad än
final Map<int, ItemEdit> edits; final Map<int, ItemEdit> edits;
final Map<int, bool> selected; final Map<int, bool> selected;
@@ -50,6 +54,7 @@ class ReceiptImportSession {
const ReceiptImportSession({ const ReceiptImportSession({
this.fileBytes, this.fileBytes,
this.fileExtension, this.fileExtension,
this.fileName,
this.items, this.items,
this.edits = const {}, this.edits = const {},
this.selected = const {}, this.selected = const {},
@@ -58,6 +63,7 @@ class ReceiptImportSession {
ReceiptImportSession copyWith({ ReceiptImportSession copyWith({
Uint8List? fileBytes, Uint8List? fileBytes,
String? fileExtension, String? fileExtension,
String? fileName,
List<ParsedReceiptItem>? items, List<ParsedReceiptItem>? items,
Map<int, ItemEdit>? edits, Map<int, ItemEdit>? edits,
Map<int, bool>? selected, Map<int, bool>? selected,
@@ -65,42 +71,168 @@ class ReceiptImportSession {
ReceiptImportSession( ReceiptImportSession(
fileBytes: fileBytes ?? this.fileBytes, fileBytes: fileBytes ?? this.fileBytes,
fileExtension: fileExtension ?? this.fileExtension, fileExtension: fileExtension ?? this.fileExtension,
fileName: fileName ?? this.fileName,
items: items ?? this.items, items: items ?? this.items,
edits: edits ?? this.edits, edits: edits ?? this.edits,
selected: selected ?? this.selected, selected: selected ?? this.selected,
); );
Map<String, dynamic> 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<String, dynamic> json) {
final rawItems = json['items'] as List<dynamic>?;
final items = rawItems
?.whereType<Map<String, dynamic>>()
.map(ParsedReceiptItem.fromJson)
.toList();
final rawEdits = (json['edits'] as Map<String, dynamic>? ?? {});
final edits = <int, ItemEdit>{};
for (final entry in rawEdits.entries) {
final idx = int.tryParse(entry.key);
final value = entry.value;
if (idx == null || value is! Map<String, dynamic>) 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<String, dynamic>? ?? {});
final selected = <int, bool>{};
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 ────────────────────────────────────────────────────────────────── // ── Notifier ──────────────────────────────────────────────────────────────────
class ReceiptImportSessionNotifier class ReceiptImportSessionNotifier
extends Notifier<ReceiptImportSession?> { extends Notifier<ReceiptImportSession?> {
static const _storageKey = 'receipt_import_session_v1';
@override @override
ReceiptImportSession? build() => null; ReceiptImportSession? build() => null;
/// Ny fil vald — återställer items/edits/selected, behåller ingenting gammalt. /// Ny fil vald — återställer items/edits/selected, behåller ingenting gammalt.
void setFile(Uint8List bytes, String extension) { void setFile(Uint8List bytes, String extension, {String? fileName}) {
state = ReceiptImportSession(fileBytes: bytes, fileExtension: extension); state = ReceiptImportSession(
fileBytes: bytes,
fileExtension: extension,
fileName: fileName,
);
unawaited(_persist());
} }
void setItems(List<ParsedReceiptItem> items) { void setItems(List<ParsedReceiptItem> items) {
// Bevara filinformationen när items sätts // Bevara filinformationen när items sätts
state = (state ?? const ReceiptImportSession()).copyWith(items: items); state = (state ?? const ReceiptImportSession()).copyWith(items: items);
unawaited(_persist());
} }
void setEdit(int index, ItemEdit edit) { void setEdit(int index, ItemEdit edit) {
if (state == null) return; if (state == null) return;
final edits = Map<int, ItemEdit>.from(state!.edits)..[index] = edit; final edits = Map<int, ItemEdit>.from(state!.edits)..[index] = edit;
state = state!.copyWith(edits: edits); state = state!.copyWith(edits: edits);
unawaited(_persist());
} }
void setSelected(int index, bool value) { void setSelected(int index, bool value) {
if (state == null) return; if (state == null) return;
final selected = Map<int, bool>.from(state!.selected)..[index] = value; final selected = Map<int, bool>.from(state!.selected)..[index] = value;
state = state!.copyWith(selected: selected); state = state!.copyWith(selected: selected);
unawaited(_persist());
} }
void clear() => state = null; Future<void> 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<String, dynamic>) {
state = ReceiptImportSession.fromJson(decoded);
}
} catch (_) {
await prefs.remove(_storageKey);
}
}
void clear() {
state = null;
unawaited(_removePersisted());
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
if (state == null) {
await prefs.remove(_storageKey);
return;
}
await prefs.setString(_storageKey, jsonEncode(state!.toJson()));
}
Future<void> _removePersisted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
}
} }
final receiptImportSessionProvider = final receiptImportSessionProvider =
@@ -51,4 +51,27 @@ class ParsedReceiptItem {
categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(), categorySuggestionId: (cat?['categoryId'] as num?)?.toInt(),
); );
} }
Map<String, dynamic> 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,
},
};
}
} }
@@ -66,6 +66,30 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
void initState() { void initState() {
super.initState(); super.initState();
_loadProducts(); _loadProducts();
_restoreSession();
}
Future<void> _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) { int? _categoryIdForProduct(int? productId) {
@@ -244,11 +268,18 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
ref.read(receiptImportSessionProvider.notifier).setFile( ref.read(receiptImportSessionProvider.notifier).setFile(
file.bytes!, file.bytes!,
file.extension?.toLowerCase() ?? '', file.extension?.toLowerCase() ?? '',
fileName: file.name,
); );
} }
Future<void> _submit() async { Future<void> _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; }); setState(() { _isLoading = true; });
// Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här // Obs: setFile() i _pickFile har redan placerat bytes i session; clear() behövs ej här
@@ -256,8 +287,8 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider); final repo = ref.read(importRepositoryProvider);
final items = await repo.importReceiptFile( final items = await repo.importReceiptFile(
bytes: _pickedFile!.bytes!, bytes: submitBytes,
filename: _pickedFile!.name, filename: submitFileName,
token: token, token: token,
); );
if (!mounted) return; if (!mounted) return;
@@ -508,7 +539,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
} }
} }
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; int get _selectedCount => _selected.values.where((v) => v).length;
// ── Kvittobild / PDF-förhandsvisning ─────────────────────────────────────── // ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
@@ -570,6 +606,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final session = ref.watch(receiptImportSessionProvider); final session = ref.watch(receiptImportSessionProvider);
final theme = Theme.of(context); final theme = Theme.of(context);
final items = session?.items; final items = session?.items;
final selectedFileName = _pickedFile?.name ?? session?.fileName;
final selectedFileSizeBytes =
_pickedFile?.size ?? session?.fileBytes?.length;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -584,12 +623,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile, onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file), 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), const SizedBox(height: 8),
Text( Text(
'${(_pickedFile!.size / 1024).round()} KB', '${(selectedFileSizeBytes / 1024).round()} KB',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
), ),
], ],
@@ -83,6 +83,15 @@ double? extractMultipackCountFromRawName(String rawName) {
return double.tryParse(match.group(1)!); 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 ──────────────────────────────────────────────────────── // ── Paketfältsinferens ────────────────────────────────────────────────────────
typedef PackageFields = ({ typedef PackageFields = ({
@@ -102,6 +111,18 @@ PackageFields inferPackageFields({
final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0; final safeCount = (quantity != null && quantity > 0) ? quantity : 1.0;
final extracted = extractPackageSizeFromRawName(rawName); final extracted = extractPackageSizeFromRawName(rawName);
final multipackCount = extractMultipackCountFromRawName(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 // Om rånamnet innehåller storlek (t.ex. "5dl") och enhet saknas eller är
// paketliknande — använd extraherad storlek. // paketliknande — använd extraherad storlek.
@@ -132,11 +153,14 @@ PackageFields inferPackageFields({
} }
if (isPackageLikeUnit(normalizedUnit) && extracted != null) { if (isPackageLikeUnit(normalizedUnit) && extracted != null) {
final packageCount = pieceCount != null && pieceCount > 0
? pieceCount
: quantity;
return ( return (
packQuantity: extracted.packQuantity, packQuantity: extracted.packQuantity,
packUnit: extracted.packUnit, packUnit: extracted.packUnit,
packageCount: quantity, packageCount: packageCount,
totalQuantity: extracted.packQuantity * quantity, totalQuantity: extracted.packQuantity * packageCount,
totalUnit: extracted.packUnit, totalUnit: extracted.packUnit,
); );
} }