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
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user