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:
+63
@@ -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;
|
||||||
+31
@@ -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
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user