feat: add unit mapping functionality
Test Suite / test (24.15.0) (push) Has been cancelled

- 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:
Nils-Johan Gynther
2026-05-07 10:00:42 +02:00
parent 26823fbf35
commit a68a0ca86f
35 changed files with 558 additions and 24 deletions
@@ -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;