From 5b6d44b5552cd168e4e02899ad2a685b233e5683 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 3 May 2026 19:55:32 +0200 Subject: [PATCH] feat(receipt-import): enhance receipt processing with new category rules and add unit tests Co-authored-by: Copilot --- backend/src/ai/ai.service.ts | 5 +- .../receipt-import.service.spec.ts | 99 ++++++++++ .../receipt-import/receipt-import.service.ts | 170 +++++++++++++++++- backend/tsconfig.json | 3 +- db/seeds/seed_all.sql | 4 + 5 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 backend/src/receipt-import/receipt-import.service.spec.ts diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts index ec4494a3..bcd3de66 100644 --- a/backend/src/ai/ai.service.ts +++ b/backend/src/ai/ai.service.ts @@ -120,8 +120,9 @@ Regler: ? (parsed.confidence as 'high' | 'medium' | 'low') : 'medium'; - // Guardrail: för låg/medel konfidenspoäng, remmappa till L1-föräldern - if (confidence === 'low' || confidence === 'medium') { + // Guardrail: endast låg konfidens remappas till L1-förälder. + // Medium får behålla sin specifika kategori för att inte tappa precision. + if (confidence === 'low') { const l1Name = matchedCategory.path.split(' > ')[0]; const l1 = categories.find((c) => c.path === l1Name); if (l1 && l1.id !== matchedCategory.id) { diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts new file mode 100644 index 00000000..d00d1a3f --- /dev/null +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -0,0 +1,99 @@ +import { CategorySuggestion } from '../ai/ai.service'; +import { FlatCategory } from '../categories/categories.service'; +import { isIgnoredReceiptName, ReceiptImportService } from './receipt-import.service'; + +function cat(id: number, name: string, path: string): FlatCategory { + return { id, name, path }; +} + +describe('ReceiptImportService test matrix', () => { + const categories: FlatCategory[] = [ + cat(1, 'Bröd & Kakor', 'Bröd & Kakor'), + cat(2, 'Kondis & fika', 'Bröd & Kakor > Kondis & fika'), + cat(3, 'Kaffebröd', 'Bröd & Kakor > Kondis & fika > Kaffebröd'), + + cat(10, 'Skafferi', 'Skafferi'), + cat(11, 'Pasta, ris & matgryn', 'Skafferi > Pasta, ris & matgryn'), + cat(12, 'Pasta', 'Skafferi > Pasta, ris & matgryn > Pasta'), + + cat(20, 'Frukt & Grönt', 'Frukt & Grönt'), + cat(21, 'Potatis & rotsaker', 'Frukt & Grönt > Potatis & rotsaker'), + cat(22, 'Potatis', 'Frukt & Grönt > Potatis & rotsaker > Potatis'), + + cat(30, 'Mejeri, ost & ägg', 'Mejeri, ost & ägg'), + cat(31, 'Matlagning', 'Mejeri, ost & ägg > Matlagning'), + cat(32, 'Grädde', 'Mejeri, ost & ägg > Matlagning > Grädde'), + cat(33, 'Ägg', 'Mejeri, ost & ägg > Ägg'), + + cat(40, 'Dryck', 'Dryck'), + cat(41, 'Juice, fruktdryck & smoothie', 'Dryck > Juice, fruktdryck & smoothie'), + cat(42, 'Kyld juice & nektar', 'Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar'), + + cat(50, 'Glass, godis & snacks', 'Glass, godis & snacks'), + cat(51, 'Godis', 'Glass, godis & snacks > Godis'), + cat(52, 'Godispåsar', 'Glass, godis & snacks > Godis > Godispåsar'), + cat(53, 'Choklad', 'Glass, godis & snacks > Choklad'), + cat(54, 'Chokladkakor & rullar', 'Glass, godis & snacks > Choklad > Chokladkakor & rullar'), + ]; + + const prismaMock = { + category: { findMany: jest.fn().mockResolvedValue([]) }, + receiptAlias: { findMany: jest.fn().mockResolvedValue([]) }, + product: { findMany: jest.fn().mockResolvedValue([]) }, + }; + + const aiServiceMock = { + suggestCategory: jest.fn(), + }; + + const categoriesServiceMock = { + findFlattened: jest.fn(), + }; + + const service = new ReceiptImportService( + prismaMock as any, + aiServiceMock as any, + categoriesServiceMock as any, + ); + + describe('ignore patterns', () => { + it.each([ + 'Willys Plus:Bröd', + 'willys plus: mjölk', + 'WILLYS PLUS - ÄGG', + 'Willys Plus : Ost', + 'Rabatt kupong', + 'Summa', + ])('ignorerar "%s"', (raw: string) => { + expect(isIgnoredReceiptName(raw)).toBe(true); + }); + + it.each([ + 'Mezze Maniche', + 'Snickers', + 'Nappar Cola 80g', + 'Vispgrädde 5DL', + ])('ignorerar inte "%s"', (raw: string) => { + expect(isIgnoredReceiptName(raw)).toBe(false); + }); + }); + + describe('rule matrix', () => { + const matrix: Array<{ raw: string; expectedPath: string }> = [ + { raw: 'Mezze Maniche', expectedPath: 'Skafferi > Pasta, ris & matgryn > Pasta' }, + { raw: 'Nappar Cola 80g', expectedPath: 'Glass, godis & snacks > Godis > Godispåsar' }, + { raw: 'Snickers', expectedPath: 'Glass, godis & snacks > Choklad > Chokladkakor & rullar' }, + { raw: 'Potatis Fast', expectedPath: 'Frukt & Grönt > Potatis & rotsaker > Potatis' }, + { raw: 'Ägg 24p Inne M', expectedPath: 'Mejeri, ost & ägg > Ägg' }, + { raw: 'Dryck Multivitamin', expectedPath: 'Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar' }, + { raw: 'Vispgrädde 5DL', expectedPath: 'Mejeri, ost & ägg > Matlagning > Grädde' }, + { raw: 'Wienerbröd', expectedPath: 'Bröd & Kakor > Kondis & fika > Kaffebröd' }, + ]; + + it.each(matrix)('klassar "$raw" -> "$expectedPath"', ({ raw, expectedPath }: { raw: string; expectedPath: string }) => { + const suggestion = (service as any).ruleBasedCategorySuggestion(raw, categories) as CategorySuggestion | null; + expect(suggestion).not.toBeNull(); + expect(suggestion?.path).toBe(expectedPath); + }); + }); +}); diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index d3cfba4f..35efaab7 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -32,7 +32,7 @@ function tokenize(value: string): string[] { .filter((w) => w.length >= 3); } -function isIgnoredReceiptName(value: string | null | undefined): boolean { +export function isIgnoredReceiptName(value: string | null | undefined): boolean { const normalized = (value ?? '').trim().toLowerCase(); if (!normalized) return false; @@ -44,6 +44,7 @@ function isIgnoredReceiptName(value: string | null | undefined): boolean { if (/^totalt\b/.test(normalized)) return true; if (/^kort\b/.test(normalized)) return true; if (/^kontant\b/.test(normalized)) return true; + if (/^willys\s+plus\s*[:\-]?\b/.test(normalized)) return true; return false; } @@ -732,6 +733,38 @@ export class ReceiptImportService { if (hit) return hit; } + // ── Regel: Pasta (inkl. italienska formatnamn) ───────────────────── + const hasPastaSignal = + /\bmezze\b/.test(normalized) || + /\bmaniche\b/.test(normalized) || + /\bpenne\b/.test(normalized) || + /\brigatoni\b/.test(normalized) || + /\bfusilli\b/.test(normalized) || + /\bspaghetti\b/.test(normalized) || + /\btagliatelle\b/.test(normalized) || + /\bmakaron\w*\b/.test(normalized) || + /\bgnocchi\b/.test(normalized) || + /\blasagne\b/.test(normalized) || + /\bpasta\b/.test(normalized); + + if (hasPastaSignal) { + const freshPasta = findCategory({ + name: 'färsk pasta', + startsWith: 'skafferi > pasta, ris & matgryn > ', + }); + if (/\bfarsk\b/.test(normalized) || /\bfresh\b/.test(normalized)) { + const freshHit = toSuggestion(freshPasta, 'high'); + if (freshHit) return freshHit; + } + + const pasta = findCategory({ + name: 'pasta', + startsWith: 'skafferi > pasta, ris & matgryn > ', + }); + const pastaHit = toSuggestion(pasta, 'high'); + if (pastaHit) return pastaHit; + } + // ── Regel: Grädde/matlagningsgrädde (icke-allergi) ───────────────── const hasCreamSignal = /\bvispgradde\b/.test(normalized) || @@ -749,6 +782,13 @@ export class ReceiptImportService { /\bplant\b/.test(normalized); if (hasCreamSignal && !hasPlantOrAllergySignal) { + const l3Cream = findCategory({ + name: 'grädde', + startsWith: 'mejeri, ost & ägg > matlagning > ', + }); + const l3Hit = toSuggestion(l3Cream, 'high'); + if (l3Hit) return l3Hit; + const l2CookingDairy = findCategory({ name: 'matlagning', startsWith: 'mejeri, ost & ägg > ', @@ -783,7 +823,7 @@ export class ReceiptImportService { if (fallbackHit) return fallbackHit; } - // ── Regel: Ägg (saknar egen L2/L3 i nuvarande träd) ──────────────── + // ── Regel: Ägg ────────────────────────────────────────────────────── const hasEggSignal = /\bagg\b/.test(normalized) || /\begg\b/.test(normalized) || @@ -791,6 +831,21 @@ export class ReceiptImportService { /\b24p\b/.test(normalized); if (hasEggSignal) { + const l2Egg = categories.find( + (c) => + c.name.toLowerCase() === 'ägg' && + c.path.toLowerCase() === 'mejeri, ost & ägg > ägg', + ); + if (l2Egg) { + return { + categoryId: l2Egg.id, + categoryName: l2Egg.name, + path: l2Egg.path, + confidence: 'high', + usedFallback: false, + }; + } + const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); @@ -805,6 +860,30 @@ export class ReceiptImportService { } } + // ── Regel: Juice/fruktdryck/smoothie ─────────────────────────────── + const hasJuiceSignal = + /\bjuice\b/.test(normalized) || + /\bnektar\b/.test(normalized) || + /\bfruktdryck\b/.test(normalized) || + /\bsmoothie\b/.test(normalized) || + /\bmultivitamin\b/.test(normalized); + + if (hasJuiceSignal) { + const l3ColdJuice = findCategory({ + name: 'kyld juice & nektar', + startsWith: 'dryck > juice, fruktdryck & smoothie > ', + }); + const l3Hit = toSuggestion(l3ColdJuice, 'high'); + if (l3Hit) return l3Hit; + + const l2Juice = findCategory({ + name: 'juice, fruktdryck & smoothie', + startsWith: 'dryck > ', + }); + const l2Hit = toSuggestion(l2Juice, 'high'); + if (l2Hit) return l2Hit; + } + // ── Regel: Te ──────────────────────────────────────────────────────── const isTea = /\bte\b/.test(normalized) || @@ -829,6 +908,7 @@ export class ReceiptImportService { // ── Regel: Kaffebröd ───────────────────────────────────────────────── const isKaffebrod = + /\bkaffebrod\b/.test(normalized) || /\bwienerbrod\b/.test(normalized) || /\bdonut\b/.test(normalized) || /\bmunk\b/.test(normalized) || @@ -857,6 +937,55 @@ export class ReceiptImportService { } } + // ── Regel: Godis/chokladkakor ────────────────────────────────────── + const isChocolateBar = + /\bsnickers\b/.test(normalized) || + /\bmars\b/.test(normalized) || + /\btwix\b/.test(normalized) || + /\bbounty\b/.test(normalized) || + /\bkitkat\b/.test(normalized) || + /\bdajm\b/.test(normalized) || + /\bjapp\b/.test(normalized); + + if (isChocolateBar) { + const l3ChocolateBars = findCategory({ + name: 'chokladkakor & rullar', + startsWith: 'glass, godis & snacks > choklad > ', + }); + const hit = toSuggestion(l3ChocolateBars, 'high'); + if (hit) return hit; + } + + const isCandyBagLike = + /\bnappar\b/.test(normalized) || + /\bgodispas\w*\b/.test(normalized); + + if (isCandyBagLike) { + const l3CandyBag = findCategory({ + name: 'godispåsar', + startsWith: 'glass, godis & snacks > godis > ', + }); + const hit = toSuggestion(l3CandyBag, 'high'); + if (hit) return hit; + } + + // ── Regel: Potatis (färsk) ───────────────────────────────────────── + const hasPotatoSignal = /\bpotatis\b/.test(normalized); + const hasFrozenPotatoSignal = + /\bfryst\b/.test(normalized) || + /\bdjupfryst\b/.test(normalized) || + /\bpommes\b/.test(normalized) || + /\bstrips?\b/.test(normalized); + + if (hasPotatoSignal && !hasFrozenPotatoSignal) { + const l3Potato = findCategory({ + name: 'potatis', + startsWith: 'frukt & grönt > potatis & rotsaker > ', + }); + const l3Hit = toSuggestion(l3Potato, 'high'); + if (l3Hit) return l3Hit; + } + // ── Regel: Laktosfri/växtbaserad mejeri ────────────────────────────── const isCookingBase = /\bmatlagningsbas\b/.test(normalized) || @@ -979,6 +1108,24 @@ export class ReceiptImportService { /\b24p\b/.test(normalized); if (hasEggSignal && suggestion.path.toLowerCase().includes('allergi mejeri')) { + const l2Egg = categories.find( + (c) => + c.name.toLowerCase() === 'ägg' && + c.path.toLowerCase() === 'mejeri, ost & ägg > ägg', + ); + if (l2Egg) { + this.logger.log( + `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l2Egg.path}"`, + ); + return { + categoryId: l2Egg.id, + categoryName: l2Egg.name, + path: l2Egg.path, + confidence: 'high', + usedFallback: true, + }; + } + const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); @@ -1022,6 +1169,25 @@ export class ReceiptImportService { ); if (!l2CookingDairy) return suggestion; + const l3Cream = categories.find( + (c) => + c.name.toLowerCase() === 'grädde' && + c.path.toLowerCase().startsWith('mejeri, ost & ägg > matlagning > '), + ); + + if (l3Cream) { + this.logger.log( + `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3Cream.path}"`, + ); + return { + categoryId: l3Cream.id, + categoryName: l3Cream.name, + path: l3Cream.path, + confidence: 'high', + usedFallback: true, + }; + } + this.logger.log( `AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l2CookingDairy.path}"`, ); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 497d1faf..0a4fa0ef 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,7 +16,8 @@ "noImplicitAny": true, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "types": ["node", "jest"] }, "exclude": ["node_modules", "prisma.config.ts"] } diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index 7d546ff5..756db334 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -403,6 +403,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 'Grädde', 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'