diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts index 1e34a7f9..b13affa1 100644 --- a/backend/src/ai/ai.service.ts +++ b/backend/src/ai/ai.service.ts @@ -144,6 +144,13 @@ Regler: throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler'); } + // Snabb deterministic guard för välkända äppelsorter. + // Detta minskar felklassning när "äpple" saknas i namnet (t.ex. Granny Smith). + const appleCategory = this.matchAppleVarietyCategory(productName, categories); + if (appleCategory) { + return appleCategory; + } + const categoryList = categories .map((c) => `[${c.id}] ${c.path}`) .join('\n'); @@ -156,6 +163,7 @@ ${categoryList} KWICK-MATCHNING (gör detta först): - Kött/fläsk: Sök efter ord som "fläsk", "flaskytterfile", "bacon", "kotlett", "karré" → Kött, chark & fågel > Fläsk - Fågel: Sök efter "kyckling", "kalkon", "drumstick", "filé" → Kött, chark & fågel > Fågel +- Äpplen/fruktsorter: Sök efter "äpple", "apple", "granny smith", "pink lady", "royal gala", "golden delicious", "jonagold", "fuji" → Frukt & Grönt > Frukt > Äpplen (eller Frukt om Äpplen saknas) - Choklad/spreads: Sök efter "nutella", "choklad", "kakao", "spreads" → Sötsaker & snacks > Choklad & spreads - Bröd: Sök efter "bröd", "toast", "brödrost", "limpa" → Bröd & bakvaror > Bröd @@ -288,4 +296,45 @@ Regler: usedFallback: true, }; } + + private matchAppleVarietyCategory( + productName: string, + categories: FlatCategory[], + ): CategorySuggestion | null { + const normalized = productName.trim().toLowerCase(); + const looksLikeApple = /\b(äpple|apple|granny\s*smith|pink\s*lady|royal\s*gala|golden\s*delicious|jonagold|fuji|braeburn|aroma)\b/i + .test(normalized); + + if (!looksLikeApple) { + return null; + } + + const appleLeaf = categories.find( + (c) => c.path.toLowerCase() === 'frukt & grönt > frukt > äpplen', + ); + if (appleLeaf) { + return { + categoryId: appleLeaf.id, + categoryName: appleLeaf.name, + path: appleLeaf.path, + confidence: 'high', + usedFallback: false, + }; + } + + const fruitFallback = categories.find( + (c) => c.path.toLowerCase() === 'frukt & grönt > frukt', + ); + if (fruitFallback) { + return { + categoryId: fruitFallback.id, + categoryName: fruitFallback.name, + path: fruitFallback.path, + confidence: 'high', + usedFallback: false, + }; + } + + return null; + } } diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index a0f11b18..687a3774 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -269,6 +269,15 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Citrusfrukt', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Frukt' WHERE c1.name = 'Frukt & Grönt' AND c1.parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Äpplen', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Frukt' + WHERE c1.name = 'Frukt & Grönt' + AND c1.parentId IS NULL + AND NOT EXISTS ( + SELECT 1 FROM `Category` existing + WHERE existing.parentId = c2.id AND existing.name = 'Äpplen' + ); -- ── NIVÅ 3: under Glass > Chips, snacks & dip ─────────────── INSERT INTO `Category` (`name`, `parentId`) diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 9db73de5..0f7fe1c6 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -173,9 +173,6 @@ class AdminRepository { // ── Produkter ────────────────────────────────────────────────────────────── Future> listProducts() => - _getList(ProductApiPaths.mine, AdminProduct.fromJson); - - Future> listGlobalProducts() => _getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); Future> listPrivateProducts() => @@ -244,8 +241,15 @@ class AdminRepository { parse: (d) => d as Map, ); - Future bulkSetCategory(List ids, {required int? categoryId}) => - _postVoid(ProductApiPaths.bulkUpdate, {'ids': ids, 'categoryId': categoryId}); + Future bulkSetCategory(List ids, {required int? categoryId}) => + _post( + ProductApiPaths.bulkUpdate, + body: {'ids': ids, 'categoryId': categoryId}, + parse: (d) { + final map = Map.from(d as Map); + return (map['updated'] as num?)?.toInt() ?? 0; + }, + ); Future mergeProducts({ required int sourceProductId, diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index b0652c4a..c5cee390 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -116,10 +116,15 @@ class _AdminProductsPanelState extends ConsumerState { setState(() => _isApplying = true); try { - await ref.read(adminRepositoryProvider).bulkSetCategory( + final updated = await ref.read(adminRepositoryProvider).bulkSetCategory( _selectedIds.toList(), categoryId: categoryId, ); + if (updated == 0) { + if (!mounted) return; + _showError('Inga produkter uppdaterades. Kontrollera att valda produkter fortfarande finns och försök igen.'); + return; + } if (!mounted) return; setState(() { _selectedIds.clear(); @@ -164,11 +169,18 @@ class _AdminProductsPanelState extends ConsumerState { if (!selectedProductIds.contains(row.productId)) continue; grouped.putIfAbsent(row.categoryId, () => []).add(row.productId); } + var totalUpdated = 0; for (final entry in grouped.entries) { - await ref.read(adminRepositoryProvider).bulkSetCategory( + final updated = await ref.read(adminRepositoryProvider).bulkSetCategory( entry.value, categoryId: entry.key, ); + totalUpdated += updated; + } + if (totalUpdated == 0) { + if (!mounted) return; + _showError('AI-förslag kunde inte sparas. Prova att uppdatera listan och kör igen.'); + return; } if (!mounted) return; setState(() => _selectedIds.clear());