|
|
|
@@ -55,12 +55,12 @@ export class ReceiptImportService {
|
|
|
|
|
private readonly categoriesService: CategoriesService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async parseReceipt(file: Express.Multer.File, isPremium = false): Promise<ParsedReceiptItem[]> {
|
|
|
|
|
async parseReceipt(file: Express.Multer.File, isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
|
|
|
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
|
|
|
|
const rawItems = await this.parseReceiptViaImporter(file);
|
|
|
|
|
|
|
|
|
|
// Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app)
|
|
|
|
|
const matched = await this.matchProducts(rawItems);
|
|
|
|
|
const matched = await this.matchProducts(rawItems, userId);
|
|
|
|
|
|
|
|
|
|
// Steg 3: AI-kategorisering för premium-användare
|
|
|
|
|
if (isPremium) {
|
|
|
|
@@ -110,15 +110,21 @@ export class ReceiptImportService {
|
|
|
|
|
|
|
|
|
|
private async matchProducts(
|
|
|
|
|
items: ParsedReceiptItem[],
|
|
|
|
|
userId?: number,
|
|
|
|
|
): Promise<ParsedReceiptItem[]> {
|
|
|
|
|
// Hämta alias och produkter parallellt
|
|
|
|
|
// Hämta alias och produkter parallellt — filtrera på userId om angivet
|
|
|
|
|
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
|
|
|
|
|
const aliasFilter = userId
|
|
|
|
|
? { product: { ownerId: userId } }
|
|
|
|
|
: {};
|
|
|
|
|
const [aliases, products] = await Promise.all([
|
|
|
|
|
this.prisma.receiptAlias.findMany({
|
|
|
|
|
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true } } },
|
|
|
|
|
where: aliasFilter,
|
|
|
|
|
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } } } },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.product.findMany({
|
|
|
|
|
where: { isActive: true },
|
|
|
|
|
select: { id: true, name: true, canonicalName: true },
|
|
|
|
|
where: productFilter,
|
|
|
|
|
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } },
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
@@ -129,29 +135,34 @@ export class ReceiptImportService {
|
|
|
|
|
// 1. Alias-match (säker, användaren behöver inte bekräfta)
|
|
|
|
|
const alias = aliases.find((a) => a.receiptName === raw);
|
|
|
|
|
if (alias) {
|
|
|
|
|
const cat = alias.product.categoryRef;
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
matchedProductId: alias.product.id,
|
|
|
|
|
matchedProductName: alias.product.canonicalName ?? alias.product.name,
|
|
|
|
|
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, 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 };
|
|
|
|
|
}
|
|
|
|
|
const cat = suggestion.categoryRef;
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
suggestedProductId: suggestion?.id,
|
|
|
|
|
suggestedProductName: suggestion
|
|
|
|
|
? (suggestion.canonicalName ?? suggestion.name)
|
|
|
|
|
: undefined,
|
|
|
|
|
suggestedProductId: suggestion.id,
|
|
|
|
|
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
|
|
|
|
|
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'medium' as const, usedFallback: false } } : {}),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private findWordMatch(
|
|
|
|
|
raw: string,
|
|
|
|
|
products: { id: number; name: string; canonicalName: string | null }[],
|
|
|
|
|
): { id: number; name: string; canonicalName: string | null } | undefined {
|
|
|
|
|
products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null }[],
|
|
|
|
|
): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null } | undefined {
|
|
|
|
|
// Dela upp kvittonamnet i ord (min 3 tecken)
|
|
|
|
|
const rawWords = tokenize(raw);
|
|
|
|
|
if (rawWords.length === 0) return undefined;
|
|
|
|
@@ -229,7 +240,8 @@ export class ReceiptImportService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
|
|
|
|
|
const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName);
|
|
|
|
|
// Kör regler/AI för alla items som saknar categorySuggestion och har ett rawName
|
|
|
|
|
const unmatched = items.filter((i) => !i.categorySuggestion && i.rawName);
|
|
|
|
|
if (unmatched.length === 0) return items;
|
|
|
|
|
|
|
|
|
|
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
|
|
|