feat(receipt-import): enhance receipt processing with new category rules and add unit tests
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -32,7 +32,7 @@ function tokenize(value: string): string[] {
|
||||
.filter((w) => w.length >= 3);
|
||||
}
|
||||
|
||||
function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
||||
export function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
||||
const normalized = (value ?? '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
||||
@@ -44,6 +44,7 @@ function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
||||
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;
|
||||
}
|
||||
@@ -732,6 +733,38 @@ export class ReceiptImportService {
|
||||
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) ||
|
||||
@@ -749,6 +782,13 @@ export class ReceiptImportService {
|
||||
/\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 > ',
|
||||
@@ -783,7 +823,7 @@ export class ReceiptImportService {
|
||||
if (fallbackHit) return fallbackHit;
|
||||
}
|
||||
|
||||
// ── Regel: Ägg (saknar egen L2/L3 i nuvarande träd) ────────────────
|
||||
// ── Regel: Ägg ──────────────────────────────────────────────────────
|
||||
const hasEggSignal =
|
||||
/\bagg\b/.test(normalized) ||
|
||||
/\begg\b/.test(normalized) ||
|
||||
@@ -791,6 +831,21 @@ export class ReceiptImportService {
|
||||
/\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',
|
||||
);
|
||||
@@ -805,6 +860,30 @@ export class ReceiptImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) ||
|
||||
@@ -829,6 +908,7 @@ export class ReceiptImportService {
|
||||
|
||||
// ── Regel: Kaffebröd ─────────────────────────────────────────────────
|
||||
const isKaffebrod =
|
||||
/\bkaffebrod\b/.test(normalized) ||
|
||||
/\bwienerbrod\b/.test(normalized) ||
|
||||
/\bdonut\b/.test(normalized) ||
|
||||
/\bmunk\b/.test(normalized) ||
|
||||
@@ -857,6 +937,55 @@ export class ReceiptImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) ||
|
||||
@@ -979,6 +1108,24 @@ export class ReceiptImportService {
|
||||
/\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',
|
||||
);
|
||||
@@ -1022,6 +1169,25 @@ export class ReceiptImportService {
|
||||
);
|
||||
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}"`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user