diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index a6a8bd12..2bf79637 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -74,13 +74,14 @@ ## Nästa steg -1. Inför hybrid alias-modell för kvittoimport: user-scope alias som standard + global alias som admin-verifierad fallback. -2. Uppdatera backend-matchordning för alias: user-alias -> global alias -> poängbaserat namnförslag -> AI-kategori. -3. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet (först user-scope). -4. Deploy och smoke-test av kvittoimportflödet på server. -5. Fortsatt flytt av UI-strängar till ARB (inventarie, pantry, recept). -6. Smoke-test på testdomän och avstämning. -7. Planera och påbörja avancerad AI-integration och EAN-skanning. +1. Kvittoimport steg 2: persistenta förpackningsfält i inventarie (packCount, packSizeQuantity, packSizeUnit) + visning/redigering i inventory-UI. +2. Inför hybrid alias-modell för kvittoimport: user-scope alias som standard + global alias som admin-verifierad fallback. +3. Uppdatera backend-matchordning för alias: user-alias -> global alias -> poängbaserat namnförslag -> AI-kategori. +4. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet (först user-scope). +5. Deploy och smoke-test av kvittoimportflödet på server. +6. Fortsatt flytt av UI-strängar till ARB (inventarie, pantry, recept). +7. Smoke-test på testdomän och avstämning. +8. Planera och påbörja avancerad AI-integration och EAN-skanning. ## Beslut 2026-05-02 - Aliasstrategi för kvittoimport diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 6b7ae69c..e5860655 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -423,26 +423,30 @@ class _UserTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(user.email, style: theme.textTheme.bodySmall), - Row( + Wrap( + spacing: 4, + runSpacing: 2, children: [ - Chip( - label: Text(user.role), + ActionChip( + label: Text(user.isAdmin ? 'Admin' : 'User'), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, labelStyle: theme.textTheme.labelSmall, + tooltip: user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin', + onPressed: onChangeRole, ), - if (user.isPremium) ...[ - const SizedBox(width: 4), - Chip( - label: const Text('Premium'), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - labelStyle: theme.textTheme.labelSmall, - backgroundColor: theme.colorScheme.tertiaryContainer, - ), - ], - const SizedBox(width: 4), - Chip( + ActionChip( + label: Text(user.isPremium ? 'Premium' : 'Free'), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + labelStyle: theme.textTheme.labelSmall, + backgroundColor: user.isPremium + ? theme.colorScheme.tertiaryContainer + : theme.colorScheme.surfaceContainerHighest, + tooltip: user.isPremium ? 'Ta bort Premium' : 'Ge Premium', + onPressed: onTogglePremium, + ), + ActionChip( label: Text(user.canShareRecipes ? 'Delning: På' : 'Delning: Av'), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, @@ -450,6 +454,8 @@ class _UserTile extends StatelessWidget { backgroundColor: user.canShareRecipes ? theme.colorScheme.secondaryContainer : theme.colorScheme.errorContainer, + tooltip: user.canShareRecipes ? 'Blockera receptdelning' : 'Tillåt receptdelning', + onPressed: onToggleRecipeSharing, ), ], ), diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart index 19cfa746..1beb69ab 100644 --- a/flutter/lib/features/import/data/receipt_import_session.dart +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -18,6 +18,9 @@ class ItemEdit { final CategorySelectionSource? categorySource; final double? quantity; final String? unit; + final double? packQuantity; + final String? packUnit; + final double? packageCount; final ImportDestination destination; const ItemEdit({ @@ -28,6 +31,9 @@ class ItemEdit { this.categorySource, this.quantity, this.unit, + this.packQuantity, + this.packUnit, + this.packageCount, this.destination = ImportDestination.inventory, }); } diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 9d42f913..ba78f9d0 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -22,14 +22,8 @@ typedef _Destination = ImportDestination; enum _ProductEntryMode { existing, create } -({double quantity, String unit})? _normalizePackageQuantityFromRawName({ - required String rawName, - required double? quantity, - required String? unit, -}) { - if (quantity == null || unit == null) return null; - - final normalizedUnit = unit.trim().toLowerCase(); +bool _isPackageLikeUnit(String? unit) { + if (unit == null) return false; const packageUnits = { 'paket', 'forpackning', @@ -41,37 +35,63 @@ enum _ProductEntryMode { existing, create } 'fp', 'pkt', 'pack', + 'pak', 'st', 'styck', }; - if (!packageUnits.contains(normalizedUnit)) return null; + return packageUnits.contains(unit.trim().toLowerCase()); +} +({double packQuantity, String packUnit})? _extractPackageSizeFromRawName( + String rawName, +) { final match = RegExp( r'(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b', caseSensitive: false, ).firstMatch(rawName); if (match == null) return null; - final value = double.tryParse(match.group(1)!.replaceAll(',', '.')); final sizeUnit = match.group(2)!.toLowerCase(); if (value == null) return null; + return (packQuantity: value, packUnit: sizeUnit); +} - switch (sizeUnit) { - case 'ml': - return (quantity: quantity * (value / 1000.0), unit: 'l'); - case 'cl': - return (quantity: quantity * (value / 100.0), unit: 'l'); - case 'dl': - return (quantity: quantity * (value / 10.0), unit: 'l'); - case 'l': - return (quantity: quantity * value, unit: 'l'); - case 'g': - return (quantity: quantity * (value / 1000.0), unit: 'kg'); - case 'kg': - return (quantity: quantity * value, unit: 'kg'); - default: - return null; +({double? packQuantity, String? packUnit, double packageCount, double? totalQuantity, String? totalUnit}) + _inferPackageFields({ + required String rawName, + required double? quantity, + required String? unit, +}) { + if (quantity == null || unit == null) { + return ( + packQuantity: null, + packUnit: null, + packageCount: 1, + totalQuantity: quantity, + totalUnit: unit, + ); } + + final looksLikePackage = _isPackageLikeUnit(unit); + final extracted = _extractPackageSizeFromRawName(rawName); + + if (looksLikePackage && extracted != null) { + return ( + packQuantity: extracted.packQuantity, + packUnit: extracted.packUnit, + packageCount: quantity, + totalQuantity: extracted.packQuantity * quantity, + totalUnit: extracted.packUnit, + ); + } + + return ( + packQuantity: quantity, + packUnit: unit, + packageCount: 1, + totalQuantity: quantity, + totalUnit: unit, + ); } String _formatCompactNumber(double value) { @@ -86,6 +106,48 @@ String _formatSwedishNumber(double value) { return _formatCompactNumber(value).replaceAll('.', ','); } +double? _convertQuantity(double quantity, String fromUnit, String toUnit) { + final from = fromUnit.trim().toLowerCase(); + final to = toUnit.trim().toLowerCase(); + if (from.isEmpty || to.isEmpty) return null; + if (from == to) return quantity; + + // Mass + if (from == 'mg' && to == 'g') return quantity / 1000.0; + if (from == 'mg' && to == 'kg') return quantity / 1000000.0; + if (from == 'mg' && to == 'hg') return quantity / 100000.0; + + if (from == 'g' && to == 'mg') return quantity * 1000.0; + if (from == 'g' && to == 'hg') return quantity / 100.0; + if (from == 'g' && to == 'kg') return quantity / 1000.0; + + if (from == 'hg' && to == 'mg') return quantity * 100000.0; + if (from == 'hg' && to == 'g') return quantity * 100.0; + if (from == 'hg' && to == 'kg') return quantity / 10.0; + + if (from == 'kg' && to == 'mg') return quantity * 1000000.0; + if (from == 'kg' && to == 'hg') return quantity * 10.0; + if (from == 'kg' && to == 'g') return quantity * 1000.0; + + // Volume + if (from == 'ml' && to == 'l') return quantity / 1000.0; + if (from == 'cl' && to == 'l') return quantity / 100.0; + if (from == 'dl' && to == 'l') return quantity / 10.0; + if (from == 'l' && to == 'ml') return quantity * 1000.0; + if (from == 'l' && to == 'cl') return quantity * 100.0; + if (from == 'l' && to == 'dl') return quantity * 10.0; + + // Intra-volume conversions + if (from == 'ml' && to == 'cl') return quantity / 10.0; + if (from == 'ml' && to == 'dl') return quantity / 100.0; + if (from == 'cl' && to == 'ml') return quantity * 10.0; + if (from == 'cl' && to == 'dl') return quantity / 10.0; + if (from == 'dl' && to == 'ml') return quantity * 100.0; + if (from == 'dl' && to == 'cl') return quantity * 10.0; + + return null; +} + /// Konverterar VERSALER-produktnamn till Title Case med 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, 12x85g @@ -129,6 +191,7 @@ class _EditDialog extends StatefulWidget { class _EditDialogState extends State<_EditDialog> { late final TextEditingController _quantityCtrl; late final TextEditingController _unitCtrl; + late final TextEditingController _packageCountCtrl; late final TextEditingController _newProductNameCtrl; int? _productId; String? _productName; @@ -161,11 +224,23 @@ class _EditDialogState extends State<_EditDialog> { _newCategoryId = widget.current.categoryId ?? widget.item.categorySuggestionId; _newCategoryPath = widget.current.categoryPath ?? widget.item.categorySuggestionPath; _newCategorySource = widget.current.categorySource; + final inferred = _inferPackageFields( + rawName: widget.item.rawName, + quantity: widget.current.quantity ?? widget.item.quantity, + unit: widget.current.unit ?? widget.item.unit, + ); + final initialPackQuantity = widget.current.packQuantity ?? inferred.packQuantity; + final initialPackUnit = widget.current.packUnit ?? inferred.packUnit; + final initialPackageCount = widget.current.packageCount ?? inferred.packageCount; + _quantityCtrl = TextEditingController( - text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '', + text: initialPackQuantity?.toString() ?? '', ); _unitCtrl = TextEditingController( - text: widget.current.unit ?? widget.item.unit ?? '', + text: initialPackUnit ?? '', + ); + _packageCountCtrl = TextEditingController( + text: initialPackageCount.toString(), ); _newProductNameCtrl = TextEditingController( text: _normalizeProductName(widget.current.productName ?? widget.item.rawName), @@ -200,6 +275,7 @@ class _EditDialogState extends State<_EditDialog> { void dispose() { _quantityCtrl.dispose(); _unitCtrl.dispose(); + _packageCountCtrl.dispose(); _newProductNameCtrl.dispose(); super.dispose(); } @@ -309,6 +385,14 @@ class _EditDialogState extends State<_EditDialog> { if (!mounted || _productId == null) return; } + final packQuantity = double.tryParse(_quantityCtrl.text.replaceAll(',', '.')); + final packageCount = + double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0; + final packUnit = _unitCtrl.text.trim().isEmpty + ? (widget.current.packUnit ?? widget.current.unit ?? widget.item.unit) + : _unitCtrl.text.trim(); + final totalQuantity = packQuantity != null ? packQuantity * packageCount : widget.item.quantity; + Navigator.pop( context, _ItemEdit( @@ -317,8 +401,11 @@ class _EditDialogState extends State<_EditDialog> { categoryId: _productCategoryId, categoryPath: _productCategoryPath, categorySource: _productCategorySource, - quantity: double.tryParse(_quantityCtrl.text) ?? widget.item.quantity, - unit: _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(), + quantity: totalQuantity, + unit: packUnit, + packQuantity: packQuantity, + packUnit: packUnit, + packageCount: packageCount, destination: _destination, ), ); @@ -338,16 +425,15 @@ class _EditDialogState extends State<_EditDialog> { : (item.matchedProductName?.isNotEmpty == true ? _normalizeProductName(item.matchedProductName!) : null); - final currentQuantity = - double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity; - final currentUnit = _unitCtrl.text.trim().isEmpty ? widget.item.unit : _unitCtrl.text.trim(); - final normalizationPreview = _destination == _Destination.inventory - ? _normalizePackageQuantityFromRawName( - rawName: item.rawName, - quantity: currentQuantity, - unit: currentUnit, - ) - : null; + final currentPackQuantity = + double.tryParse(_quantityCtrl.text.replaceAll(',', '.')); + final currentPackageCount = + double.tryParse(_packageCountCtrl.text.replaceAll(',', '.')) ?? 1.0; + final currentUnit = _unitCtrl.text.trim().isEmpty + ? (widget.current.packUnit ?? widget.current.unit ?? widget.item.unit) + : _unitCtrl.text.trim(); + final totalPreview = + currentPackQuantity == null ? null : currentPackQuantity * currentPackageCount; return AlertDialog( title: Text( @@ -592,7 +678,10 @@ class _EditDialogState extends State<_EditDialog> { child: TextField( controller: _quantityCtrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Antal', border: OutlineInputBorder()), + decoration: const InputDecoration( + labelText: 'Mangd per forpackning', + border: OutlineInputBorder(), + ), onChanged: (_) => setState(() {}), ), ), @@ -600,12 +689,25 @@ class _EditDialogState extends State<_EditDialog> { Expanded( child: TextField( controller: _unitCtrl, - decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()), + decoration: const InputDecoration( + labelText: 'Enhet', + border: OutlineInputBorder(), + ), onChanged: (_) => setState(() {}), ), ), ]), - if (normalizationPreview != null) ...[ + const SizedBox(height: 8), + TextField( + controller: _packageCountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Antal forpackningar', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + if (totalPreview != null && currentUnit != null && currentUnit.isNotEmpty) ...[ const SizedBox(height: 8), Container( width: double.infinity, @@ -616,8 +718,8 @@ class _EditDialogState extends State<_EditDialog> { border: Border.all(color: Colors.green.shade200), ), child: Text( - 'Tolkat som totalt ${_formatSwedishNumber(normalizationPreview.quantity)} ${normalizationPreview.unit} ' - '(antal x förpackningsstorlek).', + 'Totalt: ${_formatSwedishNumber(totalPreview)} $currentUnit ' + '(mangd x antal forpackningar).', style: theme.textTheme.bodySmall?.copyWith( color: Colors.green.shade800, ), @@ -821,6 +923,11 @@ class _ReceiptImportTabState extends ConsumerState { final pid = it.matchedProductId ?? it.suggestedProductId; notifier.setSelected(i, pid != null); if (pid != null) { + final inferred = _inferPackageFields( + rawName: it.rawName, + quantity: it.quantity, + unit: it.unit, + ); final name = it.matchedProductName ?? it.suggestedProductName; final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid); final resolvedCategoryPath = it.categorySuggestionPath ?? @@ -833,8 +940,11 @@ class _ReceiptImportTabState extends ConsumerState { categorySource: it.categorySuggestionId != null ? CategorySelectionSource.ai : null, - quantity: it.quantity, - unit: it.unit, + quantity: inferred.totalQuantity, + unit: inferred.totalUnit, + packQuantity: inferred.packQuantity, + packUnit: inferred.packUnit, + packageCount: inferred.packageCount, )); } } @@ -852,6 +962,11 @@ class _ReceiptImportTabState extends ConsumerState { _ProductEntryMode? initialEntryMode, }) async { final item = _items![index]; + final inferred = _inferPackageFields( + rawName: item.rawName, + quantity: item.quantity, + unit: item.unit, + ); final current = _edits[index] ?? _ItemEdit( productId: item.matchedProductId ?? item.suggestedProductId, @@ -866,8 +981,11 @@ class _ReceiptImportTabState extends ConsumerState { categorySource: item.categorySuggestionId != null ? CategorySelectionSource.ai : null, - quantity: item.quantity, - unit: item.unit, + quantity: inferred.totalQuantity, + unit: inferred.totalUnit, + packQuantity: inferred.packQuantity, + packUnit: inferred.packUnit, + packageCount: inferred.packageCount, ); final result = await showDialog<_ItemEdit>( @@ -948,18 +1066,26 @@ class _ReceiptImportTabState extends ConsumerState { pantryAdded++; } } else { - final normalized = _normalizePackageQuantityFromRawName( + final inferred = _inferPackageFields( rawName: item.rawName, quantity: edit.quantity ?? item.quantity, unit: edit.unit ?? item.unit, ); - final qty = normalized?.quantity ?? edit.quantity ?? item.quantity ?? 1.0; - final unit = normalized?.unit ?? edit.unit ?? item.unit ?? 'st'; + final packageCount = edit.packageCount ?? inferred.packageCount; + final packQuantity = edit.packQuantity ?? inferred.packQuantity; + final packUnit = edit.packUnit ?? inferred.packUnit ?? edit.unit ?? item.unit ?? 'st'; + final qty = packQuantity != null + ? (packQuantity * packageCount) + : (edit.quantity ?? inferred.totalQuantity ?? item.quantity ?? 1.0); + final unit = packUnit; final existing = _inventoryByProduct[pid]; - if (existing != null) { + final qtyInExistingUnit = existing == null + ? null + : _convertQuantity(qty, unit, existing.unit); + if (existing != null && qtyInExistingUnit != null) { await invRepo.updateInventoryItem( existing.id, - {'quantity': existing.quantity + qty}, + {'quantity': existing.quantity + qtyInExistingUnit}, token: token, ); merged++; @@ -1129,9 +1255,32 @@ class _ReceiptImportTabState extends ConsumerState { final hasProduct = edit?.productId != null; final isMatched = item.matchedProductId != null; final isSuggested = item.suggestedProductId != null && item.matchedProductId == null; - final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry + final existingInv = edit?.productId != null && edit?.destination != _Destination.pantry ? _inventoryByProduct[edit!.productId] : null; + final inferredForPreview = _inferPackageFields( + rawName: item.rawName, + quantity: edit?.quantity ?? item.quantity, + unit: edit?.unit ?? item.unit, + ); + final previewPackageCount = edit?.packageCount ?? inferredForPreview.packageCount; + final previewPackQuantity = edit?.packQuantity ?? inferredForPreview.packQuantity; + final previewIncomingQty = previewPackQuantity != null + ? (previewPackQuantity * previewPackageCount) + : (edit?.quantity ?? inferredForPreview.totalQuantity ?? item.quantity ?? 0); + final previewIncomingUnit = edit?.packUnit ?? + inferredForPreview.packUnit ?? + edit?.unit ?? + item.unit ?? + 'st'; + final convertedPreviewQty = existingInv == null + ? null + : _convertQuantity( + previewIncomingQty, + previewIncomingUnit, + existingInv.unit, + ); + final canMergePreview = existingInv != null && convertedPreviewQty != null; final alreadyInPantry = edit?.productId != null && edit?.destination == _Destination.pantry ? _pantryProductIds.contains(edit!.productId) : false; @@ -1250,17 +1399,28 @@ class _ReceiptImportTabState extends ConsumerState { ], ), ], - if (existingInv != null) ...[ + if (existingInv != null && canMergePreview) ...[ const SizedBox(height: 2), Row(children: [ Icon(Icons.kitchen_outlined, size: 12, color: Colors.blue.shade700), const SizedBox(width: 3), Text( - 'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (edit?.quantity ?? item.quantity ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}', + 'I lager: ${existingInv.quantity} ${existingInv.unit} → blir ${(existingInv.quantity + (convertedPreviewQty ?? 0)).toStringAsFixed(existingInv.quantity % 1 == 0 ? 0 : 2)} ${existingInv.unit}', style: theme.textTheme.bodySmall?.copyWith(color: Colors.blue.shade700), ), ]), ], + if (existingInv != null && !canMergePreview) ...[ + const SizedBox(height: 2), + Row(children: [ + Icon(Icons.info_outline, size: 12, color: Colors.orange.shade700), + const SizedBox(width: 3), + Text( + 'Finns i lager med annan enhet (${existingInv.unit}) - sparas som ny rad', + style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + ), + ]), + ], if (alreadyInPantry) ...[ const SizedBox(height: 2), Row(children: [