1586 lines
56 KiB
TypeScript
1586 lines
56 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
|
import { SaveReceiptDto } from './dto/save-receipt.dto';
|
|
import { SaveReceiptResponse } from './dto/save-receipt.response';
|
|
import { AiService, CategorySuggestion } from '../ai/ai.service';
|
|
import { CategoriesService } from '../categories/categories.service';
|
|
import { normalizeName } from '../common/utils/normalize-name';
|
|
import {
|
|
isIgnoredReceiptAliasName,
|
|
normalizeReceiptAliasName,
|
|
validateReceiptAliasName,
|
|
} from '../common/utils/receipt-alias';
|
|
|
|
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: string): string[] {
|
|
return value
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9åäö]+/)
|
|
.filter((w) => w.length >= 3);
|
|
}
|
|
|
|
export function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
|
return isIgnoredReceiptAliasName(value);
|
|
}
|
|
|
|
function normalizeToken(s: string): string {
|
|
return s.replace(/å/g, 'a').replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/é/g, 'e').replace(/è/g, 'e');
|
|
}
|
|
|
|
function normalizeForRules(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function hasPorkLikeSignal(normalized: string): boolean {
|
|
return (
|
|
normalized.includes('bacon') ||
|
|
normalized.includes('bacn') ||
|
|
normalized.includes('baco') ||
|
|
/\bbac[a-z0-9]{1,5}\b/.test(normalized) ||
|
|
normalized.includes('sidflask') ||
|
|
normalized.includes('pancetta') ||
|
|
normalized.includes('flask') ||
|
|
normalized.includes('karre') ||
|
|
normalized.includes('kotlett')
|
|
);
|
|
}
|
|
|
|
function hasBreadLikeSignal(normalized: string): boolean {
|
|
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)
|
|
);
|
|
}
|
|
|
|
type UnitMappingLite = {
|
|
productId: number;
|
|
originalUnit: string;
|
|
preferredUnit: string;
|
|
};
|
|
|
|
type ProductLite = {
|
|
id: number;
|
|
name: string;
|
|
canonicalName: string | null;
|
|
categoryRef: { id: number; name: string } | null;
|
|
};
|
|
|
|
type AliasLite = {
|
|
receiptName: string;
|
|
product: ProductLite;
|
|
};
|
|
|
|
type MatchingContext = {
|
|
aliases: AliasLite[];
|
|
aliasByReceiptName?: Map<string, AliasLite>;
|
|
products: ProductLite[];
|
|
unitMappings: UnitMappingLite[];
|
|
unitMappingByKey?: Map<string, string>;
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
aiEnabled: boolean;
|
|
};
|
|
|
|
type MatchDebug = {
|
|
steps: string[];
|
|
tree: Record<string, unknown>;
|
|
};
|
|
|
|
@Injectable()
|
|
export class ReceiptImportService {
|
|
private readonly logger = new Logger(ReceiptImportService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly aiService: AiService,
|
|
private readonly categoriesService: CategoriesService,
|
|
) {}
|
|
|
|
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
|
const rawItems = await this.parseReceiptViaImporter(file);
|
|
|
|
// Steg 2 & 3: Unified matching + categorization
|
|
// Samla context en gång för alla items
|
|
const context = await this.prepareMatchingContext(userId);
|
|
|
|
// Mappa alla items genom unified matcher
|
|
return Promise.all(
|
|
rawItems.map((item) =>
|
|
this.matchAndEnrichReceiptItem(item, context),
|
|
),
|
|
);
|
|
}
|
|
|
|
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
|
|
const prismaAny = this.prisma as any;
|
|
|
|
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<UnitMappingLite[]>)
|
|
: Promise.resolve([] as UnitMappingLite[]);
|
|
|
|
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
try {
|
|
categories = await this.categoriesService.findFlattened();
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`prepareMatchingContext: kunde inte ladda kategorier (${String(err)}). Kategori-förslag kan utebli.`,
|
|
);
|
|
categories = [];
|
|
}
|
|
if (categories.length === 0) {
|
|
this.logger.warn(
|
|
'prepareMatchingContext: inga kategorier laddade. Regel-baserade kategori-förslag blir tomma.',
|
|
);
|
|
}
|
|
|
|
const [aliases, products, unitMappings] = await Promise.all([
|
|
this.prisma.receiptAlias.findMany({
|
|
where: aliasFilter,
|
|
orderBy: [{ isGlobal: 'asc' }, { id: 'asc' }],
|
|
select: {
|
|
receiptName: true,
|
|
product: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
canonicalName: true,
|
|
categoryRef: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.product.findMany({
|
|
where: productFilter,
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
canonicalName: true,
|
|
categoryRef: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
unitMappingsPromise,
|
|
]) as [AliasLite[], ProductLite[], UnitMappingLite[]];
|
|
|
|
const user = userId
|
|
? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true } })
|
|
: null;
|
|
|
|
const aliasByReceiptName = new Map<string, AliasLite>();
|
|
for (const alias of aliases) {
|
|
const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName);
|
|
if (!normalizedReceiptName) continue;
|
|
if (!aliasByReceiptName.has(normalizedReceiptName)) {
|
|
aliasByReceiptName.set(normalizedReceiptName, alias);
|
|
}
|
|
}
|
|
|
|
const unitMappingByKey = new Map<string, string>();
|
|
for (const unitMapping of unitMappings) {
|
|
const key = `${unitMapping.productId}:${unitMapping.originalUnit}`;
|
|
if (!unitMappingByKey.has(key)) {
|
|
unitMappingByKey.set(key, unitMapping.preferredUnit);
|
|
}
|
|
}
|
|
|
|
return {
|
|
aliases,
|
|
aliasByReceiptName,
|
|
products,
|
|
unitMappings,
|
|
unitMappingByKey,
|
|
categories,
|
|
aiEnabled: user?.aiEngineEnabled ?? false,
|
|
};
|
|
}
|
|
|
|
private resolvePreferredUnit(
|
|
productId: number,
|
|
itemUnit: string | null | undefined,
|
|
context: Pick<MatchingContext, 'unitMappings' | 'unitMappingByKey'>,
|
|
): 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,
|
|
originalUnit: string,
|
|
preferredUnit: string,
|
|
) {
|
|
const prismaAny = this.prisma as any;
|
|
const normalizedOriginalUnit = originalUnit.trim().toLowerCase();
|
|
const normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
|
|
|
|
if (!normalizedOriginalUnit || !normalizedPreferredUnit) {
|
|
throw new BadRequestException('Enheter måste vara ifyllda.');
|
|
}
|
|
|
|
// Ingen inlärning behövs om enheten redan är samma.
|
|
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 saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
|
|
const response: SaveReceiptResponse = {
|
|
created: 0,
|
|
merged: 0,
|
|
pantryAdded: 0,
|
|
pantrySkipped: 0,
|
|
aliasesLearned: 0,
|
|
unitMappingsLearned: 0,
|
|
errors: [],
|
|
};
|
|
|
|
const prismaAny = this.prisma as any;
|
|
|
|
// Preload existierande pantry-poster för denna användare
|
|
const userPantry = await this.prisma.pantryItem.findMany({
|
|
where: { userId },
|
|
select: { productId: true },
|
|
});
|
|
const pantryProductIds = new Set(userPantry.map((p) => p.productId));
|
|
|
|
// Preload existierande inventarioposter för denna användare (grupperat efter productId)
|
|
const userInventory = await this.prisma.inventoryItem.findMany({
|
|
where: { userId },
|
|
select: { id: true, productId: true, quantity: true, unit: true },
|
|
});
|
|
const inventoryByProductId = new Map<number, typeof userInventory[0]>();
|
|
for (const item of userInventory) {
|
|
if (!inventoryByProductId.has(item.productId)) {
|
|
inventoryByProductId.set(item.productId, item);
|
|
}
|
|
}
|
|
|
|
// Kör allt i en transaktion för atomicitet
|
|
try {
|
|
await this.prisma.$transaction(async (tx) => {
|
|
const txAny = tx as any;
|
|
|
|
for (let index = 0; index < dto.items.length; index++) {
|
|
const item = dto.items[index];
|
|
|
|
try {
|
|
// === Steg 1: Bestäm/skapa produkten ===
|
|
let productId: number;
|
|
|
|
if (item.createProductName) {
|
|
// Skapa ny privat produkt
|
|
const name = item.createProductName.trim();
|
|
const normalizedName = `private:${userId}:${normalizeName(name)}`;
|
|
|
|
const existing = await tx.product.findUnique({
|
|
where: { normalizedName },
|
|
});
|
|
|
|
if (existing && existing.isActive) {
|
|
productId = existing.id;
|
|
} else if (existing) {
|
|
const updated = await tx.product.update({
|
|
where: { id: existing.id },
|
|
data: { isActive: true, deletedAt: null, name, canonicalName: name },
|
|
});
|
|
productId = updated.id;
|
|
} else {
|
|
const created = await tx.product.create({
|
|
data: {
|
|
name,
|
|
normalizedName,
|
|
canonicalName: name,
|
|
isActive: true,
|
|
isPrivate: true,
|
|
ownerId: userId,
|
|
...(item.categoryId != null ? { categoryId: item.categoryId } : {}),
|
|
},
|
|
});
|
|
productId = created.id;
|
|
}
|
|
} else if (item.productId) {
|
|
// Använd befintlig produkt
|
|
const product = await tx.product.findUnique({
|
|
where: { id: item.productId },
|
|
});
|
|
if (!product) {
|
|
throw new Error(`Produkten med ID ${item.productId} hittades inte.`);
|
|
}
|
|
productId = product.id;
|
|
} else {
|
|
throw new Error('Antingen productId eller createProductName måste anges.');
|
|
}
|
|
|
|
// === Steg 2: Hantera pantry eller inventory ===
|
|
if (item.destination === 'pantry') {
|
|
if (pantryProductIds.has(productId)) {
|
|
response.pantrySkipped++;
|
|
} else {
|
|
await tx.pantryItem.create({
|
|
data: { userId, productId },
|
|
});
|
|
response.pantryAdded++;
|
|
pantryProductIds.add(productId);
|
|
}
|
|
} else {
|
|
// inventory
|
|
const quantity = item.quantity ?? 0;
|
|
const unit = (item.unit ?? '').trim() || 'st';
|
|
|
|
const existing = inventoryByProductId.get(productId);
|
|
if (existing) {
|
|
// Slå samman
|
|
await tx.inventoryItem.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
quantity: {
|
|
increment: new Prisma.Decimal(quantity),
|
|
},
|
|
},
|
|
});
|
|
response.merged++;
|
|
} else {
|
|
// Skapa ny
|
|
await tx.inventoryItem.create({
|
|
data: {
|
|
userId,
|
|
productId,
|
|
quantity: new Prisma.Decimal(quantity),
|
|
unit,
|
|
brand: item.brand ?? undefined,
|
|
origin: item.origin ?? undefined,
|
|
receiptName: item.rawName,
|
|
},
|
|
});
|
|
response.created++;
|
|
// Uppdatera local cache
|
|
inventoryByProductId.set(productId, {
|
|
id: -1,
|
|
productId,
|
|
quantity: new Prisma.Decimal(quantity),
|
|
unit,
|
|
});
|
|
}
|
|
|
|
// === Steg 3: Lär in enhetsmappning om requested ===
|
|
if (item.learnUnitMapping) {
|
|
const originalUnit = (item.rawName ?? '').trim().toLowerCase();
|
|
const preferredUnit = unit.toLowerCase();
|
|
if (originalUnit && preferredUnit && originalUnit !== preferredUnit) {
|
|
await txAny.unitMapping.upsert({
|
|
where: {
|
|
productId_originalUnit_userId: {
|
|
productId,
|
|
originalUnit,
|
|
userId,
|
|
},
|
|
},
|
|
update: {
|
|
preferredUnit,
|
|
},
|
|
create: {
|
|
productId,
|
|
userId,
|
|
originalUnit,
|
|
preferredUnit,
|
|
},
|
|
});
|
|
response.unitMappingsLearned++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Steg 4: Lär in alias om requested ===
|
|
if (item.learnAlias) {
|
|
const normalizedReceiptName = normalizeReceiptAliasName(item.rawName);
|
|
const aliasValidationError = validateReceiptAliasName(normalizedReceiptName);
|
|
if (aliasValidationError) {
|
|
throw new Error(aliasValidationError);
|
|
}
|
|
if (normalizedReceiptName) {
|
|
const isGlobalAlias = item.learnAliasGlobally === true;
|
|
const aliasOwnerId: number | null = isGlobalAlias ? null : userId || null;
|
|
await tx.receiptAlias.upsert({
|
|
where: {
|
|
receiptName_ownerId_isGlobal: {
|
|
receiptName: normalizedReceiptName,
|
|
ownerId: aliasOwnerId as any,
|
|
isGlobal: isGlobalAlias,
|
|
},
|
|
},
|
|
update: {
|
|
productId,
|
|
},
|
|
create: {
|
|
receiptName: normalizedReceiptName,
|
|
productId,
|
|
ownerId: aliasOwnerId as any,
|
|
isGlobal: isGlobalAlias,
|
|
},
|
|
});
|
|
response.aliasesLearned++;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
this.logger.warn(
|
|
`saveReceipt item [${index}] error: ${errorMsg}`,
|
|
);
|
|
response.errors = response.errors ?? [];
|
|
response.errors.push({ index, error: errorMsg });
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
this.logger.error(`saveReceipt transaction failed: ${err}`);
|
|
throw new BadRequestException(
|
|
`Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
|
const form = new FormData();
|
|
form.append(
|
|
'file',
|
|
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
|
|
file.originalname,
|
|
);
|
|
|
|
let response: 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 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()) as { message?: string };
|
|
if (body.message) message = body.message;
|
|
} catch {
|
|
// ignorera parse-fel
|
|
}
|
|
if (response.status === 503 || response.status === 429) {
|
|
throw new ServiceUnavailableException(message);
|
|
}
|
|
throw new BadRequestException(message);
|
|
}
|
|
|
|
const items = (await response.json()) as ParsedReceiptItem[];
|
|
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
|
|
}
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// UNIFIED MATCHER: Kombinerar product matching + categorization
|
|
//
|
|
// KATEGORI-HIERARKI (fallback-first):
|
|
// 1. Alias-kopplad produkt (om fanns)
|
|
// 2. Word-match-kopplad produkt (om fanns)
|
|
// 3. Regel-baserad (deterministisk, alltid försökt)
|
|
// 4. AI-kategorisering (BARA som fallback när allt annat misslyckades, och om aktiverat)
|
|
//
|
|
// AI kallas ALDRIG om regel-baserad eller produkt-koppling redan satte kategori.
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
private async matchAndEnrichReceiptItem(
|
|
item: ParsedReceiptItem,
|
|
context: MatchingContext,
|
|
): Promise<ParsedReceiptItem> {
|
|
if (!item.rawName) return item;
|
|
|
|
const raw = normalizeReceiptAliasName(item.rawName);
|
|
const debug: MatchDebug = { steps: [], tree: {} };
|
|
|
|
try {
|
|
// ┌─ STEG 1: Alias-lookup (certifierad match) ─────────────────────────┐
|
|
debug.steps.push('Step 1: Alias lookup');
|
|
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 = this.resolvePreferredUnit(
|
|
aliasMatch.product.id,
|
|
item.unit,
|
|
context,
|
|
);
|
|
|
|
const aliasResult: ParsedReceiptItem = {
|
|
...item,
|
|
matchedProductId: aliasMatch.product.id,
|
|
matchedProductName: aliasMatch.product.canonicalName ?? aliasMatch.product.name,
|
|
unit: mappedUnit ?? item.unit,
|
|
matchedVia: 'alias' as const,
|
|
...(aliasMatch.product.categoryRef
|
|
? {
|
|
categorySuggestion: {
|
|
categoryId: aliasMatch.product.categoryRef.id,
|
|
categoryName: aliasMatch.product.categoryRef.name,
|
|
path: aliasMatch.product.categoryRef.name,
|
|
confidence: 'high' as const,
|
|
usedFallback: false,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
// Kör alltid enrichCategoryForItem för guard-funktioner och hard overrides
|
|
return await this.enrichCategoryForItem(aliasResult, context, debug);
|
|
}
|
|
debug.steps.push(` ✗ No alias match`);
|
|
debug.tree.alias = { found: false };
|
|
|
|
// ┌─ STEG 2: Ordet-baserad matchning (förslag) ────────────────────────┐
|
|
debug.steps.push('Step 2: Word match');
|
|
const wordMatchResult = this.findWordMatchWithScore(raw, context.products);
|
|
if (wordMatchResult) {
|
|
debug.tree.wordMatch = { found: true, productId: wordMatchResult.id, score: wordMatchResult.score };
|
|
debug.steps.push(` ✓ Word match found → productId ${wordMatchResult.id} (score ${wordMatchResult.score})`);
|
|
|
|
const preferredUnit =
|
|
this.resolvePreferredUnit(wordMatchResult.id, item.unit, context) ??
|
|
item.unit;
|
|
|
|
const result: ParsedReceiptItem = {
|
|
...item,
|
|
suggestedProductId: wordMatchResult.id,
|
|
suggestedProductName: wordMatchResult.canonicalName ?? wordMatchResult.name,
|
|
unit: preferredUnit,
|
|
matchedVia: 'wordmatch' as const,
|
|
};
|
|
|
|
// Lägg på kategori från produkt om den finns
|
|
if (wordMatchResult.categoryRef) {
|
|
result.categorySuggestion = {
|
|
categoryId: wordMatchResult.categoryRef.id,
|
|
categoryName: wordMatchResult.categoryRef.name,
|
|
path: wordMatchResult.categoryRef.name,
|
|
confidence: 'medium' as const,
|
|
usedFallback: false,
|
|
};
|
|
}
|
|
|
|
// Gå vidare till kategorisering för wordmatch
|
|
return await this.enrichCategoryForItem(result, context, debug);
|
|
}
|
|
debug.steps.push(` ✗ No word match`);
|
|
debug.tree.wordMatch = { found: false };
|
|
|
|
// ┌─ STEG 3: Regel-baserad kategorisering (no product match) ──────────┐
|
|
return await this.enrichCategoryForItem(
|
|
{ ...item, matchedVia: 'none' as const },
|
|
context,
|
|
debug,
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(`matchAndEnrichReceiptItem error for "${item.rawName}": ${err}`);
|
|
return item;
|
|
}
|
|
}
|
|
|
|
private async enrichCategoryForItem(
|
|
item: ParsedReceiptItem,
|
|
context: Pick<MatchingContext, 'categories' | 'aiEnabled'>,
|
|
debug: MatchDebug,
|
|
): Promise<ParsedReceiptItem> {
|
|
// Kategori-hierarki:
|
|
// 1. Om produktmatchning redan satte kategori, börjar vi med den
|
|
// 2. Försöker regel-baserad kategorisering (HIGH confidence: ersätt, fallback: använd om tom)
|
|
// 3. AI kallas ENDAST om ingen kategori satts än (nextCategory === null)
|
|
// 4. Guards och hard overrides tillämpas på slutresultatet
|
|
|
|
debug.steps.push('Step 3: Categorization');
|
|
|
|
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(signalOrRaw, context.categories);
|
|
debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path };
|
|
|
|
if (ruleResult?.confidence === 'high') {
|
|
const sameAsExisting = nextCategory && nextCategory.categoryId === ruleResult.categoryId;
|
|
if (!sameAsExisting) {
|
|
debug.steps.push(` ✓ Rule-based HIGH: ${ruleResult.path}`);
|
|
nextCategory = ruleResult;
|
|
} else {
|
|
debug.steps.push(` ✓ Rule-based HIGH (same as existing): ${ruleResult.path}`);
|
|
}
|
|
} else if (!nextCategory && ruleResult) {
|
|
debug.steps.push(` ✓ Rule-based fallback: ${ruleResult.path}`);
|
|
nextCategory = ruleResult;
|
|
} else {
|
|
debug.steps.push(` ✗ Rule-based miss or lower priority`);
|
|
}
|
|
|
|
// ┌─ STEG 3B: AI-kategorisering ENDAST som fallback ────────────────────┐
|
|
// AI kallas bara om:
|
|
// 1) nextCategory är fortfarande NULL (regel-baserad misslyckades/fanns inte)
|
|
// 2) User har AI aktiverat (context.aiEnabled === true)
|
|
// AI ersätter ALDRIG redan satta kategorier.
|
|
if (!nextCategory) {
|
|
debug.steps.push(' Trying AI categorization (fallback: no category set yet)');
|
|
if (context.aiEnabled) {
|
|
debug.tree.ai = { called: true };
|
|
try {
|
|
nextCategory = await this.aiService.suggestCategory(item.rawName, context.categories);
|
|
debug.steps.push(` ✓ AI suggestion: ${nextCategory.path}`);
|
|
} catch (err) {
|
|
debug.steps.push(` ✗ AI failed: ${err}`);
|
|
debug.tree.ai = { called: true, error: String(err) };
|
|
}
|
|
} else {
|
|
debug.steps.push(` ✗ AI disabled for user`);
|
|
debug.tree.ai = { called: false };
|
|
}
|
|
} else {
|
|
debug.steps.push(` ⊘ AI skipped (category already set: ${nextCategory.path})`);
|
|
debug.tree.ai = { called: false, reason: 'category_already_set' };
|
|
}
|
|
|
|
// ┌─ Contradiction guard (final sanity check) ────────────────────────┐
|
|
if (nextCategory) {
|
|
debug.steps.push(' Applying contradiction guard');
|
|
const beforePath = nextCategory.path;
|
|
const guardedCategory = this.applyContradictionGuard(signalOrRaw, nextCategory, context.categories);
|
|
if (guardedCategory && guardedCategory.path !== beforePath) {
|
|
debug.steps.push(` ⚠️ Guard remapped: ${beforePath} → ${guardedCategory.path}`);
|
|
nextCategory = guardedCategory;
|
|
debug.tree.guard = { applied: true, oldPath: beforePath, newPath: guardedCategory.path };
|
|
} else {
|
|
debug.steps.push(` ✓ Guard OK`);
|
|
}
|
|
}
|
|
|
|
// ┌─ Hard overrides (special rules for problematic cases) ─────────────┐
|
|
if (nextCategory) {
|
|
debug.steps.push(' Applying hard overrides');
|
|
const beforePath = nextCategory.path;
|
|
const finalCategory = this.applyHardCategoryOverrides(signalOrRaw, nextCategory, context.categories);
|
|
if (finalCategory && finalCategory.path !== beforePath) {
|
|
debug.steps.push(` ⚠️ Override applied: ${beforePath} → ${finalCategory.path}`);
|
|
nextCategory = finalCategory;
|
|
debug.tree.hardOverride = { applied: true, oldPath: beforePath, newPath: finalCategory.path };
|
|
} else {
|
|
debug.steps.push(` ✓ No hard override needed`);
|
|
}
|
|
}
|
|
|
|
if (nextCategory) {
|
|
debug.steps.push(`✅ FINAL: ${nextCategory.path} (${nextCategory.confidence})`);
|
|
} else {
|
|
debug.steps.push(`❌ FINAL: No category assigned`);
|
|
}
|
|
|
|
if (this.shouldTraceDecision(signalOrRaw)) {
|
|
this.logger.log(`[ReceiptDecision] ${item.rawName}\n${debug.steps.join('\n')}`);
|
|
}
|
|
|
|
return nextCategory ? { ...item, categorySuggestion: nextCategory } : item;
|
|
}
|
|
|
|
// Helper: findWordMatch som returnerar både product OCH score
|
|
private findWordMatchWithScore(
|
|
raw: string,
|
|
products: Array<{
|
|
id: number;
|
|
name: string;
|
|
canonicalName: string | null;
|
|
categoryRef: { id: number; name: string } | null;
|
|
}>,
|
|
): (typeof products[0] & { score: number }) | undefined {
|
|
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: (typeof products[0] & { 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private shouldTraceDecision(signalText: string): boolean {
|
|
const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase();
|
|
if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') {
|
|
return true;
|
|
}
|
|
|
|
const normalized = normalizeForRules(signalText);
|
|
return hasPorkLikeSignal(normalized);
|
|
}
|
|
|
|
private resolvePorkCategory(
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
|
) {
|
|
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')
|
|
);
|
|
}
|
|
|
|
private resolveBreadCategory(
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
|
) {
|
|
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')
|
|
);
|
|
}
|
|
|
|
private applyHardCategoryOverrides(
|
|
signalText: string,
|
|
suggestion: CategorySuggestion,
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
|
): CategorySuggestion {
|
|
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,
|
|
};
|
|
}
|
|
|
|
private ruleBasedCategorySuggestion(
|
|
rawName: string,
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
|
): CategorySuggestion | null {
|
|
const normalized = normalizeForRules(rawName);
|
|
const findCategory = (opts: {
|
|
name: string;
|
|
startsWith?: string;
|
|
includes?: string;
|
|
}) =>
|
|
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: { id: number; name: string; path: string } | undefined,
|
|
confidence: 'high' | 'medium' = 'high',
|
|
): CategorySuggestion | null => {
|
|
if (!cat) return null;
|
|
return {
|
|
categoryId: cat.id,
|
|
categoryName: cat.name,
|
|
path: cat.path,
|
|
confidence,
|
|
usedFallback: false,
|
|
};
|
|
};
|
|
|
|
// ── Regel: Kött/chark (bacon/fläsk m.m.) ────────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Choklad & spreads (Nutella, sjokolade, m.m.) ─────────────
|
|
const hasChocolateSignal =
|
|
normalized.includes('nutella') ||
|
|
normalized.includes('chocolate') ||
|
|
normalized.includes('choklad') ||
|
|
normalized.includes('sjokolade') ||
|
|
normalized.includes('kakao') ||
|
|
normalized.includes('spreads');
|
|
|
|
if (hasChocolateSignal) {
|
|
const chocolate = findCategory({
|
|
name: 'choklad & spreads',
|
|
includes: 'sötsaker',
|
|
});
|
|
const hit = toSuggestion(chocolate, 'high');
|
|
if (hit) return hit;
|
|
}
|
|
|
|
// ── Regel: Korvfamiljen ─────────────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Fågel (färsk/fryst) ──────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Pålägg (korv/salami vs skivat) ───────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Färs ──────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Nöt/kalv (hela detaljer) ─────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Pasta (inkl. italienska formatnamn) ─────────────────────
|
|
const hasPastaSignal =
|
|
/\bmezze\b/.test(normalized) ||
|
|
/\bmaniche\b/.test(normalized) ||
|
|
/\bpenne\b/.test(normalized) ||
|
|
/\brigatoni\b/.test(normalized) ||
|
|
/\bfusilli\b/.test(normalized) ||
|
|
/\bspaghetti\b/.test(normalized) ||
|
|
/\btagliatelle\b/.test(normalized) ||
|
|
/\bmakaron\w*\b/.test(normalized) ||
|
|
/\bgnocchi\b/.test(normalized) ||
|
|
/\blasagne\b/.test(normalized) ||
|
|
/\bpasta\b/.test(normalized);
|
|
|
|
if (hasPastaSignal) {
|
|
const freshPasta = findCategory({
|
|
name: 'färsk pasta',
|
|
startsWith: 'skafferi > pasta, ris & matgryn > ',
|
|
});
|
|
if (/\bfarsk\b/.test(normalized) || /\bfresh\b/.test(normalized)) {
|
|
const freshHit = toSuggestion(freshPasta, 'high');
|
|
if (freshHit) return freshHit;
|
|
}
|
|
|
|
const pasta = findCategory({
|
|
name: 'pasta',
|
|
startsWith: 'skafferi > pasta, ris & matgryn > ',
|
|
});
|
|
const pastaHit = toSuggestion(pasta, 'high');
|
|
if (pastaHit) return pastaHit;
|
|
}
|
|
|
|
// ── Regel: Grädde/matlagningsgrädde (icke-allergi) ─────────────────
|
|
const hasCreamSignal =
|
|
/\bvispgradde\b/.test(normalized) ||
|
|
/\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;
|
|
}
|
|
|
|
// ── Regel: Vanlig mjölk (inte laktosfri/allergi) ───────────────────
|
|
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;
|
|
}
|
|
|
|
// ── Regel: Ägg ──────────────────────────────────────────────────────
|
|
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',
|
|
);
|
|
const l2EggHit = toSuggestion(l2Egg, 'high');
|
|
if (l2EggHit) return l2EggHit;
|
|
|
|
const l1DairyEgg = categories.find(
|
|
(c) => c.path.toLowerCase() === 'mejeri, ost & ägg',
|
|
);
|
|
const l1DairyEggHit = toSuggestion(l1DairyEgg, 'high');
|
|
if (l1DairyEggHit) return l1DairyEggHit;
|
|
}
|
|
|
|
// ── Regel: Juice/fruktdryck/smoothie ───────────────────────────────
|
|
const hasJuiceSignal =
|
|
/\bjuice\b/.test(normalized) ||
|
|
/\bnektar\b/.test(normalized) ||
|
|
/\bfruktdryck\b/.test(normalized) ||
|
|
/\bsmoothie\b/.test(normalized) ||
|
|
/\bmultivitamin\b/.test(normalized);
|
|
|
|
if (hasJuiceSignal) {
|
|
const l3ColdJuice = findCategory({
|
|
name: 'kyld juice & nektar',
|
|
startsWith: 'dryck > juice, fruktdryck & smoothie > ',
|
|
});
|
|
const l3Hit = toSuggestion(l3ColdJuice, 'high');
|
|
if (l3Hit) return l3Hit;
|
|
|
|
const l2Juice = findCategory({
|
|
name: 'juice, fruktdryck & smoothie',
|
|
startsWith: 'dryck > ',
|
|
});
|
|
const l2Hit = toSuggestion(l2Juice, 'high');
|
|
if (l2Hit) return l2Hit;
|
|
}
|
|
|
|
// ── Regel: Te ────────────────────────────────────────────────────────
|
|
const isTea =
|
|
/\bte\b/.test(normalized) ||
|
|
/\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'),
|
|
);
|
|
const l3TeHit = toSuggestion(l3Te, 'high');
|
|
if (l3TeHit) return l3TeHit;
|
|
const l2TeChoklad = categories.find(
|
|
(c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'),
|
|
);
|
|
const l2TeChokladHit = toSuggestion(l2TeChoklad, 'medium');
|
|
if (l2TeChokladHit) return l2TeChokladHit;
|
|
}
|
|
|
|
// ── Regel: Kaffebröd ─────────────────────────────────────────────────
|
|
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'),
|
|
);
|
|
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'),
|
|
);
|
|
const l2KondisHit = toSuggestion(l2Kondis, 'medium');
|
|
if (l2KondisHit) return l2KondisHit;
|
|
}
|
|
|
|
// ── Regel: Godis/chokladkakor ──────────────────────────────────────
|
|
const isChocolateBar =
|
|
/\bsnickers\b/.test(normalized) ||
|
|
/\bmars\b/.test(normalized) ||
|
|
/\btwix\b/.test(normalized) ||
|
|
/\bbounty\b/.test(normalized) ||
|
|
/\bkitkat\b/.test(normalized) ||
|
|
/\bdajm\b/.test(normalized) ||
|
|
/\bjapp\b/.test(normalized);
|
|
|
|
if (isChocolateBar) {
|
|
const l3ChocolateBars = findCategory({
|
|
name: 'chokladkakor & rullar',
|
|
startsWith: 'glass, godis & snacks > choklad > ',
|
|
});
|
|
const hit = toSuggestion(l3ChocolateBars, 'high');
|
|
if (hit) return hit;
|
|
}
|
|
|
|
const isCandyBagLike =
|
|
/\bnappar\b/.test(normalized) ||
|
|
/\bgodispas\w*\b/.test(normalized);
|
|
|
|
if (isCandyBagLike) {
|
|
const l3CandyBag = findCategory({
|
|
name: 'godispåsar',
|
|
startsWith: 'glass, godis & snacks > godis > ',
|
|
});
|
|
const hit = toSuggestion(l3CandyBag, 'high');
|
|
if (hit) return hit;
|
|
}
|
|
|
|
// ── Regel: Potatis (färsk) ─────────────────────────────────────────
|
|
const hasPotatoSignal = /\bpotatis\b/.test(normalized);
|
|
const hasFrozenPotatoSignal =
|
|
/\bfryst\b/.test(normalized) ||
|
|
/\bdjupfryst\b/.test(normalized) ||
|
|
/\bpommes\b/.test(normalized) ||
|
|
/\bstrips?\b/.test(normalized);
|
|
|
|
if (hasPotatoSignal && !hasFrozenPotatoSignal) {
|
|
const l3Potato = findCategory({
|
|
name: 'potatis',
|
|
startsWith: 'frukt & grönt > potatis & rotsaker > ',
|
|
});
|
|
const l3Hit = toSuggestion(l3Potato, 'high');
|
|
if (l3Hit) return l3Hit;
|
|
}
|
|
|
|
// ── Regel: Laktosfri/växtbaserad mejeri ──────────────────────────────
|
|
const isCookingBase =
|
|
/\bmatlagningsbas\b/.test(normalized) ||
|
|
/\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 > '),
|
|
);
|
|
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',
|
|
);
|
|
const l2CookingHit = toSuggestion(l2Cooking, 'medium');
|
|
if (l2CookingHit) return l2CookingHit;
|
|
|
|
return null;
|
|
}
|
|
|
|
private applyContradictionGuard(
|
|
rawName: string,
|
|
suggestion: CategorySuggestion,
|
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
|
|
): CategorySuggestion {
|
|
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;
|
|
}
|
|
}
|