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
|
## 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user