feat(receipt-import): add rule-based category suggestion logic for items

feat(migrations): add new categories for lactose-free products and allergy options
This commit is contained in:
Nils-Johan Gynther
2026-05-02 16:42:33 +02:00
parent 2563738fcf
commit 1604751b65
6 changed files with 214 additions and 2 deletions
@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { AiService } from '../ai/ai.service';
import { AiService, CategorySuggestion } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
const IMPORTER_SERVICE_URL =
@@ -32,6 +32,15 @@ function tokenize(value: string): string[] {
.filter((w) => w.length >= 3);
}
function normalizeForRules(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
@Injectable()
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
@@ -221,6 +230,12 @@ export class ReceiptImportService {
const enriched = new Map<string, ParsedReceiptItem>();
for (const item of unmatched) {
try {
const byRule = this.ruleBasedCategorySuggestion(item.rawName, categories);
if (byRule) {
enriched.set(item.rawName, { ...item, categorySuggestion: byRule });
continue;
}
const suggestion = await this.aiService.suggestCategory(item.rawName, categories);
enriched.set(item.rawName, { ...item, categorySuggestion: suggestion });
} catch {
@@ -231,4 +246,61 @@ export class ReceiptImportService {
return items.map((item) => enriched.get(item.rawName) ?? item);
}
private ruleBasedCategorySuggestion(
rawName: string,
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>,
): CategorySuggestion | null {
const normalized = normalizeForRules(rawName);
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;
}
}