feat(receipt-import): enhance bread category detection and improve session management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<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(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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<ParsedReceiptItem>? items; // null = ej parsad än
|
||||
final Map<int, ItemEdit> edits;
|
||||
final Map<int, bool> 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<ParsedReceiptItem>? items,
|
||||
Map<int, ItemEdit>? edits,
|
||||
Map<int, bool>? 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<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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReceiptImportSessionNotifier
|
||||
extends Notifier<ReceiptImportSession?> {
|
||||
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<ParsedReceiptItem> 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<int, ItemEdit>.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<int, bool>.from(state!.selected)..[index] = value;
|
||||
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 =
|
||||
|
||||
@@ -51,4 +51,27 @@ class ParsedReceiptItem {
|
||||
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() {
|
||||
super.initState();
|
||||
_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) {
|
||||
@@ -244,11 +268,18 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
ref.read(receiptImportSessionProvider.notifier).setFile(
|
||||
file.bytes!,
|
||||
file.extension?.toLowerCase() ?? '',
|
||||
fileName: file.name,
|
||||
);
|
||||
}
|
||||
|
||||
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; });
|
||||
// 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 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<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;
|
||||
|
||||
// ── Kvittobild / PDF-förhandsvisning ───────────────────────────────────────
|
||||
@@ -570,6 +606,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user