Files
recipe-app/backend/dist/receipt-import/receipt-import.service.js
T
Nils-Johan Gynther a68a0ca86f
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add unit mapping functionality
- Added new API path for unit mappings in `api_paths.dart`.
- Implemented `upsertUnitMapping` method in `ImportRepository` to handle unit mapping creation.
- Updated `ReceiptImportTab` to learn and save unit mappings during receipt import.
- Created DTO for unit mapping with validation in `create-unit-mapping.dto.ts`.
- Added SQL migration for `UnitMapping` table creation with necessary constraints.
2026-05-07 10:00:42 +02:00

1038 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, userId);
}
async upsertUnitMapping(userId, productId, originalUnit, preferredUnit) {
const prismaAny = this.prisma;
const normalizedOriginalUnit = originalUnit.trim().toLowerCase();
const normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
if (!normalizedOriginalUnit || !normalizedPreferredUnit) {
throw new common_1.BadRequestException('Enheter måste vara ifyllda.');
}
if (normalizedOriginalUnit === normalizedPreferredUnit) {
return { skipped: true };
}
return prismaAny.unitMapping.upsert({
where: {
productId_originalUnit_userId: {
productId,
originalUnit: normalizedOriginalUnit,
userId,
},
},
update: {
preferredUnit: normalizedPreferredUnit,
},
create: {
productId,
userId,
originalUnit: normalizedOriginalUnit,
preferredUnit: normalizedPreferredUnit,
},
});
}
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 prismaAny = this.prisma;
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 },
})
: Promise.resolve([]);
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,
]);
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 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,
...(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 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,
...(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, userId) {
let categories;
try {
categories = await this.categoriesService.findFlattened();
}
catch {
return items;
}
const user = userId
? await this.prisma.user.findUnique({
where: { id: userId },
select: { aiEngineEnabled: true },
})
: null;
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) {
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 }
: 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