- Added new API path for unit mappings in `api_paths.dart`. - Implemented `upsertUnitMapping` method in `ImportRepository` to handle unit mapping creation. - Updated `ReceiptImportTab` to learn and save unit mappings during receipt import. - Created DTO for unit mapping with validation in `create-unit-mapping.dto.ts`. - Added SQL migration for `UnitMapping` table creation with necessary constraints.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { IsInt, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateUnitMappingDto {
|
||||
@IsInt()
|
||||
productId!: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
originalUnit!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
preferredUnit!: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
@@ -13,6 +14,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { ReceiptImportService } from './receipt-import.service';
|
||||
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
||||
import { CreateUnitMappingDto } from './dto/create-unit-mapping.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
const ALLOWED_MIMES = [
|
||||
@@ -61,4 +63,28 @@ export class ReceiptImportController {
|
||||
await this.receiptImportService.loadCategories();
|
||||
return { message: 'Kategorier har uppdaterats.' };
|
||||
}
|
||||
|
||||
@Post('unit-mappings')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
async upsertUnitMapping(
|
||||
@Body() dto: CreateUnitMappingDto,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const userId =
|
||||
typeof req?.user?.id === 'number'
|
||||
? req.user.id
|
||||
: typeof req?.user?.userId === 'number'
|
||||
? req.user.userId
|
||||
: undefined;
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Kunde inte identifiera användaren.');
|
||||
}
|
||||
|
||||
return this.receiptImportService.upsertUnitMapping(
|
||||
userId,
|
||||
dto.productId,
|
||||
dto.originalUnit,
|
||||
dto.preferredUnit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('ReceiptImportService test matrix', () => {
|
||||
category: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
receiptAlias: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
product: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
unitMapping: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
@@ -60,6 +61,7 @@ describe('ReceiptImportService test matrix', () => {
|
||||
jest.clearAllMocks();
|
||||
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
|
||||
prismaMock.product.findMany.mockResolvedValue([]);
|
||||
prismaMock.unitMapping.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('ignore patterns', () => {
|
||||
@@ -222,5 +224,37 @@ describe('ReceiptImportService test matrix', () => {
|
||||
expect(second[0].matchedProductName).toBe('Mjolk');
|
||||
expect(second[0].suggestedProductId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('använder inlärd enhetsmappning vid aliasträff', async () => {
|
||||
prismaMock.receiptAlias.findMany.mockResolvedValue([
|
||||
{
|
||||
receiptName: 'mjolk 1l',
|
||||
productId: 501,
|
||||
product: {
|
||||
id: 501,
|
||||
name: 'Mjolk user',
|
||||
canonicalName: 'Mjolk user',
|
||||
categoryId: 30,
|
||||
categoryRef: { id: 30, name: 'Mejeri' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.unitMapping.findMany.mockResolvedValue([
|
||||
{
|
||||
productId: 501,
|
||||
originalUnit: 'l',
|
||||
preferredUnit: 'st',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await (service as any).matchProducts(
|
||||
[{ rawName: 'MJOLK 1L', unit: 'L' }],
|
||||
77,
|
||||
);
|
||||
|
||||
expect(result[0].matchedProductId).toBe(501);
|
||||
expect(result[0].unit).toBe('st');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,6 +162,45 @@ export class ReceiptImportService {
|
||||
return this.enrichWithAiCategories(matched, userId);
|
||||
}
|
||||
|
||||
async upsertUnitMapping(
|
||||
userId: number,
|
||||
productId: number,
|
||||
originalUnit: string,
|
||||
preferredUnit: string,
|
||||
) {
|
||||
const prismaAny = this.prisma as any;
|
||||
const normalizedOriginalUnit = originalUnit.trim().toLowerCase();
|
||||
const normalizedPreferredUnit = preferredUnit.trim().toLowerCase();
|
||||
|
||||
if (!normalizedOriginalUnit || !normalizedPreferredUnit) {
|
||||
throw new BadRequestException('Enheter måste vara ifyllda.');
|
||||
}
|
||||
|
||||
// Ingen inlärning behövs om enheten redan är samma.
|
||||
if (normalizedOriginalUnit === normalizedPreferredUnit) {
|
||||
return { skipped: true };
|
||||
}
|
||||
|
||||
return prismaAny.unitMapping.upsert({
|
||||
where: {
|
||||
productId_originalUnit_userId: {
|
||||
productId,
|
||||
originalUnit: normalizedOriginalUnit,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
preferredUnit: normalizedPreferredUnit,
|
||||
},
|
||||
create: {
|
||||
productId,
|
||||
userId,
|
||||
originalUnit: normalizedOriginalUnit,
|
||||
preferredUnit: normalizedPreferredUnit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
@@ -205,6 +244,19 @@ export class ReceiptImportService {
|
||||
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
|
||||
@@ -215,6 +267,14 @@ export class ReceiptImportService {
|
||||
],
|
||||
}
|
||||
: { 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,
|
||||
@@ -228,24 +288,27 @@ export class ReceiptImportService {
|
||||
where: productFilter,
|
||||
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } },
|
||||
}),
|
||||
this.prisma.unitMapping.findMany({
|
||||
where: { userId: userId },
|
||||
select: { productId: true, originalUnit: true, preferredUnit: 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) => a.receiptName === raw);
|
||||
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,
|
||||
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.name, confidence: 'high' as const, usedFallback: false } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -257,7 +320,11 @@ export class ReceiptImportService {
|
||||
}
|
||||
|
||||
// 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);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user