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
+39
View File
@@ -0,0 +1,39 @@
const ignoredReceiptAliasPatterns = [
/^rabatt\b/,
/^summa\b/,
/^moms\b/,
/^pant\b/,
/^att\s+betala\b/,
/^totalt\b/,
/^kort\b/,
/^kontant\b/,
/^willys\s+plus\s*[:\-]?\b/,
];
export function normalizeReceiptAliasName(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
export function isIgnoredReceiptAliasName(value: string | null | undefined): boolean {
const normalized = normalizeReceiptAliasName(value);
if (!normalized) return false;
return ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized));
}
export function validateReceiptAliasName(value: string | null | undefined): string | null {
const normalized = normalizeReceiptAliasName(value);
if (!normalized) {
return 'Alias får inte vara tomt.';
}
if (!/[a-z0-9åäö]/.test(normalized)) {
return 'Alias måste innehålla bokstäver eller siffror.';
}
if (ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized))) {
return 'Aliaset ser ut som en kvittorad som ska ignoreras och kan inte sparas.';
}
return null;
}
@@ -0,0 +1,67 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service';
describe('ReceiptAliasService', () => {
const prismaMock = {
receiptAlias: {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
const service = new ReceiptAliasService(prismaMock as any);
beforeEach(() => {
jest.clearAllMocks();
});
it('normaliserar alias före upsert', async () => {
prismaMock.receiptAlias.findFirst.mockResolvedValue(null);
prismaMock.receiptAlias.create.mockResolvedValue({ id: 1 });
await service.upsert(
{ receiptName: ' ARLA MJOLK 1L ', productId: 7 },
10,
'user',
);
expect(prismaMock.receiptAlias.findFirst).toHaveBeenCalledWith({
where: { receiptName: 'arla mjolk 1l', ownerId: 10, isGlobal: false },
});
expect(prismaMock.receiptAlias.create).toHaveBeenCalledWith({
data: { receiptName: 'arla mjolk 1l', productId: 7, ownerId: 10, isGlobal: false },
});
});
it('blockerar brusalias', async () => {
await expect(
service.upsert({ receiptName: ' Rabatt kupong ', productId: 7 }, 10, 'user'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('tillåter inte vanlig användare att skapa globalt alias', async () => {
await expect(
service.upsert({ receiptName: 'mjolk 1l', productId: 7, isGlobal: true }, 10, 'user'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('tillåter inte vanlig användare att ta bort globalt alias', async () => {
prismaMock.receiptAlias.findUnique.mockResolvedValue({
id: 9,
ownerId: null,
isGlobal: true,
});
await expect(service.remove(9, 10, 'user')).rejects.toBeInstanceOf(ForbiddenException);
});
it('returnerar not found vid borttagning av okänt alias', async () => {
prismaMock.receiptAlias.findUnique.mockResolvedValue(null);
await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -1,6 +1,15 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
import {
normalizeReceiptAliasName,
validateReceiptAliasName,
} from '../common/utils/receipt-alias';
@Injectable()
export class ReceiptAliasService {
@@ -24,7 +33,11 @@ export class ReceiptAliasService {
}
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
const normalized = dto.receiptName.toLowerCase().trim();
const normalized = normalizeReceiptAliasName(dto.receiptName);
const validationError = validateReceiptAliasName(normalized);
if (validationError) {
throw new BadRequestException(validationError);
}
const wantsGlobal = dto.isGlobal === true;
if (wantsGlobal && role !== 'admin') {
@@ -45,10 +58,11 @@ export class ReceiptAliasService {
ownerId: number | null,
isGlobal: boolean,
) {
const normalizedReceiptName = normalizeReceiptAliasName(receiptName);
const existing = await this.prisma.receiptAlias.findFirst({
where: isGlobal
? { receiptName, isGlobal: true }
: { receiptName, ownerId, isGlobal: false },
? { receiptName: normalizedReceiptName, isGlobal: true }
: { receiptName: normalizedReceiptName, ownerId, isGlobal: false },
});
if (existing) {
@@ -59,7 +73,7 @@ export class ReceiptAliasService {
}
return this.prisma.receiptAlias.create({
data: { receiptName, productId, ownerId, isGlobal },
data: { receiptName: normalizedReceiptName, productId, ownerId, isGlobal },
});
}
@@ -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 {