feat: remove deprecated refreshCategories endpoint and refactor matching logic for improved clarity and performance
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -59,13 +59,6 @@ export class ReceiptImportController {
|
|||||||
return this.receiptImportService.parseReceipt(file, isPremium, userId);
|
return this.receiptImportService.parseReceipt(file, isPremium, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh-categories')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
async refreshCategories() {
|
|
||||||
await this.receiptImportService.loadCategories();
|
|
||||||
return { message: 'Kategorier har uppdaterats.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('unit-mappings')
|
@Post('unit-mappings')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
async upsertUnitMapping(
|
async upsertUnitMapping(
|
||||||
|
|||||||
@@ -106,8 +106,12 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('alias fallback och prioritet', () => {
|
describe('alias fallback och prioritet', () => {
|
||||||
|
function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) {
|
||||||
|
return { userId, aliases, products, unitMappings, categories, aiEnabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
it('prioriterar user-alias före global alias för samma receiptName', async () => {
|
it('prioriterar user-alias före global alias för samma receiptName', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([
|
const aliases = [
|
||||||
{
|
{
|
||||||
receiptName: 'mjolk 1l',
|
receiptName: 'mjolk 1l',
|
||||||
productId: 501,
|
productId: 501,
|
||||||
@@ -130,32 +134,17 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
categoryRef: { id: 30, name: 'Mejeri' },
|
categoryRef: { id: 30, name: 'Mejeri' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
prismaMock.product.findMany.mockResolvedValue([]);
|
const context = makeContext(aliases, [], [], 77);
|
||||||
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context);
|
||||||
|
|
||||||
const result = await (service as any).matchProducts(
|
expect(result.matchedProductId).toBe(501);
|
||||||
[{ rawName: 'MJOLK 1L' }],
|
expect(result.matchedProductName).toBe('Mjolk user');
|
||||||
77,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(prismaMock.receiptAlias.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: 77, isGlobal: false },
|
|
||||||
{ isGlobal: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0].matchedProductId).toBe(501);
|
|
||||||
expect(result[0].matchedProductName).toBe('Mjolk user');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('använder global alias när user-alias saknas', async () => {
|
it('använder global alias när user-alias saknas', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([
|
const aliases = [
|
||||||
{
|
{
|
||||||
receiptName: 'snickers',
|
receiptName: 'snickers',
|
||||||
productId: 222,
|
productId: 222,
|
||||||
@@ -167,24 +156,17 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
categoryRef: { id: 53, name: 'Choklad' },
|
categoryRef: { id: 53, name: 'Choklad' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
prismaMock.product.findMany.mockResolvedValue([]);
|
const context = makeContext(aliases, [], [], 88);
|
||||||
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context);
|
||||||
|
|
||||||
const result = await (service as any).matchProducts(
|
expect(result.matchedProductId).toBe(222);
|
||||||
[{ rawName: 'SNICKERS' }],
|
expect(result.matchedProductName).toBe('Snickers');
|
||||||
88,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0].matchedProductId).toBe(222);
|
|
||||||
expect(result[0].matchedProductName).toBe('Snickers');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
|
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
|
||||||
const aliases: any[] = [];
|
const products = [
|
||||||
prismaMock.receiptAlias.findMany.mockImplementation(async () => aliases);
|
|
||||||
|
|
||||||
prismaMock.product.findMany.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
id: 700,
|
id: 700,
|
||||||
name: 'Arla Mjolk 1l',
|
name: 'Arla Mjolk 1l',
|
||||||
@@ -192,18 +174,17 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
categoryId: 30,
|
categoryId: 30,
|
||||||
categoryRef: { id: 30, name: 'Mejeri' },
|
categoryRef: { id: 30, name: 'Mejeri' },
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
const first = await (service as any).matchProducts(
|
const contextNoAlias = makeContext([], products, [], 42);
|
||||||
[{ rawName: 'ARLA MJOLK 1L' }],
|
const first = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextNoAlias);
|
||||||
42,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first[0].matchedProductId).toBeUndefined();
|
expect(first.matchedProductId).toBeUndefined();
|
||||||
expect(first[0].suggestedProductId).toBe(700);
|
expect(first.suggestedProductId).toBe(700);
|
||||||
|
|
||||||
// Simulerar att användaren manuellt korrigerar och alias lärs in.
|
// Simulerar att användaren manuellt korrigerar och alias lärs in.
|
||||||
aliases.push({
|
const aliases = [
|
||||||
|
{
|
||||||
receiptName: 'arla mjolk 1l',
|
receiptName: 'arla mjolk 1l',
|
||||||
productId: 700,
|
productId: 700,
|
||||||
product: {
|
product: {
|
||||||
@@ -213,20 +194,19 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
categoryId: 30,
|
categoryId: 30,
|
||||||
categoryRef: { id: 30, name: 'Mejeri' },
|
categoryRef: { id: 30, name: 'Mejeri' },
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const second = await (service as any).matchProducts(
|
const contextWithAlias = makeContext(aliases, products, [], 42);
|
||||||
[{ rawName: 'ARLA MJOLK 1L' }],
|
const second = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextWithAlias);
|
||||||
42,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(second[0].matchedProductId).toBe(700);
|
expect(second.matchedProductId).toBe(700);
|
||||||
expect(second[0].matchedProductName).toBe('Mjolk');
|
expect(second.matchedProductName).toBe('Mjolk');
|
||||||
expect(second[0].suggestedProductId).toBeUndefined();
|
expect(second.suggestedProductId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('använder inlärd enhetsmappning vid aliasträff', async () => {
|
it('använder inlärd enhetsmappning vid aliasträff', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([
|
const aliases = [
|
||||||
{
|
{
|
||||||
receiptName: 'mjolk 1l',
|
receiptName: 'mjolk 1l',
|
||||||
productId: 501,
|
productId: 501,
|
||||||
@@ -238,60 +218,51 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
categoryRef: { id: 30, name: 'Mejeri' },
|
categoryRef: { id: 30, name: 'Mejeri' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
prismaMock.unitMapping.findMany.mockResolvedValue([
|
const unitMappings = [{ productId: 501, originalUnit: 'l', preferredUnit: 'st' }];
|
||||||
{
|
const context = makeContext(aliases, [], unitMappings, 77);
|
||||||
productId: 501,
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L', unit: 'L' }, context);
|
||||||
originalUnit: 'l',
|
|
||||||
preferredUnit: 'st',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await (service as any).matchProducts(
|
expect(result.matchedProductId).toBe(501);
|
||||||
[{ rawName: 'MJOLK 1L', unit: 'L' }],
|
expect(result.unit).toBe('st');
|
||||||
77,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0].matchedProductId).toBe(501);
|
|
||||||
expect(result[0].unit).toBe('st');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('matchedVia', () => {
|
describe('matchedVia', () => {
|
||||||
|
function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) {
|
||||||
|
return { userId, aliases, products, unitMappings, categories, aiEnabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
it('sätter matchedVia: alias vid aliasträff', async () => {
|
it('sätter matchedVia: alias vid aliasträff', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([
|
const aliases = [
|
||||||
{
|
{
|
||||||
receiptName: 'snickers',
|
receiptName: 'snickers',
|
||||||
productId: 222,
|
productId: 222,
|
||||||
product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: null, categoryRef: null },
|
product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: null, categoryRef: null },
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
prismaMock.product.findMany.mockResolvedValue([]);
|
const context = makeContext(aliases, [], [], 10);
|
||||||
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context);
|
||||||
|
|
||||||
const result = await (service as any).matchProducts([{ rawName: 'SNICKERS' }], 10);
|
expect(result.matchedVia).toBe('alias');
|
||||||
|
|
||||||
expect(result[0].matchedVia).toBe('alias');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sätter matchedVia: wordmatch vid ordbaserad matchning', async () => {
|
it('sätter matchedVia: wordmatch vid ordbaserad matchning', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
|
const products = [
|
||||||
prismaMock.product.findMany.mockResolvedValue([
|
|
||||||
{ id: 300, name: 'Mjolk', canonicalName: 'Mjolk', categoryId: null, categoryRef: null },
|
{ id: 300, name: 'Mjolk', canonicalName: 'Mjolk', categoryId: null, categoryRef: null },
|
||||||
]);
|
];
|
||||||
|
const context = makeContext([], products, [], 10);
|
||||||
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context);
|
||||||
|
|
||||||
const result = await (service as any).matchProducts([{ rawName: 'MJOLK 1L' }], 10);
|
expect(result.matchedVia).toBe('wordmatch');
|
||||||
|
|
||||||
expect(result[0].matchedVia).toBe('wordmatch');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sätter matchedVia: none när ingen matchning finns', async () => {
|
it('sätter matchedVia: none när ingen matchning finns', async () => {
|
||||||
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
|
const context = makeContext([], [], [], 10);
|
||||||
prismaMock.product.findMany.mockResolvedValue([]);
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'XYZXYZ' }, context);
|
||||||
|
|
||||||
const result = await (service as any).matchProducts([{ rawName: 'XYZXYZ' }], 10);
|
expect(result.matchedVia).toBe('none');
|
||||||
|
|
||||||
expect(result[0].matchedVia).toBe('none');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,63 +96,48 @@ function hasBreadLikeSignal(normalized: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPackageDebugFromRawName(rawName: string): {
|
type UnitMappingLite = {
|
||||||
packageCount: number;
|
productId: number;
|
||||||
packQuantity: number | null;
|
originalUnit: string;
|
||||||
packUnit: string | null;
|
preferredUnit: string;
|
||||||
} {
|
};
|
||||||
const normalized = rawName.toLowerCase();
|
|
||||||
|
|
||||||
// e.g. "3x120g", "2 x 1.5l"
|
type ProductLite = {
|
||||||
const multiPack = /(\d+)\s*[x×]\s*(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized);
|
id: number;
|
||||||
if (multiPack) {
|
name: string;
|
||||||
const count = Number.parseInt(multiPack[1], 10);
|
canonicalName: string | null;
|
||||||
const qty = Number.parseFloat(multiPack[2].replace(',', '.'));
|
categoryRef: { id: number; name: string } | null;
|
||||||
const unit = multiPack[3].toLowerCase();
|
};
|
||||||
return {
|
|
||||||
packageCount: Number.isFinite(count) && count > 0 ? count : 1,
|
|
||||||
packQuantity: Number.isFinite(qty) ? qty : null,
|
|
||||||
packUnit: unit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// e.g. "5dl", "1,5l"
|
type AliasLite = {
|
||||||
const singlePack = /(\d+(?:[\.,]\d+)?)\s*(ml|cl|dl|l|g|kg)\b/i.exec(normalized.replace(/([\d.,]+)(ml|cl|dl|l|g|kg)\b/i, '$1 $2'));
|
receiptName: string;
|
||||||
if (singlePack) {
|
product: ProductLite;
|
||||||
const qty = Number.parseFloat(singlePack[1].replace(',', '.'));
|
};
|
||||||
const unit = singlePack[2].toLowerCase();
|
|
||||||
return {
|
|
||||||
packageCount: 1,
|
|
||||||
packQuantity: Number.isFinite(qty) ? qty : null,
|
|
||||||
packUnit: unit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
type MatchingContext = {
|
||||||
packageCount: 1,
|
aliases: AliasLite[];
|
||||||
packQuantity: null,
|
aliasByReceiptName?: Map<string, AliasLite>;
|
||||||
packUnit: null,
|
products: ProductLite[];
|
||||||
};
|
unitMappings: UnitMappingLite[];
|
||||||
}
|
unitMappingByKey?: Map<string, string>;
|
||||||
|
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
||||||
|
aiEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatchDebug = {
|
||||||
|
steps: string[];
|
||||||
|
tree: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReceiptImportService {
|
export class ReceiptImportService {
|
||||||
private readonly logger = new Logger(ReceiptImportService.name);
|
private readonly logger = new Logger(ReceiptImportService.name);
|
||||||
private cachedCategories: any[] = [];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly aiService: AiService,
|
private readonly aiService: AiService,
|
||||||
private readonly categoriesService: CategoriesService,
|
private readonly categoriesService: CategoriesService,
|
||||||
) {
|
) {}
|
||||||
this.loadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCategories() {
|
|
||||||
this.cachedCategories = await this.prisma.category.findMany({
|
|
||||||
include: { children: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
||||||
// Steg 1: Delegera AI-parsning till microservice-importer
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||||
@@ -170,18 +155,7 @@ export class ReceiptImportService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async prepareMatchingContext(userId?: number) {
|
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
|
||||||
type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string };
|
|
||||||
type AliasLite = {
|
|
||||||
receiptName: string;
|
|
||||||
product: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
canonicalName: string | null;
|
|
||||||
categoryRef: { id: number; name: string } | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const prismaAny = this.prisma as any;
|
const prismaAny = this.prisma as any;
|
||||||
|
|
||||||
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
|
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
|
||||||
@@ -230,26 +204,58 @@ export class ReceiptImportService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
unitMappingsPromise,
|
unitMappingsPromise,
|
||||||
]) as [
|
]) as [AliasLite[], ProductLite[], UnitMappingLite[]];
|
||||||
AliasLite[],
|
|
||||||
Array<{ id: number; name: string; canonicalName: string | null; categoryRef: { id: number; name: string } | null }>,
|
|
||||||
UnitMappingLite[]
|
|
||||||
];
|
|
||||||
|
|
||||||
const user = userId
|
const user = userId
|
||||||
? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true } })
|
? await this.prisma.user.findUnique({ where: { id: userId }, select: { aiEngineEnabled: true } })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const aliasByReceiptName = new Map<string, AliasLite>();
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (!aliasByReceiptName.has(alias.receiptName)) {
|
||||||
|
aliasByReceiptName.set(alias.receiptName, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitMappingByKey = new Map<string, string>();
|
||||||
|
for (const unitMapping of unitMappings) {
|
||||||
|
const key = `${unitMapping.productId}:${unitMapping.originalUnit}`;
|
||||||
|
if (!unitMappingByKey.has(key)) {
|
||||||
|
unitMappingByKey.set(key, unitMapping.preferredUnit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
|
||||||
aliases,
|
aliases,
|
||||||
|
aliasByReceiptName,
|
||||||
products,
|
products,
|
||||||
unitMappings,
|
unitMappings,
|
||||||
|
unitMappingByKey,
|
||||||
categories,
|
categories,
|
||||||
aiEnabled: user?.aiEngineEnabled ?? false,
|
aiEnabled: user?.aiEngineEnabled ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolvePreferredUnit(
|
||||||
|
productId: number,
|
||||||
|
itemUnit: string | null | undefined,
|
||||||
|
context: Pick<MatchingContext, 'unitMappings' | 'unitMappingByKey'>,
|
||||||
|
): string | null | undefined {
|
||||||
|
const normalizedUnit = (itemUnit ?? '').trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
context.unitMappingByKey?.get(`${productId}:${normalizedUnit}`) ??
|
||||||
|
context.unitMappings.find(
|
||||||
|
(um) => um.productId === productId && um.originalUnit === normalizedUnit,
|
||||||
|
)?.preferredUnit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSignalText(item: ParsedReceiptItem): string {
|
||||||
|
return [item.rawName, item.matchedProductName, item.suggestedProductName]
|
||||||
|
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
async upsertUnitMapping(
|
async upsertUnitMapping(
|
||||||
userId: number,
|
userId: number,
|
||||||
productId: number,
|
productId: number,
|
||||||
@@ -541,194 +547,6 @@ export class ReceiptImportService {
|
|||||||
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
|
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated CLEANUP PENDING (Session 2026-05-09)
|
|
||||||
*
|
|
||||||
* Ersatt av unified matcher (matchAndEnrichReceiptItem).
|
|
||||||
* Denna metod körde alias-lookup + word-match separat.
|
|
||||||
*
|
|
||||||
* Cleanup: Se enrichWithAiCategories checklist ovan.
|
|
||||||
*/
|
|
||||||
private async matchProducts(
|
|
||||||
items: ParsedReceiptItem[],
|
|
||||||
userId?: number,
|
|
||||||
): Promise<ParsedReceiptItem[]> {
|
|
||||||
type UnitMappingLite = { productId: number; originalUnit: string; preferredUnit: string };
|
|
||||||
type AliasLite = {
|
|
||||||
receiptName: string;
|
|
||||||
product: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
canonicalName: string | null;
|
|
||||||
categoryRef: { id: number; name: string } | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const prismaAny = this.prisma as any;
|
|
||||||
|
|
||||||
// Hämta alias och produkter parallellt — filtrera på userId om angivet
|
|
||||||
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
|
|
||||||
const aliasFilter = userId
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: userId, isGlobal: false },
|
|
||||||
{ isGlobal: true },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: { isGlobal: true };
|
|
||||||
const unitMappingsPromise =
|
|
||||||
userId && prismaAny.unitMapping?.findMany
|
|
||||||
? (prismaAny.unitMapping.findMany({
|
|
||||||
where: { userId },
|
|
||||||
select: { productId: true, originalUnit: true, preferredUnit: true },
|
|
||||||
}) as Promise<UnitMappingLite[]>)
|
|
||||||
: Promise.resolve([] as UnitMappingLite[]);
|
|
||||||
|
|
||||||
const [aliases, products, unitMappings] = await Promise.all([
|
|
||||||
this.prisma.receiptAlias.findMany({
|
|
||||||
where: aliasFilter,
|
|
||||||
orderBy: [
|
|
||||||
{ isGlobal: 'asc' },
|
|
||||||
{ id: 'asc' },
|
|
||||||
],
|
|
||||||
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } } } },
|
|
||||||
}),
|
|
||||||
this.prisma.product.findMany({
|
|
||||||
where: productFilter,
|
|
||||||
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } },
|
|
||||||
}),
|
|
||||||
unitMappingsPromise,
|
|
||||||
]) as [AliasLite[], { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[], UnitMappingLite[]];
|
|
||||||
|
|
||||||
return items.map((item) => {
|
|
||||||
const raw = (item.rawName ?? '').toLowerCase().trim();
|
|
||||||
if (!raw) return item;
|
|
||||||
|
|
||||||
// 1. Alias-match (säker, användaren behöver inte bekräfta)
|
|
||||||
const alias = aliases.find((a: AliasLite) => a.receiptName === raw);
|
|
||||||
if (alias) {
|
|
||||||
const mappedUnit = unitMappings.find(
|
|
||||||
(um) =>
|
|
||||||
um.productId === alias.product.id &&
|
|
||||||
um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
|
|
||||||
)?.preferredUnit;
|
|
||||||
const cat = alias.product.categoryRef;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
matchedProductId: alias.product.id,
|
|
||||||
matchedProductName: alias.product.canonicalName ?? alias.product.name,
|
|
||||||
unit: mappedUnit ?? item.unit,
|
|
||||||
matchedVia: 'alias' as const,
|
|
||||||
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, 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, matchedVia: 'none' as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kontrollera om det finns en enhetsmappning för produkten och användaren
|
|
||||||
const unitMapping = unitMappings.find(
|
|
||||||
(um) =>
|
|
||||||
um.productId === suggestion.id &&
|
|
||||||
um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
|
|
||||||
);
|
|
||||||
const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit;
|
|
||||||
|
|
||||||
const cat = suggestion.categoryRef;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
suggestedProductId: suggestion.id,
|
|
||||||
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
|
|
||||||
unit: preferredUnit,
|
|
||||||
matchedVia: 'wordmatch' as const,
|
|
||||||
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'medium' as const, usedFallback: false } } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private findWordMatch(
|
|
||||||
raw: string,
|
|
||||||
products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }[],
|
|
||||||
): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null } | undefined {
|
|
||||||
// Dela upp kvittonamnet i ord (min 3 tecken)
|
|
||||||
const rawWords = tokenize(raw);
|
|
||||||
if (rawWords.length === 0) return undefined;
|
|
||||||
|
|
||||||
const rawWordSet = new Set(rawWords);
|
|
||||||
// Normaliserade versioner (utan diakritik) för att hantera t.ex. gradde == grädde
|
|
||||||
const rawWordsNorm = rawWords.map(normalizeToken);
|
|
||||||
const rawWordSetNorm = new Set(rawWordsNorm);
|
|
||||||
|
|
||||||
let best:
|
|
||||||
| { product: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string } | null }; score: number }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
for (const product of products) {
|
|
||||||
const productWords = tokenize(product.canonicalName ?? product.name);
|
|
||||||
if (productWords.length === 0) continue;
|
|
||||||
|
|
||||||
let score = 0;
|
|
||||||
let exactStrong = 0;
|
|
||||||
let exactAny = 0;
|
|
||||||
let partialStrong = 0;
|
|
||||||
|
|
||||||
const phrase = (product.canonicalName ?? product.name).toLowerCase();
|
|
||||||
if (raw.includes(phrase)) {
|
|
||||||
score += 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pw of productWords) {
|
|
||||||
const isWeak = WEAK_DESCRIPTORS.has(pw);
|
|
||||||
const pwNorm = normalizeToken(pw);
|
|
||||||
|
|
||||||
if (rawWordSet.has(pw) || rawWordSetNorm.has(pwNorm)) {
|
|
||||||
exactAny += 1;
|
|
||||||
if (isWeak) {
|
|
||||||
score += 1;
|
|
||||||
} else {
|
|
||||||
exactStrong += 1;
|
|
||||||
score += 8;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delmatchning tillåts bara för ord med minst 4 tecken.
|
|
||||||
if (pw.length < 4) continue;
|
|
||||||
|
|
||||||
const hasPartial =
|
|
||||||
rawWords.some((rw) => rw.includes(pw) || pw.includes(rw)) ||
|
|
||||||
rawWordsNorm.some((rw) => rw.includes(pwNorm) || pwNorm.includes(rw));
|
|
||||||
if (!hasPartial) continue;
|
|
||||||
|
|
||||||
if (isWeak) {
|
|
||||||
// Deskriptiva ord (t.ex. rökt) ska inte driva förslag ensamma.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
partialStrong += 1;
|
|
||||||
score += 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kräv antingen minst ett starkt exakt ord, eller flera samverkande signaler.
|
|
||||||
// Undantag: ett enstaka starkt partiellt ord (>=5 tecken) räcker, t.ex. vispgrädde → grädde.
|
|
||||||
const hasLongPartial = partialStrong >= 1 && productWords.some((pw) => pw.length >= 5);
|
|
||||||
const hasStrongSignal = exactStrong >= 1 || exactAny + partialStrong >= 2 || hasLongPartial;
|
|
||||||
if (!hasStrongSignal) continue;
|
|
||||||
|
|
||||||
// Tröskel för att undvika svaga enkelträffar.
|
|
||||||
if (score < 8) continue;
|
|
||||||
|
|
||||||
if (!best || score > best.score) {
|
|
||||||
best = { product, score };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best?.product;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
// UNIFIED MATCHER: Kombinerar product matching + categorization
|
// UNIFIED MATCHER: Kombinerar product matching + categorization
|
||||||
//
|
//
|
||||||
@@ -743,44 +561,28 @@ export class ReceiptImportService {
|
|||||||
|
|
||||||
private async matchAndEnrichReceiptItem(
|
private async matchAndEnrichReceiptItem(
|
||||||
item: ParsedReceiptItem,
|
item: ParsedReceiptItem,
|
||||||
context: {
|
context: MatchingContext,
|
||||||
userId?: number;
|
|
||||||
aliases: Array<{
|
|
||||||
receiptName: string;
|
|
||||||
product: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
canonicalName: string | null;
|
|
||||||
categoryRef: { id: number; name: string } | null;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
products: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
canonicalName: string | null;
|
|
||||||
categoryRef: { id: number; name: string } | null;
|
|
||||||
}>;
|
|
||||||
unitMappings: Array<{ productId: number; originalUnit: string; preferredUnit: string }>;
|
|
||||||
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
||||||
aiEnabled: boolean;
|
|
||||||
},
|
|
||||||
): Promise<ParsedReceiptItem> {
|
): Promise<ParsedReceiptItem> {
|
||||||
if (!item.rawName) return item;
|
if (!item.rawName) return item;
|
||||||
|
|
||||||
const raw = item.rawName.toLowerCase().trim();
|
const raw = item.rawName.toLowerCase().trim();
|
||||||
const debug = { steps: <string[]>[], tree: <Record<string, any>>{} };
|
const debug: MatchDebug = { steps: [], tree: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ┌─ STEG 1: Alias-lookup (certifierad match) ─────────────────────────┐
|
// ┌─ STEG 1: Alias-lookup (certifierad match) ─────────────────────────┐
|
||||||
debug.steps.push('Step 1: Alias lookup');
|
debug.steps.push('Step 1: Alias lookup');
|
||||||
const aliasMatch = context.aliases.find((a) => a.receiptName === raw);
|
const aliasMatch =
|
||||||
|
context.aliasByReceiptName?.get(raw) ??
|
||||||
|
context.aliases.find((a) => a.receiptName === raw);
|
||||||
if (aliasMatch) {
|
if (aliasMatch) {
|
||||||
debug.tree.alias = { found: true, productId: aliasMatch.product.id };
|
debug.tree.alias = { found: true, productId: aliasMatch.product.id };
|
||||||
debug.steps.push(` ✓ Alias found → productId ${aliasMatch.product.id}`);
|
debug.steps.push(` ✓ Alias found → productId ${aliasMatch.product.id}`);
|
||||||
|
|
||||||
const mappedUnit = context.unitMappings.find(
|
const mappedUnit = this.resolvePreferredUnit(
|
||||||
(um) => um.productId === aliasMatch.product.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
|
aliasMatch.product.id,
|
||||||
)?.preferredUnit;
|
item.unit,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
const aliasResult: ParsedReceiptItem = {
|
const aliasResult: ParsedReceiptItem = {
|
||||||
...item,
|
...item,
|
||||||
@@ -814,10 +616,9 @@ export class ReceiptImportService {
|
|||||||
debug.tree.wordMatch = { found: true, productId: wordMatchResult.id, score: wordMatchResult.score };
|
debug.tree.wordMatch = { found: true, productId: wordMatchResult.id, score: wordMatchResult.score };
|
||||||
debug.steps.push(` ✓ Word match found → productId ${wordMatchResult.id} (score ${wordMatchResult.score})`);
|
debug.steps.push(` ✓ Word match found → productId ${wordMatchResult.id} (score ${wordMatchResult.score})`);
|
||||||
|
|
||||||
const unitMapping = context.unitMappings.find(
|
const preferredUnit =
|
||||||
(um) => um.productId === wordMatchResult.id && um.originalUnit === (item.unit ?? '').trim().toLowerCase(),
|
this.resolvePreferredUnit(wordMatchResult.id, item.unit, context) ??
|
||||||
);
|
item.unit;
|
||||||
const preferredUnit = unitMapping ? unitMapping.preferredUnit : item.unit;
|
|
||||||
|
|
||||||
const result: ParsedReceiptItem = {
|
const result: ParsedReceiptItem = {
|
||||||
...item,
|
...item,
|
||||||
@@ -858,12 +659,8 @@ export class ReceiptImportService {
|
|||||||
|
|
||||||
private async enrichCategoryForItem(
|
private async enrichCategoryForItem(
|
||||||
item: ParsedReceiptItem,
|
item: ParsedReceiptItem,
|
||||||
context: {
|
context: Pick<MatchingContext, 'categories' | 'aiEnabled'>,
|
||||||
userId?: number;
|
debug: MatchDebug,
|
||||||
categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
||||||
aiEnabled: boolean;
|
|
||||||
},
|
|
||||||
debug: any,
|
|
||||||
): Promise<ParsedReceiptItem> {
|
): Promise<ParsedReceiptItem> {
|
||||||
// Kategori-hierarki:
|
// Kategori-hierarki:
|
||||||
// 1. Om produktmatchning redan satte kategori, börjar vi med den
|
// 1. Om produktmatchning redan satte kategori, börjar vi med den
|
||||||
@@ -873,15 +670,14 @@ export class ReceiptImportService {
|
|||||||
|
|
||||||
debug.steps.push('Step 3: Categorization');
|
debug.steps.push('Step 3: Categorization');
|
||||||
|
|
||||||
const signalText = [item.rawName, item.matchedProductName, item.suggestedProductName]
|
const signalText = this.buildSignalText(item);
|
||||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
const signalOrRaw = signalText || item.rawName;
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
let nextCategory = item.categorySuggestion ?? null;
|
let nextCategory = item.categorySuggestion ?? null;
|
||||||
|
|
||||||
// ┌─ STEG 3A: Försök regel-baserad kategorisering ─────────────────────┐
|
// ┌─ STEG 3A: Försök regel-baserad kategorisering ─────────────────────┐
|
||||||
debug.steps.push(' Trying rule-based categorization');
|
debug.steps.push(' Trying rule-based categorization');
|
||||||
const ruleResult = this.ruleBasedCategorySuggestion(signalText || item.rawName, context.categories);
|
const ruleResult = this.ruleBasedCategorySuggestion(signalOrRaw, context.categories);
|
||||||
debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path };
|
debug.tree.rule = { found: !!ruleResult, path: ruleResult?.path };
|
||||||
|
|
||||||
if (ruleResult?.confidence === 'high') {
|
if (ruleResult?.confidence === 'high') {
|
||||||
@@ -928,7 +724,7 @@ export class ReceiptImportService {
|
|||||||
if (nextCategory) {
|
if (nextCategory) {
|
||||||
debug.steps.push(' Applying contradiction guard');
|
debug.steps.push(' Applying contradiction guard');
|
||||||
const beforePath = nextCategory.path;
|
const beforePath = nextCategory.path;
|
||||||
const guardedCategory = this.applyContradictionGuard(signalText || item.rawName, nextCategory, context.categories);
|
const guardedCategory = this.applyContradictionGuard(signalOrRaw, nextCategory, context.categories);
|
||||||
if (guardedCategory && guardedCategory.path !== beforePath) {
|
if (guardedCategory && guardedCategory.path !== beforePath) {
|
||||||
debug.steps.push(` ⚠️ Guard remapped: ${beforePath} → ${guardedCategory.path}`);
|
debug.steps.push(` ⚠️ Guard remapped: ${beforePath} → ${guardedCategory.path}`);
|
||||||
nextCategory = guardedCategory;
|
nextCategory = guardedCategory;
|
||||||
@@ -942,7 +738,7 @@ export class ReceiptImportService {
|
|||||||
if (nextCategory) {
|
if (nextCategory) {
|
||||||
debug.steps.push(' Applying hard overrides');
|
debug.steps.push(' Applying hard overrides');
|
||||||
const beforePath = nextCategory.path;
|
const beforePath = nextCategory.path;
|
||||||
const finalCategory = this.applyHardCategoryOverrides(signalText || item.rawName, nextCategory, context.categories);
|
const finalCategory = this.applyHardCategoryOverrides(signalOrRaw, nextCategory, context.categories);
|
||||||
if (finalCategory && finalCategory.path !== beforePath) {
|
if (finalCategory && finalCategory.path !== beforePath) {
|
||||||
debug.steps.push(` ⚠️ Override applied: ${beforePath} → ${finalCategory.path}`);
|
debug.steps.push(` ⚠️ Override applied: ${beforePath} → ${finalCategory.path}`);
|
||||||
nextCategory = finalCategory;
|
nextCategory = finalCategory;
|
||||||
@@ -958,7 +754,7 @@ export class ReceiptImportService {
|
|||||||
debug.steps.push(`❌ FINAL: No category assigned`);
|
debug.steps.push(`❌ FINAL: No category assigned`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldTraceDecision(signalText || item.rawName)) {
|
if (this.shouldTraceDecision(signalOrRaw)) {
|
||||||
this.logger.log(`[ReceiptDecision] ${item.rawName}\n${debug.steps.join('\n')}`);
|
this.logger.log(`[ReceiptDecision] ${item.rawName}\n${debug.steps.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,172 +836,6 @@ export class ReceiptImportService {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated CLEANUP PENDING (Session 2026-05-09)
|
|
||||||
*
|
|
||||||
* Denna metod är ersatt av unified matcher (matchAndEnrichReceiptItem + enrichCategoryForItem).
|
|
||||||
* Den användes för att köra regel-baserad och AI-kategorisering separat från product-matching.
|
|
||||||
*
|
|
||||||
* Cleanup checklist:
|
|
||||||
* - [ ] Ta bort denna metod
|
|
||||||
* - [ ] Ta bort matchProducts() metod
|
|
||||||
* - [ ] Ta bort findWordMatch() (gammal version)
|
|
||||||
* - [ ] Uppdatera kommentarer
|
|
||||||
* - [ ] Kör full test suite för regression detection
|
|
||||||
*/
|
|
||||||
private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise<ParsedReceiptItem[]> {
|
|
||||||
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
|
||||||
try {
|
|
||||||
categories = await this.categoriesService.findFlattened();
|
|
||||||
} catch {
|
|
||||||
return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userId
|
|
||||||
? await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { aiEngineEnabled: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const enriched: ParsedReceiptItem[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.rawName) {
|
|
||||||
enriched.push(item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signalText = [
|
|
||||||
item.rawName,
|
|
||||||
item.matchedProductName,
|
|
||||||
item.suggestedProductName,
|
|
||||||
]
|
|
||||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
const trace: string[] = [];
|
|
||||||
const traceEnabled = this.shouldTraceDecision(signalText || item.rawName);
|
|
||||||
const pushTrace = (msg: string) => {
|
|
||||||
if (traceEnabled) trace.push(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkg = inferPackageDebugFromRawName(item.rawName);
|
|
||||||
|
|
||||||
pushTrace(
|
|
||||||
`start raw="${item.rawName}" signal="${signalText || item.rawName}" parsedQuantity=${item.quantity ?? 'null'} parsedUnit=${item.unit ?? 'null'} packageCount=${pkg.packageCount} packQuantity=${pkg.packQuantity ?? 'null'} packUnit=${pkg.packUnit ?? 'null'}`,
|
|
||||||
);
|
|
||||||
pushTrace(
|
|
||||||
`match matchedProductId=${item.matchedProductId ?? 'null'} suggestedProductId=${item.suggestedProductId ?? 'null'}`,
|
|
||||||
);
|
|
||||||
if (item.categorySuggestion) {
|
|
||||||
pushTrace(
|
|
||||||
`incoming category="${item.categorySuggestion.path}" confidence=${item.categorySuggestion.confidence} fallback=${item.categorySuggestion.usedFallback}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const byRule = this.ruleBasedCategorySuggestion(signalText || item.rawName, categories);
|
|
||||||
if (byRule) {
|
|
||||||
pushTrace(`rule hit -> "${byRule.path}" (${byRule.confidence})`);
|
|
||||||
} else {
|
|
||||||
pushTrace('rule miss');
|
|
||||||
}
|
|
||||||
let nextSuggestion = item.categorySuggestion ?? null;
|
|
||||||
|
|
||||||
const isTrustedSuggestion =
|
|
||||||
nextSuggestion?.confidence === 'high' && !nextSuggestion.usedFallback;
|
|
||||||
|
|
||||||
// Regel med stark signal får överstyra svaga förslag (och även felaktiga matchningsförslag)
|
|
||||||
if (byRule?.confidence === 'high') {
|
|
||||||
const sameAsCurrent =
|
|
||||||
nextSuggestion != null && nextSuggestion.categoryId === byRule.categoryId;
|
|
||||||
if (sameAsCurrent && nextSuggestion && nextSuggestion.confidence !== 'high') {
|
|
||||||
nextSuggestion = { ...nextSuggestion, confidence: 'high' };
|
|
||||||
pushTrace(`rule applied -> "${byRule.path}" (confidence upgraded to high)`);
|
|
||||||
} else if (!sameAsCurrent && (!isTrustedSuggestion || nextSuggestion == null)) {
|
|
||||||
nextSuggestion = byRule;
|
|
||||||
pushTrace(`rule applied -> "${byRule.path}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Om regler säger en annan kategori än ett redan "trusted" förslag,
|
|
||||||
// låt regeln vinna för att bryta felaktiga historiska produktkopplingar.
|
|
||||||
if (!sameAsCurrent && isTrustedSuggestion) {
|
|
||||||
this.logger.log(
|
|
||||||
`Rule-override: "${item.rawName}" ändras från "${nextSuggestion?.path}" till "${byRule.path}"`,
|
|
||||||
);
|
|
||||||
nextSuggestion = byRule;
|
|
||||||
pushTrace(`rule override trusted -> "${byRule.path}"`);
|
|
||||||
}
|
|
||||||
} else if (!nextSuggestion && byRule) {
|
|
||||||
nextSuggestion = byRule;
|
|
||||||
pushTrace(`rule fallback applied -> "${byRule.path}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI används som fallback när varken matchning eller regler satte kategori
|
|
||||||
if (!nextSuggestion) {
|
|
||||||
if (user?.aiEngineEnabled) {
|
|
||||||
pushTrace('ai invoked');
|
|
||||||
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
|
||||||
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
|
|
||||||
} else {
|
|
||||||
pushTrace('ai skipped, feature disabled');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeGuardPath = nextSuggestion?.path;
|
|
||||||
const guardedSuggestion = nextSuggestion
|
|
||||||
? this.applyContradictionGuard(signalText || item.rawName, nextSuggestion, categories)
|
|
||||||
: null;
|
|
||||||
if (guardedSuggestion && beforeGuardPath !== guardedSuggestion.path) {
|
|
||||||
pushTrace(`contradiction guard remap "${beforeGuardPath}" -> "${guardedSuggestion.path}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeHardPath = guardedSuggestion?.path;
|
|
||||||
const finalSuggestion = guardedSuggestion
|
|
||||||
? this.applyHardCategoryOverrides(signalText || item.rawName, guardedSuggestion, categories)
|
|
||||||
: null;
|
|
||||||
if (finalSuggestion && beforeHardPath !== finalSuggestion.path) {
|
|
||||||
pushTrace(`hard override remap "${beforeHardPath}" -> "${finalSuggestion.path}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalSuggestion) {
|
|
||||||
pushTrace(`final -> "${finalSuggestion.path}" (${finalSuggestion.confidence})`);
|
|
||||||
} else {
|
|
||||||
pushTrace('final -> no categorySuggestion');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (traceEnabled) {
|
|
||||||
this.logger.log(`[ReceiptDecision] ${trace.join(' | ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
enriched.push(
|
|
||||||
finalSuggestion
|
|
||||||
? { ...item, categorySuggestion: finalSuggestion, matchedVia: item.matchedVia ?? (finalSuggestion ? 'ai' as const : 'none' as const) }
|
|
||||||
: item,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const traceSignalText = [
|
|
||||||
item.rawName,
|
|
||||||
item.matchedProductName,
|
|
||||||
item.suggestedProductName,
|
|
||||||
]
|
|
||||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
|
||||||
.join(' ');
|
|
||||||
if (this.shouldTraceDecision(traceSignalText || item.rawName)) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[ReceiptDecision] error raw="${item.rawName}" signal="${traceSignalText || item.rawName}" err=${String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta
|
|
||||||
enriched.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return enriched;
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldTraceDecision(signalText: string): boolean {
|
private shouldTraceDecision(signalText: string): boolean {
|
||||||
const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase();
|
const envFlag = (process.env.RECEIPT_TRACE_DECISIONS ?? '').toLowerCase();
|
||||||
if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') {
|
if (envFlag === '1' || envFlag === 'true' || envFlag === 'yes') {
|
||||||
@@ -1589,28 +1219,14 @@ export class ReceiptImportService {
|
|||||||
c.name.toLowerCase() === 'ägg' &&
|
c.name.toLowerCase() === 'ägg' &&
|
||||||
c.path.toLowerCase() === 'mejeri, ost & ägg > ägg',
|
c.path.toLowerCase() === 'mejeri, ost & ägg > ägg',
|
||||||
);
|
);
|
||||||
if (l2Egg) {
|
const l2EggHit = toSuggestion(l2Egg, 'high');
|
||||||
return {
|
if (l2EggHit) return l2EggHit;
|
||||||
categoryId: l2Egg.id,
|
|
||||||
categoryName: l2Egg.name,
|
|
||||||
path: l2Egg.path,
|
|
||||||
confidence: 'high',
|
|
||||||
usedFallback: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const l1DairyEgg = categories.find(
|
const l1DairyEgg = categories.find(
|
||||||
(c) => c.path.toLowerCase() === 'mejeri, ost & ägg',
|
(c) => c.path.toLowerCase() === 'mejeri, ost & ägg',
|
||||||
);
|
);
|
||||||
if (l1DairyEgg) {
|
const l1DairyEggHit = toSuggestion(l1DairyEgg, 'high');
|
||||||
return {
|
if (l1DairyEggHit) return l1DairyEggHit;
|
||||||
categoryId: l1DairyEgg.id,
|
|
||||||
categoryName: l1DairyEgg.name,
|
|
||||||
path: l1DairyEgg.path,
|
|
||||||
confidence: 'high',
|
|
||||||
usedFallback: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regel: Juice/fruktdryck/smoothie ───────────────────────────────
|
// ── Regel: Juice/fruktdryck/smoothie ───────────────────────────────
|
||||||
@@ -1648,15 +1264,13 @@ export class ReceiptImportService {
|
|||||||
const l3Te = categories.find(
|
const l3Te = categories.find(
|
||||||
(c) => c.name.toLowerCase() === 'te' && c.path.toLowerCase().includes('te & choklad'),
|
(c) => c.name.toLowerCase() === 'te' && c.path.toLowerCase().includes('te & choklad'),
|
||||||
);
|
);
|
||||||
if (l3Te) {
|
const l3TeHit = toSuggestion(l3Te, 'high');
|
||||||
return { categoryId: l3Te.id, categoryName: l3Te.name, path: l3Te.path, confidence: 'high', usedFallback: false };
|
if (l3TeHit) return l3TeHit;
|
||||||
}
|
|
||||||
const l2TeChoklad = categories.find(
|
const l2TeChoklad = categories.find(
|
||||||
(c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'),
|
(c) => c.name.toLowerCase() === 'te & choklad' && c.path.toLowerCase().startsWith('dryck'),
|
||||||
);
|
);
|
||||||
if (l2TeChoklad) {
|
const l2TeChokladHit = toSuggestion(l2TeChoklad, 'medium');
|
||||||
return { categoryId: l2TeChoklad.id, categoryName: l2TeChoklad.name, path: l2TeChoklad.path, confidence: 'medium', usedFallback: false };
|
if (l2TeChokladHit) return l2TeChokladHit;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regel: Kaffebröd ─────────────────────────────────────────────────
|
// ── Regel: Kaffebröd ─────────────────────────────────────────────────
|
||||||
@@ -1679,15 +1293,13 @@ export class ReceiptImportService {
|
|||||||
const l3Kaffebrod = categories.find(
|
const l3Kaffebrod = categories.find(
|
||||||
(c) => c.name.toLowerCase() === 'kaffebröd' && c.path.toLowerCase().includes('kondis & fika'),
|
(c) => c.name.toLowerCase() === 'kaffebröd' && c.path.toLowerCase().includes('kondis & fika'),
|
||||||
);
|
);
|
||||||
if (l3Kaffebrod) {
|
const l3KaffebrodHit = toSuggestion(l3Kaffebrod, 'high');
|
||||||
return { categoryId: l3Kaffebrod.id, categoryName: l3Kaffebrod.name, path: l3Kaffebrod.path, confidence: 'high', usedFallback: false };
|
if (l3KaffebrodHit) return l3KaffebrodHit;
|
||||||
}
|
|
||||||
const l2Kondis = categories.find(
|
const l2Kondis = categories.find(
|
||||||
(c) => c.name.toLowerCase() === 'kondis & fika' && c.path.toLowerCase().startsWith('bröd & kakor'),
|
(c) => c.name.toLowerCase() === 'kondis & fika' && c.path.toLowerCase().startsWith('bröd & kakor'),
|
||||||
);
|
);
|
||||||
if (l2Kondis) {
|
const l2KondisHit = toSuggestion(l2Kondis, 'medium');
|
||||||
return { categoryId: l2Kondis.id, categoryName: l2Kondis.name, path: l2Kondis.path, confidence: 'medium', usedFallback: false };
|
if (l2KondisHit) return l2KondisHit;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regel: Godis/chokladkakor ──────────────────────────────────────
|
// ── Regel: Godis/chokladkakor ──────────────────────────────────────
|
||||||
@@ -1763,30 +1375,16 @@ export class ReceiptImportService {
|
|||||||
c.name.toLowerCase() === 'allergi matlagning' &&
|
c.name.toLowerCase() === 'allergi matlagning' &&
|
||||||
c.path.toLowerCase().startsWith('matlagning > '),
|
c.path.toLowerCase().startsWith('matlagning > '),
|
||||||
);
|
);
|
||||||
if (l3AllergyCooking) {
|
const l3AllergyCookingHit = toSuggestion(l3AllergyCooking, 'high');
|
||||||
return {
|
if (l3AllergyCookingHit) return l3AllergyCookingHit;
|
||||||
categoryId: l3AllergyCooking.id,
|
|
||||||
categoryName: l3AllergyCooking.name,
|
|
||||||
path: l3AllergyCooking.path,
|
|
||||||
confidence: 'high',
|
|
||||||
usedFallback: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const l2Cooking = categories.find(
|
const l2Cooking = categories.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase() === 'matlagning' &&
|
c.name.toLowerCase() === 'matlagning' &&
|
||||||
c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning',
|
c.path.toLowerCase() === 'mejeri, ost & ägg > matlagning',
|
||||||
);
|
);
|
||||||
if (l2Cooking) {
|
const l2CookingHit = toSuggestion(l2Cooking, 'medium');
|
||||||
return {
|
if (l2CookingHit) return l2CookingHit;
|
||||||
categoryId: l2Cooking.id,
|
|
||||||
categoryName: l2Cooking.name,
|
|
||||||
path: l2Cooking.path,
|
|
||||||
confidence: 'medium',
|
|
||||||
usedFallback: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user