- 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:
@@ -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