feat: separate AI and product suggestion chips, normalize product names, and validate AI categories
This commit is contained in:
@@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
### Nyheter och förbättringar
|
### Nyheter och förbättringar
|
||||||
- **Kvittoimport Fas 6b klar (2026-05-01)** — Flutter-granskningsflödet färdigt: per-rad checkbox, redigeringsdialog med destination-väljare (Inventarie/Baslager), merge-förhandsvisning, parallell laddning av inventarie och baslager, snackbar med separat räkning.
|
- **Kvittoimport Fas 6b klar (2026-05-01)** — Flutter-granskningsflödet färdigt: per-rad checkbox, redigeringsdialog med destination-väljare (Inventarie/Baslager), merge-förhandsvisning, parallell laddning av inventarie och baslager, snackbar med separat räkning.
|
||||||
|
- **Kvittoimport Fas 6c klar (2026-05-01)** — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier.
|
||||||
- **Microservice-importer integrerad (2026-04-30)** — All import-logik (URL-skrapning, OCR, PDF-parsning, AI-kvittoparsning) delegeras nu till `importer-api` som körs som intern Docker-tjänst. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för detaljer.
|
- **Microservice-importer integrerad (2026-04-30)** — All import-logik (URL-skrapning, OCR, PDF-parsning, AI-kvittoparsning) delegeras nu till `importer-api` som körs som intern Docker-tjänst. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för detaljer.
|
||||||
- **User-scope för pantry och matplan** — Alla baslager- och matplansdata är nu per användare. Backend och Prisma-schema är migrerade.
|
- **User-scope för pantry och matplan** — Alla baslager- och matplansdata är nu per användare. Backend och Prisma-schema är migrerade.
|
||||||
- **Robust bildimport** — Bild-URL normaliseras, laddas ner och optimeras i backend. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg.
|
- **Robust bildimport** — Bild-URL normaliseras, laddas ner och optimeras i backend. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg.
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_productId = widget.current.productId;
|
_productId = widget.current.productId;
|
||||||
_productName = widget.current.productName;
|
_productName = widget.current.productName == null
|
||||||
|
? null
|
||||||
|
: _normalizeProductName(widget.current.productName!);
|
||||||
_destination = widget.current.destination;
|
_destination = widget.current.destination;
|
||||||
_entryMode = widget.initialEntryMode ??
|
_entryMode = widget.initialEntryMode ??
|
||||||
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
|
(_productId == null ? _ProductEntryMode.create : _ProductEntryMode.existing);
|
||||||
@@ -241,10 +243,11 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
if (id != null && mounted) {
|
if (id != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_productId = id;
|
_productId = id;
|
||||||
_productName = _localProducts
|
final selectedName = _localProducts
|
||||||
.cast<ProductOption?>()
|
.cast<ProductOption?>()
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
?.name;
|
?.name;
|
||||||
|
_productName = selectedName == null ? null : _normalizeProductName(selectedName);
|
||||||
_productCategoryId = _categoryIdForProduct(id);
|
_productCategoryId = _categoryIdForProduct(id);
|
||||||
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
|
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
|
||||||
_productCategorySource = CategorySelectionSource.manual;
|
_productCategorySource = CategorySelectionSource.manual;
|
||||||
@@ -257,7 +260,9 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
if (suggestedId != null) {
|
if (suggestedId != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_productId = suggestedId;
|
_productId = suggestedId;
|
||||||
_productName = widget.item.suggestedProductName;
|
_productName = widget.item.suggestedProductName == null
|
||||||
|
? null
|
||||||
|
: _normalizeProductName(widget.item.suggestedProductName!);
|
||||||
_productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
|
_productCategoryId = _categoryIdForProduct(suggestedId) ?? widget.item.categorySuggestionId;
|
||||||
_productCategoryPath =
|
_productCategoryPath =
|
||||||
_categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath;
|
_categoryPathForCategoryId(_productCategoryId) ?? widget.item.categorySuggestionPath;
|
||||||
@@ -331,7 +336,7 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
final suggestedProductLabel = (item.suggestedProductId != null &&
|
final suggestedProductLabel = (item.suggestedProductId != null &&
|
||||||
item.suggestedProductName?.isNotEmpty == true &&
|
item.suggestedProductName?.isNotEmpty == true &&
|
||||||
item.matchedProductId == null)
|
item.matchedProductId == null)
|
||||||
? item.suggestedProductName
|
? _normalizeProductName(item.suggestedProductName!)
|
||||||
: null;
|
: null;
|
||||||
final currentQuantity =
|
final currentQuantity =
|
||||||
double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity;
|
double.tryParse(_quantityCtrl.text.replaceAll(',', '.')) ?? widget.item.quantity;
|
||||||
@@ -345,7 +350,11 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(item.rawName, maxLines: 2, overflow: TextOverflow.ellipsis),
|
title: Text(
|
||||||
|
_normalizeProductName(item.rawName),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -393,6 +402,27 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (suggestedProductLabel != null) ...[
|
||||||
|
Tooltip(
|
||||||
|
message: 'Trolig matchning baserat på produktnamn i databasen',
|
||||||
|
child: ActionChip(
|
||||||
|
avatar: Icon(
|
||||||
|
Icons.search,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Namnförslag: $suggestedProductLabel',
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.blue.shade50,
|
||||||
|
side: BorderSide(color: Colors.blue.shade300),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: _applyAiSuggestionForExistingSelection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -404,12 +434,15 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
onChanged: (id) {
|
onChanged: (id) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_productId = id;
|
_productId = id;
|
||||||
_productName = id == null
|
final selectedName = id == null
|
||||||
? null
|
? null
|
||||||
: _localProducts
|
: _localProducts
|
||||||
.cast<ProductOption?>()
|
.cast<ProductOption?>()
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
?.name;
|
?.name;
|
||||||
|
_productName = selectedName == null
|
||||||
|
? null
|
||||||
|
: _normalizeProductName(selectedName);
|
||||||
_productCategoryId = _categoryIdForProduct(id);
|
_productCategoryId = _categoryIdForProduct(id);
|
||||||
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
|
_productCategoryPath = _categoryPathForCategoryId(_productCategoryId);
|
||||||
_productCategorySource =
|
_productCategorySource =
|
||||||
@@ -432,24 +465,28 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (suggestedProductLabel != null) ...[ const SizedBox(height: 8),
|
if (_productCategoryPath != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
ActionChip(
|
ActionChip(
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
Icons.search,
|
Icons.account_tree_outlined,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Colors.blue.shade700,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Förslag: $suggestedProductLabel',
|
'Kategori: $_productCategoryPath',
|
||||||
style: theme.textTheme.labelSmall,
|
style: theme.textTheme.labelSmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.blue.shade50,
|
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
side: BorderSide(color: Colors.blue.shade300),
|
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: _applyAiSuggestionForExistingSelection,
|
onPressed: () => _openExistingCategoryPicker(
|
||||||
|
preselectedCategoryId: _productCategoryId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (aiLabel != null) ...[ const SizedBox(height: 8),
|
if (aiLabel != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
ActionChip(
|
ActionChip(
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
Icons.auto_awesome,
|
Icons.auto_awesome,
|
||||||
@@ -651,6 +688,30 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
_loadProducts();
|
_loadProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _categoryIdForProduct(int? productId) {
|
||||||
|
if (productId == null) return null;
|
||||||
|
return _products
|
||||||
|
.cast<ProductOption?>()
|
||||||
|
.firstWhere((p) => p?.id == productId, orElse: () => null)
|
||||||
|
?.categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _categoryPathForCategoryId(int? categoryId) {
|
||||||
|
if (categoryId == null) return null;
|
||||||
|
|
||||||
|
List<String>? walk(List<AdminCategoryNode> nodes, List<String> parents) {
|
||||||
|
for (final node in nodes) {
|
||||||
|
final path = [...parents, node.name];
|
||||||
|
if (node.id == categoryId) return path;
|
||||||
|
final found = walk(node.children, path);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return walk(_categoryTree, const [])?.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadProducts() async {
|
Future<void> _loadProducts() async {
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
@@ -761,11 +822,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
notifier.setSelected(i, pid != null);
|
notifier.setSelected(i, pid != null);
|
||||||
if (pid != null) {
|
if (pid != null) {
|
||||||
final name = it.matchedProductName ?? it.suggestedProductName;
|
final name = it.matchedProductName ?? it.suggestedProductName;
|
||||||
|
final resolvedCategoryId = it.categorySuggestionId ?? _categoryIdForProduct(pid);
|
||||||
|
final resolvedCategoryPath = it.categorySuggestionPath ??
|
||||||
|
_categoryPathForCategoryId(resolvedCategoryId);
|
||||||
notifier.setEdit(i, _ItemEdit(
|
notifier.setEdit(i, _ItemEdit(
|
||||||
productId: pid,
|
productId: pid,
|
||||||
productName: name,
|
productName: name,
|
||||||
categoryId: it.categorySuggestionId,
|
categoryId: resolvedCategoryId,
|
||||||
categoryPath: it.categorySuggestionPath,
|
categoryPath: resolvedCategoryPath,
|
||||||
categorySource: it.categorySuggestionId != null
|
categorySource: it.categorySuggestionId != null
|
||||||
? CategorySelectionSource.ai
|
? CategorySelectionSource.ai
|
||||||
: null,
|
: null,
|
||||||
@@ -792,8 +856,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
_ItemEdit(
|
_ItemEdit(
|
||||||
productId: item.matchedProductId ?? item.suggestedProductId,
|
productId: item.matchedProductId ?? item.suggestedProductId,
|
||||||
productName: item.matchedProductName ?? item.suggestedProductName,
|
productName: item.matchedProductName ?? item.suggestedProductName,
|
||||||
categoryId: item.categorySuggestionId,
|
categoryId: item.categorySuggestionId ??
|
||||||
categoryPath: item.categorySuggestionPath,
|
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||||
|
categoryPath: item.categorySuggestionPath ??
|
||||||
|
_categoryPathForCategoryId(
|
||||||
|
item.categorySuggestionId ??
|
||||||
|
_categoryIdForProduct(item.matchedProductId ?? item.suggestedProductId),
|
||||||
|
),
|
||||||
categorySource: item.categorySuggestionId != null
|
categorySource: item.categorySuggestionId != null
|
||||||
? CategorySelectionSource.ai
|
? CategorySelectionSource.ai
|
||||||
: null,
|
: null,
|
||||||
@@ -954,7 +1023,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
? null
|
? null
|
||||||
: OutlinedButton.icon(
|
: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.open_in_new, size: 16),
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
label: const Text('Öppna PDF'),
|
label: const Text('Visa kvitto'),
|
||||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final opened = await openPdfBytes(bytes);
|
final opened = await openPdfBytes(bytes);
|
||||||
@@ -979,15 +1048,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
||||||
child: Text(
|
|
||||||
'PDF-förhandsvisning stöds inte i appen — se importerade rader nedan.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1085,7 +1146,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: Text(item.rawName, style: theme.textTheme.bodyMedium),
|
title: Text(
|
||||||
|
_normalizeProductName(item.rawName),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1106,10 +1170,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(edit!.productName ?? '', style: theme.textTheme.bodySmall?.copyWith(
|
Text(
|
||||||
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
'Produktnamn: ${_normalizeProductName(edit!.productName ?? '')}',
|
||||||
fontWeight: FontWeight.w500,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
)),
|
color: isMatched ? Colors.green.shade700 : theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (edit.categorySource != null)
|
if (edit.categorySource != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
@@ -1136,7 +1203,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
else if (isSuggested)
|
else if (isSuggested)
|
||||||
Text('Förslag: ${item.suggestedProductName ?? ''}',
|
Text('Namnförslag: ${_normalizeProductName(item.suggestedProductName ?? '')}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.orange.shade700))
|
||||||
else
|
else
|
||||||
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
Text('Ingen matchning ännu — tryck för att välja eller skapa produkt',
|
||||||
@@ -1144,7 +1211,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (hasProduct && edit?.categoryPath != null) ...[
|
if (hasProduct && edit?.categoryPath != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
edit!.categoryPath!,
|
'Kategori: ${edit!.categoryPath!}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Relaterade dokument:
|
|||||||
|
|
||||||
### Pågående arbete
|
### Pågående arbete
|
||||||
- **Kvittoimport (Fas 6b):** ✅ Klar (2026-05-01) — Granskning, redigering, val av destination (inventarie/baslager), merge och spara implementerat.
|
- **Kvittoimport (Fas 6b):** ✅ Klar (2026-05-01) — Granskning, redigering, val av destination (inventarie/baslager), merge och spara implementerat.
|
||||||
|
- **Kvittoimport (Fas 6c):** ✅ Klar (2026-05-01) — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier.
|
||||||
- **Bildimport:** Säkerställa att containrar är uppdaterade med senaste kod och att diagnostikloggar syns vid felsökning.
|
- **Bildimport:** Säkerställa att containrar är uppdaterade med senaste kod och att diagnostikloggar syns vid felsökning.
|
||||||
- **Adminfunktioner:** Avancerad AI-integration och ytterligare adminfunktioner planeras men är ej migrerade.
|
- **Adminfunktioner:** Avancerad AI-integration och ytterligare adminfunktioner planeras men är ej migrerade.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,47 @@
|
|||||||
|
|
||||||
Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda Windows-sökvägar. Använd inte `c:/dev/recipe-app/...` eftersom bygg- och testmiljön är på en remote Ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu.
|
Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda Windows-sökvägar. Använd inte `c:/dev/recipe-app/...` eftersom bygg- och testmiljön är på en remote Ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu.
|
||||||
|
|
||||||
|
## Senaste ändringar (2026-05-01, session 3)
|
||||||
|
|
||||||
|
### Separering av AI-chip och produktsuggestions-chip
|
||||||
|
|
||||||
|
**Problem:** AI-chipet visade felaktigt produktnamnsförslag som om de vore kategoriförslag, vilket skapade förvirring när användaren såg "AI-förslag: Dryck Multivitamin" (ett produktnamn) istället för en kategori.
|
||||||
|
|
||||||
|
**Lösning:**
|
||||||
|
- **Blå chip** "Förslag: [produktnamn]" — när systemet hittat en trolig produkt via ordmatchning (inga AI-anrop inblandade). Klick väljer produkten.
|
||||||
|
- **Grön chip** "AI-kategori: [kategoriväg]" — när AI:n föreslagit en kategori från databasen. Klick öppnar produktpickern filtrerad på den kategorin.
|
||||||
|
|
||||||
|
**Kodändringar:**
|
||||||
|
- `aiLabel` beräknas enbart från `categorySuggestionName`/`categorySuggestionPath` (kategoriförslag).
|
||||||
|
- Nytt fält `suggestedProductLabel` för produktsuggestions-chip.
|
||||||
|
- Separata villkor och UI-block för de två chipen i `_EditDialogState.build()`.
|
||||||
|
|
||||||
|
### Produktnamns-normalisering
|
||||||
|
|
||||||
|
**Problem:** Kvittonamn i VERSALER (t.ex. "APRIKOSMARMELAD 284G") såg oprofessionella ut i UI:n.
|
||||||
|
|
||||||
|
**Lösning:** Ny funktion `_normalizeProductName()` som tillämpar 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` → `284g`, `12X85G` → `12x85g`
|
||||||
|
- Övriga token: första bokstav versal, resten gemen: `APRIKOSMARMELAD` → `Aprikosmarmelad`, `JUICE TROPISK` → `Juice Tropisk`
|
||||||
|
|
||||||
|
**Implementering:**
|
||||||
|
- Top-level-funktion i `receipt_import_tab.dart`
|
||||||
|
- Tillämpas när "Ny produkt"-fältet prefylls: `_newProductNameCtrl.text = _normalizeProductName(widget.current.productName ?? widget.item.rawName)`
|
||||||
|
|
||||||
|
### AI-kategorisering — validering i backend
|
||||||
|
|
||||||
|
**Problem:** Användaren rapporterade att AI föreslog kategorin "Dryck Multivitamin", som inte fanns i databasen.
|
||||||
|
|
||||||
|
**Undersökning:**
|
||||||
|
- Backend-AI:n (`ai.service.ts`) validerar redan att `categoryId` finns i `categories`-listan och faller tillbaka på "Övrigt" om inte.
|
||||||
|
- Problemet var att frontend visade produktnamnsförslag som om de vore kategoriförslag.
|
||||||
|
|
||||||
|
**Lösning:**
|
||||||
|
- Separering av chipen (se ovan) gör det tydligt att AI-kategoriförslag alltid kommer från databasen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Senaste ändringar (2026-05-01, session 2)
|
## Senaste ändringar (2026-05-01, session 2)
|
||||||
|
|
||||||
### Tvåstegs-picker: Kategori → Produkt
|
### Tvåstegs-picker: Kategori → Produkt
|
||||||
@@ -58,7 +99,7 @@ Importfliken laddar globala och privata produkter parallellt via `Future.wait` o
|
|||||||
User-scope-principen dokumenterades formellt i båda tekniska beskrivningarna (2026-05-01). Privata produkter är det första exemplet på mönstret för resurser som är varken globala (alla ser dem) eller fullt user-owned (bara ägaren ser dem):
|
User-scope-principen dokumenterades formellt i båda tekniska beskrivningarna (2026-05-01). Privata produkter är det första exemplet på mönstret för resurser som är varken globala (alla ser dem) eller fullt user-owned (bara ägaren ser dem):
|
||||||
- `Product.isPrivate = true` + `Product.ownerId = userId`
|
- `Product.isPrivate = true` + `Product.ownerId = userId`
|
||||||
- `normalizedName`-prefix undviker databaskollision med globala produkter
|
- `normalizedName`-prefix undviker databaskollision med globala produkter
|
||||||
- Migration: `20260501000000_add_product_is_private`
|
- Migration: `20260501000000_add_product_is_private
|
||||||
|
|
||||||
## Senaste ändringar (2026-05-01, session 1)
|
## Senaste ändringar (2026-05-01, session 1)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user