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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user