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
@@ -0,0 +1,63 @@
-- Ensure Allergi mejeri uses "Laktosfri mjölk" as L3 node.
-- 1) Rename old node when no target node exists yet.
UPDATE `Category` c_old
JOIN `Category` c2
ON c_old.parentId = c2.id
AND c2.name = 'Allergi mejeri'
JOIN `Category` c1
ON c2.parentId = c1.id
AND c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
LEFT JOIN `Category` c_new
ON c_new.parentId = c2.id
AND c_new.name = 'Laktosfri mjölk'
SET c_old.name = 'Laktosfri mjölk'
WHERE c_old.name = 'Mjölk'
AND c_new.id IS NULL;
-- 2) Re-point product links from old node to new node if both exist.
UPDATE `Product` p
JOIN `Category` c_old
ON p.categoryId = c_old.id
AND c_old.name = 'Mjölk'
JOIN `Category` c2
ON c_old.parentId = c2.id
AND c2.name = 'Allergi mejeri'
JOIN `Category` c1
ON c2.parentId = c1.id
AND c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
JOIN `Category` c_new
ON c_new.parentId = c2.id
AND c_new.name = 'Laktosfri mjölk'
SET p.categoryId = c_new.id;
-- 3) Remove duplicate old node when both old and new exist.
DELETE c_old
FROM `Category` c_old
JOIN `Category` c2
ON c_old.parentId = c2.id
AND c2.name = 'Allergi mejeri'
JOIN `Category` c1
ON c2.parentId = c1.id
AND c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
JOIN `Category` c_new
ON c_new.parentId = c2.id
AND c_new.name = 'Laktosfri mjölk'
WHERE c_old.name = 'Mjölk';
-- 4) Create missing target node if needed.
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Laktosfri mjölk', c2.id
FROM `Category` c1
JOIN `Category` c2
ON c2.parentId = c1.id
AND c2.name = 'Allergi mejeri'
LEFT JOIN `Category` c3
ON c3.parentId = c2.id
AND c3.name = 'Laktosfri mjölk'
WHERE c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
AND c3.id IS NULL;
@@ -0,0 +1,31 @@
-- Ensure L3 "Kvarg & Cottage cheese" exists in both dairy branches:
-- 1) Mejeri, ost & ägg > Allergi mejeri
-- 2) Mejeri, ost & ägg > Kvarg & Cottage cheese
-- Branch 1: under Allergi mejeri
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kvarg & Cottage cheese', c2.id
FROM `Category` c1
JOIN `Category` c2
ON c2.parentId = c1.id
AND c2.name = 'Allergi mejeri'
LEFT JOIN `Category` c3
ON c3.parentId = c2.id
AND c3.name = 'Kvarg & Cottage cheese'
WHERE c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
AND c3.id IS NULL;
-- Branch 2: under standard Kvarg & Cottage cheese branch
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kvarg & Cottage cheese', c2.id
FROM `Category` c1
JOIN `Category` c2
ON c2.parentId = c1.id
AND c2.name = 'Kvarg & Cottage cheese'
LEFT JOIN `Category` c3
ON c3.parentId = c2.id
AND c3.name = 'Kvarg & Cottage cheese'
WHERE c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
AND c3.id IS NULL;
@@ -0,0 +1,15 @@
-- Ensure L3 "Matfett" exists under:
-- Mejeri, ost & ägg > Allergi mejeri
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Matfett', c2.id
FROM `Category` c1
JOIN `Category` c2
ON c2.parentId = c1.id
AND c2.name = 'Allergi mejeri'
LEFT JOIN `Category` c3
ON c3.parentId = c2.id
AND c3.name = 'Matfett'
WHERE c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
AND c3.id IS NULL;
@@ -0,0 +1,15 @@
-- Ensure L3 "Allergi matlagning" exists under:
-- Mejeri, ost & ägg > Matlagning
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Allergi matlagning', c2.id
FROM `Category` c1
JOIN `Category` c2
ON c2.parentId = c1.id
AND c2.name = 'Matlagning'
LEFT JOIN `Category` c3
ON c3.parentId = c2.id
AND c3.name = 'Allergi matlagning'
WHERE c1.name = 'Mejeri, ost & ägg'
AND c1.parentId IS NULL
AND c3.id IS NULL;
@@ -6,7 +6,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; 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'; import { CategoriesService } from '../categories/categories.service';
const IMPORTER_SERVICE_URL = const IMPORTER_SERVICE_URL =
@@ -32,6 +32,15 @@ function tokenize(value: string): string[] {
.filter((w) => w.length >= 3); .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() @Injectable()
export class ReceiptImportService { export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name); private readonly logger = new Logger(ReceiptImportService.name);
@@ -221,6 +230,12 @@ export class ReceiptImportService {
const enriched = new Map<string, ParsedReceiptItem>(); const enriched = new Map<string, ParsedReceiptItem>();
for (const item of unmatched) { for (const item of unmatched) {
try { 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); const suggestion = await this.aiService.suggestCategory(item.rawName, categories);
enriched.set(item.rawName, { ...item, categorySuggestion: suggestion }); enriched.set(item.rawName, { ...item, categorySuggestion: suggestion });
} catch { } catch {
@@ -231,4 +246,61 @@ export class ReceiptImportService {
return items.map((item) => enriched.get(item.rawName) ?? item); 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;
}
} }
+17 -1
View File
@@ -331,13 +331,21 @@ INSERT INTO `Category` (`name`, `parentId`)
-- ── NIVÅ 3: under Mejeri, ost & ägg > Allergi mejeri ──────────────── -- ── NIVÅ 3: under Mejeri, ost & ägg > Allergi mejeri ────────────────
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Mjölk', c2.id FROM `Category` c1 SELECT 'Laktosfri mjölk', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Filmjölk & Yoghurt', c2.id FROM `Category` c1 SELECT 'Filmjölk & Yoghurt', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kvarg & Cottage cheese', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Matfett', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ─────────────── -- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ───────────────
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
@@ -354,6 +362,10 @@ INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Matlagningsyoghurt', c2.id FROM `Category` c1 SELECT 'Matlagningsyoghurt', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Matlagning' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Matlagning'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Allergi matlagning', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Matlagning'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Mejeri, ost & ägg > Smör, margarin & jäst ──────────── -- ── NIVÅ 3: under Mejeri, ost & ägg > Smör, margarin & jäst ────────────
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
@@ -372,6 +384,10 @@ INSERT INTO `Category` (`name`, `parentId`)
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL; WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Mejeri, ost & ägg > Kvarg & Cottage cheese ──────────── -- ── NIVÅ 3: under Mejeri, ost & ägg > Kvarg & Cottage cheese ────────────
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kvarg & Cottage cheese', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kvarg & Cottage cheese'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Kvarg', c2.id FROM `Category` c1 SELECT 'Kvarg', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kvarg & Cottage cheese' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Kvarg & Cottage cheese'