diff --git a/backend/prisma/migrations/20260502133000_rename_allergi_mjolk_to_laktosfri_mjolk/migration.sql b/backend/prisma/migrations/20260502133000_rename_allergi_mjolk_to_laktosfri_mjolk/migration.sql new file mode 100644 index 00000000..ae1ec27d --- /dev/null +++ b/backend/prisma/migrations/20260502133000_rename_allergi_mjolk_to_laktosfri_mjolk/migration.sql @@ -0,0 +1,63 @@ +-- Ensure Allergi mejeri uses "Laktosfri mjölk" as L3 node. + +-- 1) Rename old node when no target node exists yet. +UPDATE `Category` c_old +JOIN `Category` c2 + ON c_old.parentId = c2.id + AND c2.name = 'Allergi mejeri' +JOIN `Category` c1 + ON c2.parentId = c1.id + AND c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL +LEFT JOIN `Category` c_new + ON c_new.parentId = c2.id + AND c_new.name = 'Laktosfri mjölk' +SET c_old.name = 'Laktosfri mjölk' +WHERE c_old.name = 'Mjölk' + AND c_new.id IS NULL; + +-- 2) Re-point product links from old node to new node if both exist. +UPDATE `Product` p +JOIN `Category` c_old + ON p.categoryId = c_old.id + AND c_old.name = 'Mjölk' +JOIN `Category` c2 + ON c_old.parentId = c2.id + AND c2.name = 'Allergi mejeri' +JOIN `Category` c1 + ON c2.parentId = c1.id + AND c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL +JOIN `Category` c_new + ON c_new.parentId = c2.id + AND c_new.name = 'Laktosfri mjölk' +SET p.categoryId = c_new.id; + +-- 3) Remove duplicate old node when both old and new exist. +DELETE c_old +FROM `Category` c_old +JOIN `Category` c2 + ON c_old.parentId = c2.id + AND c2.name = 'Allergi mejeri' +JOIN `Category` c1 + ON c2.parentId = c1.id + AND c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL +JOIN `Category` c_new + ON c_new.parentId = c2.id + AND c_new.name = 'Laktosfri mjölk' +WHERE c_old.name = 'Mjölk'; + +-- 4) Create missing target node if needed. +INSERT INTO `Category` (`name`, `parentId`) +SELECT 'Laktosfri mjölk', c2.id +FROM `Category` c1 +JOIN `Category` c2 + ON c2.parentId = c1.id + AND c2.name = 'Allergi mejeri' +LEFT JOIN `Category` c3 + ON c3.parentId = c2.id + AND c3.name = 'Laktosfri mjölk' +WHERE c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL + AND c3.id IS NULL; diff --git a/backend/prisma/migrations/20260502134500_add_l3_kvarg_cottage_in_dairy_branches/migration.sql b/backend/prisma/migrations/20260502134500_add_l3_kvarg_cottage_in_dairy_branches/migration.sql new file mode 100644 index 00000000..5f2cab48 --- /dev/null +++ b/backend/prisma/migrations/20260502134500_add_l3_kvarg_cottage_in_dairy_branches/migration.sql @@ -0,0 +1,31 @@ +-- Ensure L3 "Kvarg & Cottage cheese" exists in both dairy branches: +-- 1) Mejeri, ost & ägg > Allergi mejeri +-- 2) Mejeri, ost & ägg > Kvarg & Cottage cheese + +-- Branch 1: under Allergi mejeri +INSERT INTO `Category` (`name`, `parentId`) +SELECT 'Kvarg & Cottage cheese', c2.id +FROM `Category` c1 +JOIN `Category` c2 + ON c2.parentId = c1.id + AND c2.name = 'Allergi mejeri' +LEFT JOIN `Category` c3 + ON c3.parentId = c2.id + AND c3.name = 'Kvarg & Cottage cheese' +WHERE c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL + AND c3.id IS NULL; + +-- Branch 2: under standard Kvarg & Cottage cheese branch +INSERT INTO `Category` (`name`, `parentId`) +SELECT 'Kvarg & Cottage cheese', c2.id +FROM `Category` c1 +JOIN `Category` c2 + ON c2.parentId = c1.id + AND c2.name = 'Kvarg & Cottage cheese' +LEFT JOIN `Category` c3 + ON c3.parentId = c2.id + AND c3.name = 'Kvarg & Cottage cheese' +WHERE c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL + AND c3.id IS NULL; diff --git a/backend/prisma/migrations/20260502135500_add_allergi_mejeri_matfett_l3/migration.sql b/backend/prisma/migrations/20260502135500_add_allergi_mejeri_matfett_l3/migration.sql new file mode 100644 index 00000000..b07c40d2 --- /dev/null +++ b/backend/prisma/migrations/20260502135500_add_allergi_mejeri_matfett_l3/migration.sql @@ -0,0 +1,15 @@ +-- Ensure L3 "Matfett" exists under: +-- Mejeri, ost & ägg > Allergi mejeri + +INSERT INTO `Category` (`name`, `parentId`) +SELECT 'Matfett', c2.id +FROM `Category` c1 +JOIN `Category` c2 + ON c2.parentId = c1.id + AND c2.name = 'Allergi mejeri' +LEFT JOIN `Category` c3 + ON c3.parentId = c2.id + AND c3.name = 'Matfett' +WHERE c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL + AND c3.id IS NULL; diff --git a/backend/prisma/migrations/20260502141000_add_allergi_matlagning_l3/migration.sql b/backend/prisma/migrations/20260502141000_add_allergi_matlagning_l3/migration.sql new file mode 100644 index 00000000..de792ecb --- /dev/null +++ b/backend/prisma/migrations/20260502141000_add_allergi_matlagning_l3/migration.sql @@ -0,0 +1,15 @@ +-- Ensure L3 "Allergi matlagning" exists under: +-- Mejeri, ost & ägg > Matlagning + +INSERT INTO `Category` (`name`, `parentId`) +SELECT 'Allergi matlagning', c2.id +FROM `Category` c1 +JOIN `Category` c2 + ON c2.parentId = c1.id + AND c2.name = 'Matlagning' +LEFT JOIN `Category` c3 + ON c3.parentId = c2.id + AND c3.name = 'Allergi matlagning' +WHERE c1.name = 'Mejeri, ost & ägg' + AND c1.parentId IS NULL + AND c3.id IS NULL; diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index fb8ba085..75fcc7ea 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; -import { AiService } from '../ai/ai.service'; +import { AiService, CategorySuggestion } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; const IMPORTER_SERVICE_URL = @@ -32,6 +32,15 @@ function tokenize(value: string): string[] { .filter((w) => w.length >= 3); } +function normalizeForRules(value: string): string { + return value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); @@ -221,6 +230,12 @@ export class ReceiptImportService { const enriched = new Map(); for (const item of unmatched) { try { + const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories); + if (byRule) { + enriched.set(item.rawName, { ...item, categorySuggestion: byRule }); + continue; + } + const suggestion = await this.aiService.suggestCategory(item.rawName, categories); enriched.set(item.rawName, { ...item, categorySuggestion: suggestion }); } catch { @@ -231,4 +246,61 @@ export class ReceiptImportService { return items.map((item) => enriched.get(item.rawName) ?? item); } + + private ruleBasedCategorySuggestion( + rawName: string, + categories: Awaited>, + ): CategorySuggestion | null { + const normalized = normalizeForRules(rawName); + + const isCookingBase = + /\bmatlagningsbas\b/.test(normalized) || + /\bmatlagnings\b/.test(normalized) || + /\bplant\s+cream\b/.test(normalized) || + /\bcreme\s+fraiche\b/.test(normalized) || + /\bgradde\b/.test(normalized) || + /\bvispgradde\b/.test(normalized); + + const isPlantOrAllergy = + /\blaktosfri\b/.test(normalized) || + /\bvegetabilisk\b/.test(normalized) || + /\bhavre\b/.test(normalized) || + /\bsoja\b/.test(normalized) || + /\brisdryck\b/.test(normalized) || + /\bplant\b/.test(normalized); + + if (!isCookingBase || !isPlantOrAllergy) return null; + + const l3AllergyCooking = categories.find( + (c) => + c.name.toLowerCase() === 'allergi matlagning' && + c.path.toLowerCase().startsWith('matlagning > '), + ); + if (l3AllergyCooking) { + return { + categoryId: l3AllergyCooking.id, + categoryName: l3AllergyCooking.name, + path: l3AllergyCooking.path, + confidence: 'high', + usedFallback: false, + }; + } + + const l2Cooking = categories.find( + (c) => + c.name.toLowerCase() === 'matlagning' && + c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning', + ); + if (l2Cooking) { + return { + categoryId: l2Cooking.id, + categoryName: l2Cooking.name, + path: l2Cooking.path, + confidence: 'medium', + usedFallback: false, + }; + } + + return null; + } } diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index e8526a21..c3e5f901 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -331,13 +331,21 @@ INSERT INTO `Category` (`name`, `parentId`) -- ── NIVÅ 3: under Mejeri, ost & ägg > Allergi mejeri ──────────────── INSERT INTO `Category` (`name`, `parentId`) - SELECT 'Mjölk', c2.id FROM `Category` c1 + SELECT 'Laktosfri mjölk', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Filmjölk & Yoghurt', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Kvarg & Cottage cheese', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' + WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Matfett', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' + WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; -- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ─────────────── INSERT INTO `Category` (`name`, `parentId`) @@ -354,6 +362,10 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Matlagningsyoghurt', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Matlagning' WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Allergi matlagning', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Matlagning' + WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; -- ── NIVÅ 3: under Mejeri, ost & ägg > Smör, margarin & jäst ──────────── INSERT INTO `Category` (`name`, `parentId`) @@ -372,6 +384,10 @@ INSERT INTO `Category` (`name`, `parentId`) WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; -- ── NIVÅ 3: under Mejeri, ost & ägg > Kvarg & Cottage cheese ──────────── +INSERT INTO `Category` (`name`, `parentId`) + SELECT 'Kvarg & Cottage cheese', c2.id FROM `Category` c1 + JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kvarg & Cottage cheese' + WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; INSERT INTO `Category` (`name`, `parentId`) SELECT 'Kvarg', c2.id FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kvarg & Cottage cheese'