feat: implement alias strategy for receipt import with user-scoped and global fallback, enhance validation and normalization, and update UI components
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 23:41:42 +02:00
parent b342de906e
commit 65137b41fb
17 changed files with 388 additions and 67 deletions
@@ -60,6 +60,10 @@ export class SaveReceiptItemDto {
@IsBoolean()
learnAlias?: boolean;
@IsOptional()
@IsBoolean()
learnAliasGlobally?: boolean;
@IsOptional()
@IsBoolean()
learnUnitMapping?: boolean;
@@ -70,8 +74,4 @@ export class SaveReceiptDto {
@ValidateNested({ each: true })
@Type(() => SaveReceiptItemDto)
items!: SaveReceiptItemDto[];
@IsOptional()
@IsBoolean()
isAdminLearning?: boolean;
}
@@ -102,7 +102,7 @@ export class ReceiptImportController {
}
const isAdmin = req?.user?.role === 'admin';
if (dto.isAdminLearning && !isAdmin) {
if (dto.items.some((item) => item.learnAliasGlobally === true) && !isAdmin) {
throw new BadRequestException('Endast administratörer kan spara globala aliaser.');
}
@@ -165,6 +165,28 @@ describe('ReceiptImportService test matrix', () => {
expect(result.matchedProductName).toBe('Snickers');
});
it('normaliserar whitespace vid alias-lookup', async () => {
const aliases = [
{
receiptName: 'arla mjolk 1l',
productId: 700,
product: {
id: 700,
name: 'Arla Mjolk 1l',
canonicalName: 'Mjolk',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
];
const context = makeContext(aliases, [], [], 42);
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: ' ARLA MJOLK 1L ' }, context);
expect(result.matchedProductId).toBe(700);
expect(result.matchedVia).toBe('alias');
});
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
const products = [
{
@@ -12,6 +12,11 @@ import { SaveReceiptResponse } from './dto/save-receipt.response';
import { AiService, CategorySuggestion } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
import { normalizeName } from '../common/utils/normalize-name';
import {
isIgnoredReceiptAliasName,
normalizeReceiptAliasName,
validateReceiptAliasName,
} from '../common/utils/receipt-alias';
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
@@ -37,20 +42,7 @@ function tokenize(value: string): string[] {
}
export function isIgnoredReceiptName(value: string | null | undefined): boolean {
const normalized = (value ?? '').trim().toLowerCase();
if (!normalized) return false;
if (/^rabatt\b/.test(normalized)) return true;
if (/^summa\b/.test(normalized)) return true;
if (/^moms\b/.test(normalized)) return true;
if (/^pant\b/.test(normalized)) return true;
if (/^att\s+betala\b/.test(normalized)) return true;
if (/^totalt\b/.test(normalized)) return true;
if (/^kort\b/.test(normalized)) return true;
if (/^kontant\b/.test(normalized)) return true;
if (/^willys\s+plus\s*[:\-]?\b/.test(normalized)) return true;
return false;
return isIgnoredReceiptAliasName(value);
}
function normalizeToken(s: string): string {
@@ -220,8 +212,10 @@ export class ReceiptImportService {
const aliasByReceiptName = new Map<string, AliasLite>();
for (const alias of aliases) {
if (!aliasByReceiptName.has(alias.receiptName)) {
aliasByReceiptName.set(alias.receiptName, alias);
const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName);
if (!normalizedReceiptName) continue;
if (!aliasByReceiptName.has(normalizedReceiptName)) {
aliasByReceiptName.set(normalizedReceiptName, alias);
}
}
@@ -472,15 +466,20 @@ export class ReceiptImportService {
// === Steg 4: Lär in alias om requested ===
if (item.learnAlias) {
const normalizedReceiptName = (item.rawName ?? '').trim().toLowerCase();
const normalizedReceiptName = normalizeReceiptAliasName(item.rawName);
const aliasValidationError = validateReceiptAliasName(normalizedReceiptName);
if (aliasValidationError) {
throw new Error(aliasValidationError);
}
if (normalizedReceiptName) {
const aliasOwnerId: number | null = dto.isAdminLearning ? null : userId || null;
const isGlobalAlias = item.learnAliasGlobally === true;
const aliasOwnerId: number | null = isGlobalAlias ? null : userId || null;
await tx.receiptAlias.upsert({
where: {
receiptName_ownerId_isGlobal: {
receiptName: normalizedReceiptName,
ownerId: aliasOwnerId as any,
isGlobal: dto.isAdminLearning ? true : false,
isGlobal: isGlobalAlias,
},
},
update: {
@@ -489,8 +488,8 @@ export class ReceiptImportService {
create: {
receiptName: normalizedReceiptName,
productId,
ownerId: (dto.isAdminLearning ? null : userId || null) as any,
isGlobal: dto.isAdminLearning ? true : false,
ownerId: aliasOwnerId as any,
isGlobal: isGlobalAlias,
},
});
response.aliasesLearned++;
@@ -573,7 +572,7 @@ export class ReceiptImportService {
): Promise<ParsedReceiptItem> {
if (!item.rawName) return item;
const raw = item.rawName.toLowerCase().trim();
const raw = normalizeReceiptAliasName(item.rawName);
const debug: MatchDebug = { steps: [], tree: {} };
try {