Refactor code structure for improved readability and maintainability
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-06 07:37:59 +02:00
parent e4f201ea36
commit 969dafdbc6
273 changed files with 11357 additions and 39 deletions
@@ -0,0 +1,14 @@
import type { CategorySuggestion } from '../../ai/ai.service';
export interface ParsedReceiptItem {
rawName: string;
quantity: number;
unit: string;
price?: number | null;
brand?: string | null;
origin?: string | null;
matchedProductId?: number;
matchedProductName?: string;
suggestedProductId?: number;
suggestedProductName?: string;
categorySuggestion?: CategorySuggestion;
}
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=parsed-receipt-item.dto.js.map
@@ -0,0 +1 @@
{"version":3,"file":"parsed-receipt-item.dto.js","sourceRoot":"","sources":["../../../src/receipt-import/dto/parsed-receipt-item.dto.ts"],"names":[],"mappings":""}
@@ -0,0 +1,10 @@
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
export declare class ReceiptImportController {
private readonly receiptImportService;
constructor(receiptImportService: ReceiptImportService);
parseReceipt(file?: Express.Multer.File, req?: any): Promise<ParsedReceiptItem[]>;
refreshCategories(): Promise<{
message: string;
}>;
}
@@ -0,0 +1,77 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReceiptImportController = void 0;
const common_1 = require("@nestjs/common");
const throttler_1 = require("@nestjs/throttler");
const platform_express_1 = require("@nestjs/platform-express");
const multer_1 = require("multer");
const receipt_import_service_1 = require("./receipt-import.service");
const passport_1 = require("@nestjs/passport");
const ALLOWED_MIMES = [
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif',
'application/pdf',
'application/octet-stream',
];
let ReceiptImportController = class ReceiptImportController {
constructor(receiptImportService) {
this.receiptImportService = receiptImportService;
}
async parseReceipt(file, req) {
if (!file?.buffer) {
throw new common_1.BadRequestException('Ingen fil skickades med.');
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
throw new common_1.BadRequestException('Otillåten filtyp. Använd JPEG, PNG, WebP eller PDF.');
}
const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin';
const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
return this.receiptImportService.parseReceipt(file, isPremium, userId);
}
async refreshCategories() {
await this.receiptImportService.loadCategories();
return { message: 'Kategorier har uppdaterats.' };
}
};
exports.ReceiptImportController = ReceiptImportController;
__decorate([
(0, common_1.HttpCode)(200),
(0, common_1.Post)(),
(0, throttler_1.Throttle)({ default: { ttl: 60_000, limit: 20 } }),
(0, common_1.UseInterceptors)((0, platform_express_1.FileInterceptor)('file', {
storage: (0, multer_1.memoryStorage)(),
limits: { fileSize: 15 * 1024 * 1024 },
})),
__param(0, (0, common_1.UploadedFile)()),
__param(1, (0, common_1.Request)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", Promise)
], ReceiptImportController.prototype, "parseReceipt", null);
__decorate([
(0, common_1.Post)('refresh-categories'),
(0, common_1.UseGuards)((0, passport_1.AuthGuard)('jwt')),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", Promise)
], ReceiptImportController.prototype, "refreshCategories", null);
exports.ReceiptImportController = ReceiptImportController = __decorate([
(0, common_1.Controller)('receipt-import'),
__metadata("design:paramtypes", [receipt_import_service_1.ReceiptImportService])
], ReceiptImportController);
//# sourceMappingURL=receipt-import.controller.js.map
@@ -0,0 +1 @@
{"version":3,"file":"receipt-import.controller.js","sourceRoot":"","sources":["../../src/receipt-import/receipt-import.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CASwB;AACxB,iDAA6C;AAC7C,+DAA2D;AAC3D,mCAAuC;AACvC,qEAAgE;AAEhE,+CAA6C;AAE7C,MAAM,aAAa,GAAG;IACpB,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,0BAA0B;CAC3B,CAAC;AAGK,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAClC,YAA6B,oBAA0C;QAA1C,yBAAoB,GAApB,oBAAoB,CAAsB;IAAG,CAAC;IAWrE,AAAN,KAAK,CAAC,YAAY,CACA,IAA0B,EAC/B,GAAS;QAEpB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;YAClB,MAAM,IAAI,4BAAmB,CAAC,0BAA0B,CAAC,CAAC;QAC5D,CAAC;QACD,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3C,MAAM,IAAI,4BAAmB,CAC3B,qDAAqD,CACtD,CAAC;QACJ,CAAC;QACD,MAAM,SAAS,GAAG,GAAG,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,IAAI,GAAG,EAAE,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC;QAC/E,MAAM,MAAM,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QAC3E,OAAO,IAAI,CAAC,oBAAoB,CAAC,YAAY,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;IACzE,CAAC;IAIK,AAAN,KAAK,CAAC,iBAAiB;QACrB,MAAM,IAAI,CAAC,oBAAoB,CAAC,cAAc,EAAE,CAAC;QACjD,OAAO,EAAE,OAAO,EAAE,6BAA6B,EAAE,CAAC;IACpD,CAAC;CACF,CAAA;AAnCY,0DAAuB;AAY5B;IATL,IAAA,iBAAQ,EAAC,GAAG,CAAC;IACb,IAAA,aAAI,GAAE;IACN,IAAA,oBAAQ,EAAC,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,CAAC;IACjD,IAAA,wBAAe,EACd,IAAA,kCAAe,EAAC,MAAM,EAAE;QACtB,OAAO,EAAE,IAAA,sBAAa,GAAE;QACxB,MAAM,EAAE,EAAE,QAAQ,EAAE,EAAE,GAAG,IAAI,GAAG,IAAI,EAAE;KACvC,CAAC,CACH;IAEE,WAAA,IAAA,qBAAY,GAAE,CAAA;IACd,WAAA,IAAA,gBAAO,GAAE,CAAA;;;;2DAaX;AAIK;IAFL,IAAA,aAAI,EAAC,oBAAoB,CAAC;IAC1B,IAAA,kBAAS,EAAC,IAAA,oBAAS,EAAC,KAAK,CAAC,CAAC;;;;gEAI3B;kCAlCU,uBAAuB;IADnC,IAAA,mBAAU,EAAC,gBAAgB,CAAC;qCAEwB,6CAAoB;GAD5D,uBAAuB,CAmCnC"}
@@ -0,0 +1,2 @@
export declare class ReceiptImportModule {
}
+26
View File
@@ -0,0 +1,26 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReceiptImportModule = void 0;
const common_1 = require("@nestjs/common");
const receipt_import_controller_1 = require("./receipt-import.controller");
const receipt_import_service_1 = require("./receipt-import.service");
const prisma_module_1 = require("../prisma/prisma.module");
const ai_module_1 = require("../ai/ai.module");
const categories_module_1 = require("../categories/categories.module");
let ReceiptImportModule = class ReceiptImportModule {
};
exports.ReceiptImportModule = ReceiptImportModule;
exports.ReceiptImportModule = ReceiptImportModule = __decorate([
(0, common_1.Module)({
imports: [prisma_module_1.PrismaModule, ai_module_1.AiModule, categories_module_1.CategoriesModule],
controllers: [receipt_import_controller_1.ReceiptImportController],
providers: [receipt_import_service_1.ReceiptImportService],
})
], ReceiptImportModule);
//# sourceMappingURL=receipt-import.module.js.map
@@ -0,0 +1 @@
{"version":3,"file":"receipt-import.module.js","sourceRoot":"","sources":["../../src/receipt-import/receipt-import.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2EAAsE;AACtE,qEAAgE;AAChE,2DAAuD;AACvD,+CAA2C;AAC3C,uEAAmE;AAO5D,IAAM,mBAAmB,GAAzB,MAAM,mBAAmB;CAAG,CAAA;AAAtB,kDAAmB;8BAAnB,mBAAmB;IAL/B,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,4BAAY,EAAE,oBAAQ,EAAE,oCAAgB,CAAC;QACnD,WAAW,EAAE,CAAC,mDAAuB,CAAC;QACtC,SAAS,EAAE,CAAC,6CAAoB,CAAC;KAClC,CAAC;GACW,mBAAmB,CAAG"}
+25
View File
@@ -0,0 +1,25 @@
import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
export declare function isIgnoredReceiptName(value: string | null | undefined): boolean;
export declare class ReceiptImportService {
private readonly prisma;
private readonly aiService;
private readonly categoriesService;
private readonly logger;
private cachedCategories;
constructor(prisma: PrismaService, aiService: AiService, categoriesService: CategoriesService);
loadCategories(): Promise<void>;
parseReceipt(file: Express.Multer.File, _isPremium?: boolean, userId?: number): Promise<ParsedReceiptItem[]>;
private parseReceiptViaImporter;
private matchProducts;
private findWordMatch;
private enrichWithAiCategories;
private shouldTraceDecision;
private resolvePorkCategory;
private resolveBreadCategory;
private applyHardCategoryOverrides;
private ruleBasedCategorySuggestion;
private applyContradictionGuard;
}
+983
View File
@@ -0,0 +1,983 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var ReceiptImportService_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReceiptImportService = void 0;
exports.isIgnoredReceiptName = isIgnoredReceiptName;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../prisma/prisma.service");
const ai_service_1 = require("../ai/ai.service");
const categories_service_1 = require("../categories/categories.service");
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
const WEAK_DESCRIPTORS = new Set([
'rokt',
'rökt',
'kokt',
'grillad',
'stekt',
'skivad',
'strimlad',
'fryst',
'farsk',
'färsk',
]);
function tokenize(value) {
return value
.toLowerCase()
.split(/[^a-z0-9åäö]+/)
.filter((w) => w.length >= 3);
}
function isIgnoredReceiptName(value) {
const normalized = (value ?? '').trim().toLowerCase();
if (!normalized)
return false;
if (/^rabatt\b/.test(normalized))
return true;
if (/^summa\b/.test(normalized))
return true;
if (/^moms\b/.test(normalized))
return true;
if (/^pant\b/.test(normalized))
return true;
if (/^att\s+betala\b/.test(normalized))
return true;
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;
}
function normalizeToken(s) {
return s.replace(/å/g, 'a').replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/é/g, 'e').replace(/è/g, 'e');
}
function normalizeForRules(value) {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function hasPorkLikeSignal(normalized) {
return (normalized.includes('bacon') ||
normalized.includes('bacn') ||
normalized.includes('baco') ||
/\bbac[a-z0-9]{1,5}\b/.test(normalized) ||
/\bsidflask\b/.test(normalized) ||
/\bpancetta\b/.test(normalized) ||
/\bflask\b/.test(normalized) ||
/\bflaskfile\b/.test(normalized) ||
/\bkarr[eé]\b/.test(normalized) ||
/\bkotlett\b/.test(normalized));
}
function hasBreadLikeSignal(normalized) {
return (/\brostbrod\b/.test(normalized) ||
/\brost\s*n\s*toast\b/.test(normalized) ||
/\broast\s*n\s*toast\b/.test(normalized) ||
/\btoastbrod\b/.test(normalized) ||
/\bformbrod\b/.test(normalized) ||
/\blantbrod\b/.test(normalized) ||
/\bfullkornsbrod\b/.test(normalized) ||
/\bfranska\b/.test(normalized) ||
/\blimpa\b/.test(normalized) ||
/\bbrod\b/.test(normalized) ||
/\btoast\b/.test(normalized));
}
function inferPackageDebugFromRawName(rawName) {
const normalized = rawName.toLowerCase();
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,
};
}
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,
};
}
return {
packageCount: 1,
packQuantity: null,
packUnit: null,
};
}
let ReceiptImportService = ReceiptImportService_1 = class ReceiptImportService {
constructor(prisma, aiService, categoriesService) {
this.prisma = prisma;
this.aiService = aiService;
this.categoriesService = categoriesService;
this.logger = new common_1.Logger(ReceiptImportService_1.name);
this.cachedCategories = [];
this.loadCategories();
}
async loadCategories() {
this.cachedCategories = await this.prisma.category.findMany({
include: { children: true },
});
}
async parseReceipt(file, _isPremium = false, userId) {
const rawItems = await this.parseReceiptViaImporter(file);
const matched = await this.matchProducts(rawItems, userId);
return this.enrichWithAiCategories(matched);
}
async parseReceiptViaImporter(file) {
const form = new FormData();
form.append('file', new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), file.originalname);
let response;
try {
response = await fetch(`${IMPORTER_SERVICE_URL}/api/receipt-import/parse`, {
method: 'POST',
body: form,
});
}
catch (err) {
this.logger.error(`Kunde inte nå importer-api för kvittoparsning: ${err}`);
throw new common_1.ServiceUnavailableException('Import-tjänsten är inte tillgänglig. Försök igen senare.');
}
if (!response.ok) {
let message = `Importer svarade ${response.status}`;
try {
const body = (await response.json());
if (body.message)
message = body.message;
}
catch {
}
if (response.status === 503 || response.status === 429) {
throw new common_1.ServiceUnavailableException(message);
}
throw new common_1.BadRequestException(message);
}
const items = (await response.json());
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
}
async matchProducts(items, userId) {
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId
? {
OR: [
{ ownerId: userId, isGlobal: false },
{ isGlobal: true },
],
}
: { isGlobal: true };
const [aliases, products] = 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 } } },
}),
]);
return items.map((item) => {
const raw = (item.rawName ?? '').toLowerCase().trim();
if (!raw)
return item;
const alias = aliases.find((a) => a.receiptName === raw);
if (alias) {
const cat = alias.product.categoryRef;
return {
...item,
matchedProductId: alias.product.id,
matchedProductName: alias.product.canonicalName ?? alias.product.name,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high', usedFallback: false } } : {}),
};
}
const suggestion = this.findWordMatch(raw, products);
if (!suggestion) {
return { ...item };
}
const cat = suggestion.categoryRef;
return {
...item,
suggestedProductId: suggestion.id,
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium', usedFallback: false } } : {}),
};
});
}
findWordMatch(raw, products) {
const rawWords = tokenize(raw);
if (rawWords.length === 0)
return undefined;
const rawWordSet = new Set(rawWords);
const rawWordsNorm = rawWords.map(normalizeToken);
const rawWordSetNorm = new Set(rawWordsNorm);
let best;
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;
}
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) {
continue;
}
partialStrong += 1;
score += 3;
}
const hasLongPartial = partialStrong >= 1 && productWords.some((pw) => pw.length >= 5);
const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2 || hasLongPartial;
if (!hasStrongSignal)
continue;
if (score < 8)
continue;
if (!best || score > best.score) {
best = { product, score };
}
}
return best?.product;
}
async enrichWithAiCategories(items) {
let categories;
try {
categories = await this.categoriesService.findFlattened();
}
catch {
return items;
}
const enriched = [];
for (const item of items) {
if (!item.rawName) {
enriched.push(item);
continue;
}
try {
const signalText = [
item.rawName,
item.matchedProductName,
item.suggestedProductName,
]
.filter((v) => typeof v === 'string' && v.trim().length > 0)
.join(' ');
const trace = [];
const traceEnabled = this.shouldTraceDecision(signalText || item.rawName);
const pushTrace = (msg) => {
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;
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}"`);
}
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}"`);
}
if (!nextSuggestion) {
pushTrace('ai invoked');
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
}
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 }
: item);
}
catch (err) {
const traceSignalText = [
item.rawName,
item.matchedProductName,
item.suggestedProductName,
]
.filter((v) => 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)}`);
}
enriched.push(item);
}
}
return enriched;
}
shouldTraceDecision(signalText) {
const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase();
if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') {
return true;
}
const normalized = normalizeForRules(signalText);
return hasPorkLikeSignal(normalized);
}
resolvePorkCategory(categories) {
return (categories.find((c) => c.name.toLowerCase() === 'fläsk' &&
c.path.toLowerCase().startsWith('kött, chark & fågel > kött > ')) ||
categories.find((c) => c.name.toLowerCase() === 'kött' &&
c.path.toLowerCase() === 'kött, chark & fågel > kött') ||
categories.find((c) => c.path.toLowerCase() === 'kött, chark & fågel'));
}
resolveBreadCategory(categories) {
return (categories.find((c) => c.name.toLowerCase() === 'rostbröd' &&
c.path.toLowerCase().startsWith('bröd & kakor > bröd > ')) ||
categories.find((c) => c.name.toLowerCase() === 'bröd' &&
c.path.toLowerCase() === 'bröd & kakor > bröd') ||
categories.find((c) => c.path.toLowerCase() === 'bröd & kakor'));
}
applyHardCategoryOverrides(signalText, suggestion, categories) {
const normalized = normalizeForRules(signalText);
const hasBaconLikeSignal = hasPorkLikeSignal(normalized);
if (!hasBaconLikeSignal)
return suggestion;
const l3Pork = this.resolvePorkCategory(categories);
if (!l3Pork) {
this.logger.warn(`Hard-override: pork signal hittad men ingen köttkategori kunde hittas för "${signalText}"`);
return suggestion;
}
if (suggestion.categoryId === l3Pork.id)
return suggestion;
this.logger.log(`Hard-override: "${signalText}" remappas från "${suggestion.path}" till "${l3Pork.path}"`);
return {
categoryId: l3Pork.id,
categoryName: l3Pork.name,
path: l3Pork.path,
confidence: 'high',
usedFallback: true,
};
}
ruleBasedCategorySuggestion(rawName, categories) {
const normalized = normalizeForRules(rawName);
const findCategory = (opts) => categories.find((c) => {
const cName = c.name.toLowerCase();
const cPath = c.path.toLowerCase();
if (cName !== opts.name.toLowerCase())
return false;
if (opts.startsWith && !cPath.startsWith(opts.startsWith.toLowerCase()))
return false;
if (opts.includes && !cPath.includes(opts.includes.toLowerCase()))
return false;
return true;
});
const toSuggestion = (cat, confidence = 'high') => {
if (!cat)
return null;
return {
categoryId: cat.id,
categoryName: cat.name,
path: cat.path,
confidence,
usedFallback: false,
};
};
const hasPorkSignal = hasPorkLikeSignal(normalized);
const hasToastBreadSignal = hasBreadLikeSignal(normalized);
if (hasToastBreadSignal) {
const bread = this.resolveBreadCategory(categories);
const hit = toSuggestion(bread, 'high');
if (hit)
return hit;
}
if (hasPorkSignal) {
const l3Pork = this.resolvePorkCategory(categories);
const hit = toSuggestion(l3Pork, 'high');
if (hit)
return hit;
}
const hasSausageSignal = /\bkorv\b/.test(normalized) ||
/\bfalukorv\b/.test(normalized) ||
/\bchorizo\b/.test(normalized) ||
/\bbratwurst\b/.test(normalized) ||
/\bwienerkorv\b/.test(normalized) ||
/\bgrillkorv\b/.test(normalized) ||
/\bprinskorv\b/.test(normalized) ||
/\bolkorv\b/.test(normalized);
if (hasSausageSignal) {
const isVegetarian = /\bvegetarisk\b/.test(normalized) ||
/\bvegansk\b/.test(normalized) ||
/\bvego\b/.test(normalized);
if (isVegetarian) {
const vegSausage = findCategory({
name: 'vegetarisk korv',
startsWith: 'kött, chark & fågel > korv > ',
});
const hit = toSuggestion(vegSausage, 'high');
if (hit)
return hit;
}
if (/\bolkorv\b/.test(normalized)) {
const beerSausage = findCategory({
name: 'ölkorv',
startsWith: 'kött, chark & fågel > korv > ',
});
const hit = toSuggestion(beerSausage, 'high');
if (hit)
return hit;
}
const sausageGeneral = findCategory({
name: 'grill, kok- & kryddkorv',
startsWith: 'kött, chark & fågel > korv > ',
});
const hit = toSuggestion(sausageGeneral, 'high');
if (hit)
return hit;
}
const hasPoultrySignal = /\bkyckling\b/.test(normalized) ||
/\bkalkon\b/.test(normalized) ||
/\bdrumsticks?\b/.test(normalized) ||
/\bling?file\b/.test(normalized) ||
/\blarfile\b/.test(normalized) ||
/\bkycklinglar\b/.test(normalized);
if (hasPoultrySignal) {
const isFrozen = /\bfryst\b/.test(normalized) || /\bdjupfryst\b/.test(normalized);
if (isFrozen) {
const frozenPoultry = findCategory({
name: 'fryst fågel',
startsWith: 'kött, chark & fågel > fågel > ',
});
const hit = toSuggestion(frozenPoultry, 'high');
if (hit)
return hit;
}
const freshPoultry = findCategory({
name: 'färsk fågel',
startsWith: 'kött, chark & fågel > fågel > ',
});
const hit = toSuggestion(freshPoultry, 'high');
if (hit)
return hit;
}
const hasColdCutSignal = /\bpalagg\b/.test(normalized) ||
/\bpalegg\b/.test(normalized) ||
/\bskivad\b/.test(normalized) ||
/\bsalami\b/.test(normalized) ||
/\bmedvurst\b/.test(normalized) ||
/\bpastrami\b/.test(normalized) ||
/\bskinka\b/.test(normalized);
if (hasColdCutSignal) {
const hasSlicedSausageSignal = /\bsalami\b/.test(normalized) ||
/\bmedvurst\b/.test(normalized) ||
/\bpastrami\b/.test(normalized);
if (hasSlicedSausageSignal) {
const salamiColdCut = findCategory({
name: 'korv & salami',
startsWith: 'kött, chark & fågel > pålägg > ',
});
const hit = toSuggestion(salamiColdCut, 'high');
if (hit)
return hit;
}
const slicedColdCut = findCategory({
name: 'skivat pålägg',
startsWith: 'kött, chark & fågel > pålägg > ',
});
const hit = toSuggestion(slicedColdCut, 'high');
if (hit)
return hit;
}
const hasMinceSignal = /\bfars\b/.test(normalized) ||
/\bfarse\b/.test(normalized) ||
/\bmince\b/.test(normalized) ||
/\bköttfärs\b/.test(rawName.toLowerCase());
if (hasMinceSignal && !hasPoultrySignal) {
const mince = findCategory({
name: 'köttfärs',
startsWith: 'kött, chark & fågel > kött > ',
});
const hit = toSuggestion(mince, 'high');
if (hit)
return hit;
}
const hasBeefVealSignal = /\bnot\b/.test(normalized) ||
/\bkalv\b/.test(normalized) ||
/\bbiff\b/.test(normalized) ||
/\bentrecote\b/.test(normalized) ||
/\brostbiff\b/.test(normalized) ||
/\bryggbiff\b/.test(normalized);
if (hasBeefVealSignal && !hasMinceSignal) {
const beefVeal = findCategory({
name: 'nöt & kalv',
startsWith: 'kött, chark & fågel > kött > ',
});
const hit = toSuggestion(beefVeal, 'high');
if (hit)
return hit;
}
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;
}
const hasCreamSignal = /\bvispgradde\b/.test(normalized) ||
/\bmatlagningsgradde\b/.test(normalized) ||
/\bgradde\b/.test(normalized) ||
/\bcreme\s+fraiche\b/.test(normalized) ||
/\bgraddfil\b/.test(normalized);
const hasPlantOrAllergySignal = /\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 (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 > ',
});
const hit = toSuggestion(l2CookingDairy, 'high');
if (hit)
return hit;
}
const hasMilkSignal = /\bmjolk\b/.test(normalized) ||
/\bstandardmjolk\b/.test(normalized) ||
/\bstandmjolk\b/.test(normalized) ||
/\besl\b/.test(normalized);
const hasLactoseFreeSignal = /\blaktosfri\b/.test(normalized) ||
/\blactose\s*free\b/.test(normalized);
if (hasMilkSignal && !hasPlantOrAllergySignal && !hasLactoseFreeSignal) {
const l3StandardMilk = findCategory({
name: 'standardmjölk',
startsWith: 'mejeri, ost & ägg > mjölk > ',
});
const hit = toSuggestion(l3StandardMilk, 'high');
if (hit)
return hit;
const l2Milk = findCategory({
name: 'mjölk',
startsWith: 'mejeri, ost & ägg > ',
});
const fallbackHit = toSuggestion(l2Milk, 'high');
if (fallbackHit)
return fallbackHit;
}
const hasEggSignal = /\bagg\b/.test(normalized) ||
/\begg\b/.test(normalized) ||
/\binne\b/.test(normalized) ||
/\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');
if (l1DairyEgg) {
return {
categoryId: l1DairyEgg.id,
categoryName: l1DairyEgg.name,
path: l1DairyEgg.path,
confidence: 'high',
usedFallback: false,
};
}
}
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;
}
const isTea = /\bte\b/.test(normalized) ||
/\btea\b/.test(normalized) ||
/\bchai\b/.test(normalized) ||
/\btepa(se|k|r)?\b/.test(normalized);
if (isTea) {
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 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 isKaffebrod = /\bkaffebrod\b/.test(normalized) ||
/\bwienerbrod\b/.test(normalized) ||
/\bdonut\b/.test(normalized) ||
/\bmunk\b/.test(normalized) ||
/\bcroissant\b/.test(normalized) ||
/\bkanelbulle\b/.test(normalized) ||
/\bbakelse\b/.test(normalized) ||
/\bsemla\b/.test(normalized) ||
/\bdammsugare\b/.test(normalized) ||
/\bkladdkaka\b/.test(normalized) ||
/\bmuffin\b/.test(normalized) ||
/\bcupcake\b/.test(normalized) ||
/\bchokladboll\b/.test(normalized);
if (isKaffebrod) {
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 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 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;
}
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;
}
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;
}
applyContradictionGuard(rawName, suggestion, categories) {
const normalized = normalizeForRules(rawName);
const hasPorkSignal = hasPorkLikeSignal(normalized);
if (hasPorkSignal) {
const aiPath = suggestion.path.toLowerCase();
const isClearlyWrongBranch = aiPath.includes('köttbullar & färsprodukter') || aiPath.includes('köttfärs');
if (!isClearlyWrongBranch)
return suggestion;
const l3Pork = this.resolvePorkCategory(categories);
if (!l3Pork)
return suggestion;
this.logger.log(`AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3Pork.path}"`);
return {
categoryId: l3Pork.id,
categoryName: l3Pork.name,
path: l3Pork.path,
confidence: 'high',
usedFallback: true,
};
}
const hasMilkSignal = /\bmjolk\b/.test(normalized) ||
/\bstandardmjolk\b/.test(normalized) ||
/\bstandmjolk\b/.test(normalized) ||
/\besl\b/.test(normalized);
const hasLactoseFreeSignal = /\blaktosfri\b/.test(normalized) ||
/\blactose\s*free\b/.test(normalized);
if (hasMilkSignal && !hasLactoseFreeSignal) {
const isWrongLactoseFreeBranch = suggestion.path.toLowerCase().includes('allergi mejeri > laktosfri mjölk');
if (isWrongLactoseFreeBranch) {
const l3StandardMilk = categories.find((c) => c.name.toLowerCase() === 'standardmjölk' &&
c.path.toLowerCase().startsWith('mejeri, ost & ägg > mjölk > '));
if (l3StandardMilk) {
this.logger.log(`AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l3StandardMilk.path}"`);
return {
categoryId: l3StandardMilk.id,
categoryName: l3StandardMilk.name,
path: l3StandardMilk.path,
confidence: 'high',
usedFallback: true,
};
}
}
}
const hasEggSignal = /\bagg\b/.test(normalized) ||
/\begg\b/.test(normalized) ||
/\binne\b/.test(normalized) ||
/\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');
if (l1DairyEgg) {
this.logger.log(`AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${l1DairyEgg.path}"`);
return {
categoryId: l1DairyEgg.id,
categoryName: l1DairyEgg.name,
path: l1DairyEgg.path,
confidence: 'high',
usedFallback: true,
};
}
}
const hasCreamSignal = /\bvispgradde\b/.test(normalized) ||
/\bmatlagningsgradde\b/.test(normalized) ||
/\bgradde\b/.test(normalized) ||
/\bcreme\s+fraiche\b/.test(normalized) ||
/\bgraddfil\b/.test(normalized);
const hasPlantOrAllergySignal = /\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 (hasCreamSignal && !hasPlantOrAllergySignal) {
const aiPath = suggestion.path.toLowerCase();
const isOutsideDairy = !aiPath.startsWith('mejeri, ost & ägg > matlagning');
if (!isOutsideDairy)
return suggestion;
const l2CookingDairy = categories.find((c) => c.name.toLowerCase() === 'matlagning' &&
c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning');
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}"`);
return {
categoryId: l2CookingDairy.id,
categoryName: l2CookingDairy.name,
path: l2CookingDairy.path,
confidence: 'high',
usedFallback: true,
};
}
const hasToastBreadSignal = hasBreadLikeSignal(normalized);
if (hasToastBreadSignal) {
const aiPath = suggestion.path.toLowerCase();
const isOutsideBread = !aiPath.startsWith('bröd & kakor > bröd');
if (!isOutsideBread)
return suggestion;
const bread = this.resolveBreadCategory(categories);
if (!bread)
return suggestion;
this.logger.log(`AI contradiction-guard: "${rawName}" remappas från "${suggestion.path}" till "${bread.path}"`);
return {
categoryId: bread.id,
categoryName: bread.name,
path: bread.path,
confidence: 'high',
usedFallback: true,
};
}
return suggestion;
}
};
exports.ReceiptImportService = ReceiptImportService;
exports.ReceiptImportService = ReceiptImportService = ReceiptImportService_1 = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
ai_service_1.AiService,
categories_service_1.CategoriesService])
], ReceiptImportService);
//# sourceMappingURL=receipt-import.service.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,176 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const receipt_import_service_1 = require("./receipt-import.service");
function cat(id, name, path) {
return { id, name, path };
}
describe('ReceiptImportService test matrix', () => {
const categories = [
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 receipt_import_service_1.ReceiptImportService(prismaMock, aiServiceMock, categoriesServiceMock);
beforeEach(() => {
jest.clearAllMocks();
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.product.findMany.mockResolvedValue([]);
});
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) => {
expect((0, receipt_import_service_1.isIgnoredReceiptName)(raw)).toBe(true);
});
it.each([
'Mezze Maniche',
'Snickers',
'Nappar Cola 80g',
'Vispgrädde 5DL',
])('ignorerar inte "%s"', (raw) => {
expect((0, receipt_import_service_1.isIgnoredReceiptName)(raw)).toBe(false);
});
});
describe('rule matrix', () => {
const matrix = [
{ 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 }) => {
const suggestion = service.ruleBasedCategorySuggestion(raw, categories);
expect(suggestion).not.toBeNull();
expect(suggestion?.path).toBe(expectedPath);
});
});
describe('alias fallback och prioritet', () => {
it('prioriterar user-alias före global alias för samma receiptName', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'mjolk 1l',
productId: 501,
product: {
id: 501,
name: 'Mjolk user',
canonicalName: 'Mjolk user',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
{
receiptName: 'mjolk 1l',
productId: 999,
product: {
id: 999,
name: 'Mjolk global',
canonicalName: 'Mjolk global',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
]);
prismaMock.product.findMany.mockResolvedValue([]);
const result = await service.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');
});
it('använder global alias när user-alias saknas', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'snickers',
productId: 222,
product: {
id: 222,
name: 'Snickers',
canonicalName: 'Snickers',
categoryId: 53,
categoryRef: { id: 53, name: 'Choklad' },
},
},
]);
prismaMock.product.findMany.mockResolvedValue([]);
const result = await service.matchProducts([{ rawName: 'SNICKERS' }], 88);
expect(result[0].matchedProductId).toBe(222);
expect(result[0].matchedProductName).toBe('Snickers');
});
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
const aliases = [];
prismaMock.receiptAlias.findMany.mockImplementation(async () => aliases);
prismaMock.product.findMany.mockResolvedValue([
{
id: 700,
name: 'Arla Mjolk 1l',
canonicalName: 'Mjolk',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
]);
const first = await service.matchProducts([{ rawName: 'ARLA MJOLK 1L' }], 42);
expect(first[0].matchedProductId).toBeUndefined();
expect(first[0].suggestedProductId).toBe(700);
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 second = await service.matchProducts([{ rawName: 'ARLA MJOLK 1L' }], 42);
expect(second[0].matchedProductId).toBe(700);
expect(second[0].matchedProductName).toBe('Mjolk');
expect(second[0].suggestedProductId).toBeUndefined();
});
});
});
//# sourceMappingURL=receipt-import.service.spec.js.map
File diff suppressed because one or more lines are too long