diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index b4da9e66..04f7044a 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -59,13 +59,6 @@ export class ReceiptImportController { return this.receiptImportService.parseReceipt(file, isPremium, userId); } - @Post('refresh-categories') - @UseGuards(AuthGuard('jwt')) - async refreshCategories() { - await this.receiptImportService.loadCategories(); - return { message: 'Kategorier har uppdaterats.' }; - } - @Post('unit-mappings') @UseGuards(AuthGuard('jwt')) async upsertUnitMapping( diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index 10c74a45..bf7cc493 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -106,8 +106,12 @@ describe('ReceiptImportService test matrix', () => { }); describe('alias fallback och prioritet', () => { + function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) { + return { userId, aliases, products, unitMappings, categories, aiEnabled: false }; + } + it('prioriterar user-alias före global alias för samma receiptName', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([ + const aliases = [ { receiptName: 'mjolk 1l', productId: 501, @@ -130,32 +134,17 @@ describe('ReceiptImportService test matrix', () => { categoryRef: { id: 30, name: 'Mejeri' }, }, }, - ]); + ]; - prismaMock.product.findMany.mockResolvedValue([]); + const context = makeContext(aliases, [], [], 77); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context); - const result = await (service as any).matchProducts( - [{ rawName: 'MJOLK 1L' }], - 77, - ); - - expect(prismaMock.receiptAlias.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - OR: [ - { ownerId: 77, isGlobal: false }, - { isGlobal: true }, - ], - }, - }), - ); - - expect(result[0].matchedProductId).toBe(501); - expect(result[0].matchedProductName).toBe('Mjolk user'); + expect(result.matchedProductId).toBe(501); + expect(result.matchedProductName).toBe('Mjolk user'); }); it('använder global alias när user-alias saknas', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([ + const aliases = [ { receiptName: 'snickers', productId: 222, @@ -167,24 +156,17 @@ describe('ReceiptImportService test matrix', () => { categoryRef: { id: 53, name: 'Choklad' }, }, }, - ]); + ]; - prismaMock.product.findMany.mockResolvedValue([]); + const context = makeContext(aliases, [], [], 88); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context); - const result = await (service as any).matchProducts( - [{ rawName: 'SNICKERS' }], - 88, - ); - - expect(result[0].matchedProductId).toBe(222); - expect(result[0].matchedProductName).toBe('Snickers'); + expect(result.matchedProductId).toBe(222); + expect(result.matchedProductName).toBe('Snickers'); }); it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => { - const aliases: any[] = []; - prismaMock.receiptAlias.findMany.mockImplementation(async () => aliases); - - prismaMock.product.findMany.mockResolvedValue([ + const products = [ { id: 700, name: 'Arla Mjolk 1l', @@ -192,41 +174,39 @@ describe('ReceiptImportService test matrix', () => { categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, - ]); + ]; - const first = await (service as any).matchProducts( - [{ rawName: 'ARLA MJOLK 1L' }], - 42, - ); + const contextNoAlias = makeContext([], products, [], 42); + const first = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextNoAlias); - expect(first[0].matchedProductId).toBeUndefined(); - expect(first[0].suggestedProductId).toBe(700); + expect(first.matchedProductId).toBeUndefined(); + expect(first.suggestedProductId).toBe(700); // Simulerar att användaren manuellt korrigerar och alias lärs in. - aliases.push({ - receiptName: 'arla mjolk 1l', - productId: 700, - product: { - id: 700, - name: 'Arla Mjolk 1l', - canonicalName: 'Mjolk', - categoryId: 30, - categoryRef: { id: 30, name: 'Mejeri' }, + const aliases = [ + { + receiptName: 'arla mjolk 1l', + productId: 700, + product: { + id: 700, + name: 'Arla Mjolk 1l', + canonicalName: 'Mjolk', + categoryId: 30, + categoryRef: { id: 30, name: 'Mejeri' }, + }, }, - }); + ]; - const second = await (service as any).matchProducts( - [{ rawName: 'ARLA MJOLK 1L' }], - 42, - ); + const contextWithAlias = makeContext(aliases, products, [], 42); + const second = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextWithAlias); - expect(second[0].matchedProductId).toBe(700); - expect(second[0].matchedProductName).toBe('Mjolk'); - expect(second[0].suggestedProductId).toBeUndefined(); + expect(second.matchedProductId).toBe(700); + expect(second.matchedProductName).toBe('Mjolk'); + expect(second.suggestedProductId).toBeUndefined(); }); it('använder inlärd enhetsmappning vid aliasträff', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([ + const aliases = [ { receiptName: 'mjolk 1l', productId: 501, @@ -238,60 +218,51 @@ describe('ReceiptImportService test matrix', () => { categoryRef: { id: 30, name: 'Mejeri' }, }, }, - ]); + ]; - prismaMock.unitMapping.findMany.mockResolvedValue([ - { - productId: 501, - originalUnit: 'l', - preferredUnit: 'st', - }, - ]); + const unitMappings = [{ productId: 501, originalUnit: 'l', preferredUnit: 'st' }]; + const context = makeContext(aliases, [], unitMappings, 77); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L', unit: 'L' }, context); - const result = await (service as any).matchProducts( - [{ rawName: 'MJOLK 1L', unit: 'L' }], - 77, - ); - - expect(result[0].matchedProductId).toBe(501); - expect(result[0].unit).toBe('st'); + expect(result.matchedProductId).toBe(501); + expect(result.unit).toBe('st'); }); }); describe('matchedVia', () => { + function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) { + return { userId, aliases, products, unitMappings, categories, aiEnabled: false }; + } + it('sätter matchedVia: alias vid aliasträff', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([ + const aliases = [ { receiptName: 'snickers', productId: 222, product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: null, categoryRef: null }, }, - ]); - prismaMock.product.findMany.mockResolvedValue([]); + ]; + const context = makeContext(aliases, [], [], 10); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context); - const result = await (service as any).matchProducts([{ rawName: 'SNICKERS' }], 10); - - expect(result[0].matchedVia).toBe('alias'); + expect(result.matchedVia).toBe('alias'); }); it('sätter matchedVia: wordmatch vid ordbaserad matchning', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([]); - prismaMock.product.findMany.mockResolvedValue([ + const products = [ { id: 300, name: 'Mjolk', canonicalName: 'Mjolk', categoryId: null, categoryRef: null }, - ]); + ]; + const context = makeContext([], products, [], 10); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context); - const result = await (service as any).matchProducts([{ rawName: 'MJOLK 1L' }], 10); - - expect(result[0].matchedVia).toBe('wordmatch'); + expect(result.matchedVia).toBe('wordmatch'); }); it('sätter matchedVia: none när ingen matchning finns', async () => { - prismaMock.receiptAlias.findMany.mockResolvedValue([]); - prismaMock.product.findMany.mockResolvedValue([]); + const context = makeContext([], [], [], 10); + const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'XYZXYZ' }, context); - const result = await (service as any).matchProducts([{ rawName: 'XYZXYZ' }], 10); - - expect(result[0].matchedVia).toBe('none'); + expect(result.matchedVia).toBe('none'); }); }); -}); +}); \ No newline at end of file diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 5515a4df..570a8060 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -96,63 +96,48 @@ function hasBreadLikeSignal(normalized: string): boolean { ); } -function inferPackageDebugFromRawName(rawName: string): { - packageCount: number; - packQuantity: number | null; - packUnit: string | null; -} { - const normalized = rawName.toLowerCase(); +type UnitMappingLite = { + productId: number; + originalUnit: string; + preferredUnit: string; +}; - // e.g. "3x120g", "2 x 1.5l" - const multiPack = /(\d+)\s*[x×]\s*(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized); - if (multiPack) { - const count = Number.parseInt(multiPack[1], 10); - const qty = Number.parseFloat(multiPack[2].replace(',', '.')); - const unit = multiPack[3].toLowerCase(); - return { - packageCount: Number.isFinite(count) && count > 0 ? count : 1, - packQuantity: Number.isFinite(qty) ? qty : null, - packUnit: unit, - }; - } +type ProductLite = { + id: number; + name: string; + canonicalName: string | null; + categoryRef: { id: number; name: string } | null; +}; - // e.g. "5dl", "1,5l" - const singlePack = /(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized.replace(/([\d.,]+)(ml|cl|dl|l|g|kg)\b/i, '$1 $2')); - if (singlePack) { - const qty = Number.parseFloat(singlePack[1].replace(',', '.')); - const unit = singlePack[2].toLowerCase(); - return { - packageCount: 1, - packQuantity: Number.isFinite(qty) ? qty : null, - packUnit: unit, - }; - } +type AliasLite = { + receiptName: string; + product: ProductLite; +}; - return { - packageCount: 1, - packQuantity: null, - packUnit: null, - }; -} +type MatchingContext = { + aliases: AliasLite[]; + aliasByReceiptName?: Map; + products: ProductLite[]; + unitMappings: UnitMappingLite[]; + unitMappingByKey?: Map; + categories: Awaited>; + aiEnabled: boolean; +}; + +type MatchDebug = { + steps: string[]; + tree: Record; +}; @Injectable() export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); - private cachedCategories: any[] = []; constructor( private readonly prisma: PrismaService, private readonly aiService: AiService, private readonly categoriesService: CategoriesService, - ) { - this.loadCategories(); - } - - async loadCategories() { - this.cachedCategories = await this.prisma.category.findMany({ - include: { children: true }, - }); - } + ) {} async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { // Steg 1: Delegera AI-parsning till microservice-importer @@ -170,18 +155,7 @@ export class ReceiptImportService { ); } - private async prepareMatchingContext(userId?: number) { - type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string }; - type AliasLite = { - receiptName: string; - product: { - id: number; - name: string; - canonicalName: string | null; - categoryRef: { id: number; name: string } | null; - }; - }; - + private async prepareMatchingContext(userId?: number): Promise { const prismaAny = this.prisma as any; const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true }; @@ -230,26 +204,58 @@ export class ReceiptImportService { }, }), unitMappingsPromise, - ]) as [ - AliasLite[], - Array<{ id: number; name: string; canonicalName: string | null; categoryRef: { id: number; name: string } | null }>, - UnitMappingLite[] - ]; + ]) as [AliasLite[], ProductLite[], UnitMappingLite[]]; const user = userId ? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true } }) : null; + const aliasByReceiptName = new Map(); + for (const alias of aliases) { + if (!aliasByReceiptName.has(alias.receiptName)) { + aliasByReceiptName.set(alias.receiptName, alias); + } + } + + const unitMappingByKey = new Map(); + for (const unitMapping of unitMappings) { + const key = `${unitMapping.productId}:${unitMapping.originalUnit}`; + if (!unitMappingByKey.has(key)) { + unitMappingByKey.set(key, unitMapping.preferredUnit); + } + } + return { - userId, aliases, + aliasByReceiptName, products, unitMappings, + unitMappingByKey, categories, aiEnabled: user?.aiEngineEnabled ?? false, }; } + private resolvePreferredUnit( + productId: number, + itemUnit: string | null | undefined, + context: Pick, + ): string | null | undefined { + const normalizedUnit = (itemUnit ?? '').trim().toLowerCase(); + return ( + context.unitMappingByKey?.get(`${productId}:${normalizedUnit}`) ?? + context.unitMappings.find( + (um) => um.productId === productId && um.originalUnit === normalizedUnit, + )?.preferredUnit + ); + } + + private buildSignalText(item: ParsedReceiptItem): string { + return [item.rawName, item.matchedProductName, item.suggestedProductName] + .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + .join(' '); + } + async upsertUnitMapping( userId: number, productId: number, @@ -541,194 +547,6 @@ export class ReceiptImportService { return items.filter((item) => !isIgnoredReceiptName(item.rawName)); } - /** - * @deprecated CLEANUP PENDING (Session 2026-05-09) - * - * Ersatt av unified matcher (matchAndEnrichReceiptItem). - * Denna metod körde alias-lookup + word-match separat. - * - * Cleanup: Se enrichWithAiCategories checklist ovan. - */ - private async matchProducts( - items: ParsedReceiptItem[], - userId?: number, - ): Promise { - type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string }; - type AliasLite = { - receiptName: string; - product: { - id: number; - name: string; - canonicalName: string | null; - categoryRef: { id: number; name: string } | null; - }; - }; - - const prismaAny = this.prisma as any; - - // Hämta alias och produkter parallellt — filtrera på userId om angivet - const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true }; - const aliasFilter = userId - ? { - OR: [ - { ownerId: userId, isGlobal: false }, - { isGlobal: true }, - ], - } - : { isGlobal: true }; - const unitMappingsPromise = - userId && prismaAny.unitMapping?.findMany - ? (prismaAny.unitMapping.findMany({ - where: { userId }, - select: { productId: true, originalUnit: true, preferredUnit: true }, - }) as Promise) - : Promise.resolve([] as UnitMappingLite[]); - - const [aliases, products, unitMappings] = await Promise.all([ - this.prisma.receiptAlias.findMany({ - where: aliasFilter, - orderBy: [ - { isGlobal: 'asc' }, - { id: 'asc' }, - ], - select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } } } }, - }), - this.prisma.product.findMany({ - where: productFilter, - select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } }, - }), - unitMappingsPromise, - ]) as [AliasLite[], { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], UnitMappingLite[]]; - - return items.map((item) => { - const raw = (item.rawName ?? '').toLowerCase().trim(); - if (!raw) return item; - - // 1. Alias-match (säker, användaren behöver inte bekräfta) - const alias = aliases.find((a: AliasLite) => a.receiptName === raw); - if (alias) { - const mappedUnit = unitMappings.find( - (um) => - um.productId === alias.product.id && - um.originalUnit === (item.unit ?? '').trim().toLowerCase(), - )?.preferredUnit; - const cat = alias.product.categoryRef; - return { - ...item, - matchedProductId: alias.product.id, - matchedProductName: alias.product.canonicalName ?? alias.product.name, - unit: mappedUnit ?? item.unit, - matchedVia: 'alias' as const, - ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high' as const, usedFallback: false } } : {}), - }; - } - - // 2. Ordbaserad matchning (förslag, kräver bekräftelse) - const suggestion = this.findWordMatch(raw, products); - if (!suggestion) { - return { ...item, matchedVia: 'none' as const }; - } - - // Kontrollera om det finns en enhetsmappning för produkten och användaren - const unitMapping = unitMappings.find( - (um) => - um.productId === suggestion.id && - um.originalUnit === (item.unit ?? '').trim().toLowerCase(), - ); - const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit; - - const cat = suggestion.categoryRef; - return { - ...item, - suggestedProductId: suggestion.id, - suggestedProductName: suggestion.canonicalName ?? suggestion.name, - unit: preferredUnit, - matchedVia: 'wordmatch' as const, - ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium' as const, usedFallback: false } } : {}), - }; - }); - } - - private findWordMatch( - raw: string, - products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], - ): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null } | undefined { - // Dela upp kvittonamnet i ord (min 3 tecken) - const rawWords = tokenize(raw); - if (rawWords.length === 0) return undefined; - - const rawWordSet = new Set(rawWords); - // Normaliserade versioner (utan diakritik) för att hantera t.ex. gradde == grädde - const rawWordsNorm = rawWords.map(normalizeToken); - const rawWordSetNorm = new Set(rawWordsNorm); - - let best: - | { product: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }; score: number } - | undefined; - - for (const product of products) { - const productWords = tokenize(product.canonicalName ?? product.name); - if (productWords.length === 0) continue; - - let score = 0; - let exactStrong = 0; - let exactAny = 0; - let partialStrong = 0; - - const phrase = (product.canonicalName ?? product.name).toLowerCase(); - if (raw.includes(phrase)) { - score += 5; - } - - for (const pw of productWords) { - const isWeak = WEAK_DESCRIPTORS.has(pw); - const pwNorm = normalizeToken(pw); - - if (rawWordSet.has(pw) || rawWordSetNorm.has(pwNorm)) { - exactAny += 1; - if (isWeak) { - score += 1; - } else { - exactStrong += 1; - score += 8; - } - continue; - } - - // Delmatchning tillåts bara för ord med minst 4 tecken. - if (pw.length < 4) continue; - - const hasPartial = - rawWords.some((rw) => rw.includes(pw) || pw.includes(rw)) || - rawWordsNorm.some((rw) => rw.includes(pwNorm) || pwNorm.includes(rw)); - if (!hasPartial) continue; - - if (isWeak) { - // Deskriptiva ord (t.ex. rökt) ska inte driva förslag ensamma. - continue; - } - - partialStrong += 1; - score += 3; - } - - // Kräv antingen minst ett starkt exakt ord, eller flera samverkande signaler. - // Undantag: ett enstaka starkt partiellt ord (>=5 tecken) räcker, t.ex. vispgrädde → grädde. - const hasLongPartial = partialStrong >= 1 && productWords.some((pw) => pw.length >= 5); - const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2 || hasLongPartial; - if (!hasStrongSignal) continue; - - // Tröskel för att undvika svaga enkelträffar. - if (score < 8) continue; - - if (!best || score > best.score) { - best = { product, score }; - } - } - - return best?.product; - } - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // UNIFIED MATCHER: Kombinerar product matching + categorization // @@ -743,44 +561,28 @@ export class ReceiptImportService { private async matchAndEnrichReceiptItem( item: ParsedReceiptItem, - context: { - userId?: number; - aliases: Array<{ - receiptName: string; - product: { - id: number; - name: string; - canonicalName: string | null; - categoryRef: { id: number; name: string } | null; - }; - }>; - products: Array<{ - id: number; - name: string; - canonicalName: string | null; - categoryRef: { id: number; name: string } | null; - }>; - unitMappings: Array<{ productId: number; originalUnit: string; preferredUnit: string }>; - categories: Awaited>; - aiEnabled: boolean; - }, + context: MatchingContext, ): Promise { if (!item.rawName) return item; const raw = item.rawName.toLowerCase().trim(); - const debug = { steps: [], tree: >{} }; + const debug: MatchDebug = { steps: [], tree: {} }; try { // ┌─ STEG 1: Alias-lookup (certifierad match) ─────────────────────────┐ debug.steps.push('Step 1: Alias lookup'); - const aliasMatch = context.aliases.find((a) => a.receiptName === raw); + const aliasMatch = + context.aliasByReceiptName?.get(raw) ?? + context.aliases.find((a) => a.receiptName === raw); if (aliasMatch) { debug.tree.alias = { found: true, productId: aliasMatch.product.id }; debug.steps.push(` ✓ Alias found → productId ${aliasMatch.product.id}`); - const mappedUnit = context.unitMappings.find( - (um) => um.productId === aliasMatch.product.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(), - )?.preferredUnit; + const mappedUnit = this.resolvePreferredUnit( + aliasMatch.product.id, + item.unit, + context, + ); const aliasResult: ParsedReceiptItem = { ...item, @@ -814,10 +616,9 @@ export class ReceiptImportService { debug.tree.wordMatch = { found: true, productId: wordMatchResult.id, score: wordMatchResult.score }; debug.steps.push(` ✓ Word match found → productId ${wordMatchResult.id} (score ${wordMatchResult.score})`); - const unitMapping = context.unitMappings.find( - (um) => um.productId === wordMatchResult.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(), - ); - const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit; + const preferredUnit = + this.resolvePreferredUnit(wordMatchResult.id, item.unit, context) ?? + item.unit; const result: ParsedReceiptItem = { ...item, @@ -858,12 +659,8 @@ export class ReceiptImportService { private async enrichCategoryForItem( item: ParsedReceiptItem, - context: { - userId?: number; - categories: Awaited>; - aiEnabled: boolean; - }, - debug: any, + context: Pick, + debug: MatchDebug, ): Promise { // Kategori-hierarki: // 1. Om produktmatchning redan satte kategori, börjar vi med den @@ -873,15 +670,14 @@ export class ReceiptImportService { debug.steps.push('Step 3: Categorization'); - const signalText = [item.rawName, item.matchedProductName, item.suggestedProductName] - .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) - .join(' '); + const signalText = this.buildSignalText(item); + const signalOrRaw = signalText || item.rawName; let nextCategory = item.categorySuggestion ?? null; // ┌─ STEG 3A: Försök regel-baserad kategorisering ─────────────────────┐ debug.steps.push(' Trying rule-based categorization'); - const ruleResult = this.ruleBasedCategorySuggestion(signalText || item.rawName, context.categories); + const ruleResult = this.ruleBasedCategorySuggestion(signalOrRaw, context.categories); debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path }; if (ruleResult?.confidence === 'high') { @@ -928,7 +724,7 @@ export class ReceiptImportService { if (nextCategory) { debug.steps.push(' Applying contradiction guard'); const beforePath = nextCategory.path; - const guardedCategory = this.applyContradictionGuard(signalText || item.rawName, nextCategory, context.categories); + const guardedCategory = this.applyContradictionGuard(signalOrRaw, nextCategory, context.categories); if (guardedCategory && guardedCategory.path !== beforePath) { debug.steps.push(` ⚠️ Guard remapped: ${beforePath} → ${guardedCategory.path}`); nextCategory = guardedCategory; @@ -942,7 +738,7 @@ export class ReceiptImportService { if (nextCategory) { debug.steps.push(' Applying hard overrides'); const beforePath = nextCategory.path; - const finalCategory = this.applyHardCategoryOverrides(signalText || item.rawName, nextCategory, context.categories); + const finalCategory = this.applyHardCategoryOverrides(signalOrRaw, nextCategory, context.categories); if (finalCategory && finalCategory.path !== beforePath) { debug.steps.push(` ⚠️ Override applied: ${beforePath} → ${finalCategory.path}`); nextCategory = finalCategory; @@ -958,7 +754,7 @@ export class ReceiptImportService { debug.steps.push(`❌ FINAL: No category assigned`); } - if (this.shouldTraceDecision(signalText || item.rawName)) { + if (this.shouldTraceDecision(signalOrRaw)) { this.logger.log(`[ReceiptDecision] ${item.rawName}\n${debug.steps.join('\n')}`); } @@ -1040,172 +836,6 @@ export class ReceiptImportService { return best; } - /** - * @deprecated CLEANUP PENDING (Session 2026-05-09) - * - * Denna metod är ersatt av unified matcher (matchAndEnrichReceiptItem + enrichCategoryForItem). - * Den användes för att köra regel-baserad och AI-kategorisering separat från product-matching. - * - * Cleanup checklist: - * - [ ] Ta bort denna metod - * - [ ] Ta bort matchProducts() metod - * - [ ] Ta bort findWordMatch() (gammal version) - * - [ ] Uppdatera kommentarer - * - [ ] Kör full test suite för regression detection - */ - private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise { - let categories: Awaited>; - try { - categories = await this.categoriesService.findFlattened(); - } catch { - return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag - } - - const user = userId - ? await this.prisma.user.findUnique({ - where: { id: userId }, - select: { aiEngineEnabled: true }, - }) - : null; - - const enriched: ParsedReceiptItem[] = []; - for (const item of items) { - if (!item.rawName) { - enriched.push(item); - continue; - } - - try { - const signalText = [ - item.rawName, - item.matchedProductName, - item.suggestedProductName, - ] - .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) - .join(' '); - - const trace: string[] = []; - const traceEnabled = this.shouldTraceDecision(signalText || item.rawName); - const pushTrace = (msg: string) => { - if (traceEnabled) trace.push(msg); - }; - - const pkg = inferPackageDebugFromRawName(item.rawName); - - pushTrace( - `start raw="${item.rawName}" signal="${signalText || item.rawName}" parsedQuantity=${item.quantity ?? 'null'} parsedUnit=${item.unit ?? 'null'} packageCount=${pkg.packageCount} packQuantity=${pkg.packQuantity ?? 'null'} packUnit=${pkg.packUnit ?? 'null'}`, - ); - pushTrace( - `match matchedProductId=${item.matchedProductId ?? 'null'} suggestedProductId=${item.suggestedProductId ?? 'null'}`, - ); - if (item.categorySuggestion) { - pushTrace( - `incoming category="${item.categorySuggestion.path}" confidence=${item.categorySuggestion.confidence} fallback=${item.categorySuggestion.usedFallback}`, - ); - } - - const byRule = this.ruleBasedCategorySuggestion(signalText || item.rawName, categories); - if (byRule) { - pushTrace(`rule hit -> "${byRule.path}" (${byRule.confidence})`); - } else { - pushTrace('rule miss'); - } - let nextSuggestion = item.categorySuggestion ?? null; - - const isTrustedSuggestion = - nextSuggestion?.confidence === 'high' && !nextSuggestion.usedFallback; - - // Regel med stark signal får överstyra svaga förslag (och även felaktiga matchningsförslag) - if (byRule?.confidence === 'high') { - const sameAsCurrent = - nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId; - if (sameAsCurrent && nextSuggestion && nextSuggestion.confidence !== 'high') { - nextSuggestion = { ...nextSuggestion, confidence: 'high' }; - pushTrace(`rule applied -> "${byRule.path}" (confidence upgraded to high)`); - } else if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) { - nextSuggestion = byRule; - pushTrace(`rule applied -> "${byRule.path}"`); - } - - // Om regler säger en annan kategori än ett redan "trusted" förslag, - // låt regeln vinna för att bryta felaktiga historiska produktkopplingar. - if (!sameAsCurrent && isTrustedSuggestion) { - this.logger.log( - `Rule-override: "${item.rawName}" ändras från "${nextSuggestion?.path}" till "${byRule.path}"`, - ); - nextSuggestion = byRule; - pushTrace(`rule override trusted -> "${byRule.path}"`); - } - } else if (!nextSuggestion && byRule) { - nextSuggestion = byRule; - pushTrace(`rule fallback applied -> "${byRule.path}"`); - } - - // AI används som fallback när varken matchning eller regler satte kategori - if (!nextSuggestion) { - if (user?.aiEngineEnabled) { - pushTrace('ai invoked'); - nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories); - pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`); - } else { - pushTrace('ai skipped, feature disabled'); - } - } else { - pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`); - } - - const beforeGuardPath = nextSuggestion?.path; - const guardedSuggestion = nextSuggestion - ? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories) - : null; - if (guardedSuggestion && beforeGuardPath !== guardedSuggestion.path) { - pushTrace(`contradiction guard remap "${beforeGuardPath}" -> "${guardedSuggestion.path}"`); - } - - const beforeHardPath = guardedSuggestion?.path; - const finalSuggestion = guardedSuggestion - ? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories) - : null; - if (finalSuggestion && beforeHardPath !== finalSuggestion.path) { - pushTrace(`hard override remap "${beforeHardPath}" -> "${finalSuggestion.path}"`); - } - - if (finalSuggestion) { - pushTrace(`final -> "${finalSuggestion.path}" (${finalSuggestion.confidence})`); - } else { - pushTrace('final -> no categorySuggestion'); - } - - if (traceEnabled) { - this.logger.log(`[ReceiptDecision] ${trace.join(' | ')}`); - } - - enriched.push( - finalSuggestion - ? { ...item, categorySuggestion: finalSuggestion, matchedVia: item.matchedVia ?? (finalSuggestion ? 'ai' as const : 'none' as const) } - : item, - ); - } catch (err) { - const traceSignalText = [ - item.rawName, - item.matchedProductName, - item.suggestedProductName, - ] - .filter((v): v is string => typeof v === 'string' && v.trim().length > 0) - .join(' '); - if (this.shouldTraceDecision(traceSignalText || item.rawName)) { - this.logger.warn( - `[ReceiptDecision] error raw="${item.rawName}" signal="${traceSignalText || item.rawName}" err=${String(err)}`, - ); - } - // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta - enriched.push(item); - } - } - - return enriched; - } - private shouldTraceDecision(signalText: string): boolean { const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase(); if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') { @@ -1589,28 +1219,14 @@ export class ReceiptImportService { 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 l2EggHit = toSuggestion(l2Egg, 'high'); + if (l2EggHit) return l2EggHit; const l1DairyEgg = categories.find( (c) => c.path.toLowerCase() === 'mejeri, ost & ägg', ); - if (l1DairyEgg) { - return { - categoryId: l1DairyEgg.id, - categoryName: l1DairyEgg.name, - path: l1DairyEgg.path, - confidence: 'high', - usedFallback: false, - }; - } + const l1DairyEggHit = toSuggestion(l1DairyEgg, 'high'); + if (l1DairyEggHit) return l1DairyEggHit; } // ── Regel: Juice/fruktdryck/smoothie ─────────────────────────────── @@ -1648,15 +1264,13 @@ export class ReceiptImportService { const l3Te = categories.find( (c) => c.name.toLowerCase() === 'te' && c.path.toLowerCase().includes('te & choklad'), ); - if (l3Te) { - return { categoryId: l3Te.id, categoryName: l3Te.name, path: l3Te.path, confidence: 'high', usedFallback: false }; - } + const l3TeHit = toSuggestion(l3Te, 'high'); + if (l3TeHit) return l3TeHit; const l2TeChoklad = categories.find( (c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'), ); - if (l2TeChoklad) { - return { categoryId: l2TeChoklad.id, categoryName: l2TeChoklad.name, path: l2TeChoklad.path, confidence: 'medium', usedFallback: false }; - } + const l2TeChokladHit = toSuggestion(l2TeChoklad, 'medium'); + if (l2TeChokladHit) return l2TeChokladHit; } // ── Regel: Kaffebröd ───────────────────────────────────────────────── @@ -1679,15 +1293,13 @@ export class ReceiptImportService { const l3Kaffebrod = categories.find( (c) => c.name.toLowerCase() === 'kaffebröd' && c.path.toLowerCase().includes('kondis & fika'), ); - if (l3Kaffebrod) { - return { categoryId: l3Kaffebrod.id, categoryName: l3Kaffebrod.name, path: l3Kaffebrod.path, confidence: 'high', usedFallback: false }; - } + const l3KaffebrodHit = toSuggestion(l3Kaffebrod, 'high'); + if (l3KaffebrodHit) return l3KaffebrodHit; const l2Kondis = categories.find( (c) => c.name.toLowerCase() === 'kondis & fika' && c.path.toLowerCase().startsWith('bröd & kakor'), ); - if (l2Kondis) { - return { categoryId: l2Kondis.id, categoryName: l2Kondis.name, path: l2Kondis.path, confidence: 'medium', usedFallback: false }; - } + const l2KondisHit = toSuggestion(l2Kondis, 'medium'); + if (l2KondisHit) return l2KondisHit; } // ── Regel: Godis/chokladkakor ────────────────────────────────────── @@ -1763,30 +1375,16 @@ export class ReceiptImportService { 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 l3AllergyCookingHit = toSuggestion(l3AllergyCooking, 'high'); + if (l3AllergyCookingHit) return l3AllergyCookingHit; 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, - }; - } + const l2CookingHit = toSuggestion(l2Cooking, 'medium'); + if (l2CookingHit) return l2CookingHit; return null; }