feat: remove deprecated refreshCategories endpoint and refactor matching logic for improved clarity and performance
Test Suite / test (24.15.0) (push) Has been cancelled

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