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