feat: implement save receipt functionality with transaction handling and DTOs
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 15:04:23 +02:00
parent 853e853e5e
commit 8354abbc8f
10 changed files with 461 additions and 99 deletions
@@ -4,10 +4,14 @@ import {
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { SaveReceiptDto } from './dto/save-receipt.dto';
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';
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
@@ -200,6 +204,218 @@ export class ReceiptImportService {
});
}
async saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
const response: SaveReceiptResponse = {
created: 0,
merged: 0,
pantryAdded: 0,
pantrySkipped: 0,
aliasesLearned: 0,
unitMappingsLearned: 0,
errors: [],
};
const prismaAny = this.prisma as any;
// Preload existierande pantry-poster för denna användare
const userPantry = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(userPantry.map((p) => p.productId));
// Preload existierande inventarioposter för denna användare (grupperat efter productId)
const userInventory = await this.prisma.inventoryItem.findMany({
where: { userId },
select: { id: true, productId: true, quantity: true, unit: true },
});
const inventoryByProductId = new Map<number, typeof userInventory[0]>();
for (const item of userInventory) {
if (!inventoryByProductId.has(item.productId)) {
inventoryByProductId.set(item.productId, item);
}
}
// Kör allt i en transaktion för atomicitet
try {
await this.prisma.$transaction(async (tx) => {
const txAny = tx as any;
for (let index = 0; index < dto.items.length; index++) {
const item = dto.items[index];
try {
// === Steg 1: Bestäm/skapa produkten ===
let productId: number;
if (item.createProductName) {
// Skapa ny privat produkt
const name = item.createProductName.trim();
const normalizedName = `private:${userId}:${normalizeName(name)}`;
const existing = await tx.product.findUnique({
where: { normalizedName },
});
if (existing && existing.isActive) {
productId = existing.id;
} else if (existing) {
const updated = await tx.product.update({
where: { id: existing.id },
data: { isActive: true, deletedAt: null, name, canonicalName: name },
});
productId = updated.id;
} else {
const created = await tx.product.create({
data: {
name,
normalizedName,
canonicalName: name,
isActive: true,
isPrivate: true,
ownerId: userId,
...(item.categoryId != null ? { categoryId: item.categoryId } : {}),
},
});
productId = created.id;
}
} else if (item.productId) {
// Använd befintlig produkt
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new Error(`Produkten med ID ${item.productId} hittades inte.`);
}
productId = product.id;
} else {
throw new Error('Antingen productId eller createProductName måste anges.');
}
// === Steg 2: Hantera pantry eller inventory ===
if (item.destination === 'pantry') {
if (pantryProductIds.has(productId)) {
response.pantrySkipped++;
} else {
await tx.pantryItem.create({
data: { userId, productId },
});
response.pantryAdded++;
pantryProductIds.add(productId);
}
} else {
// inventory
const quantity = item.quantity ?? 0;
const unit = (item.unit ?? '').trim() || 'st';
const existing = inventoryByProductId.get(productId);
if (existing) {
// Slå samman
await tx.inventoryItem.update({
where: { id: existing.id },
data: {
quantity: {
increment: new Prisma.Decimal(quantity),
},
},
});
response.merged++;
} else {
// Skapa ny
await tx.inventoryItem.create({
data: {
userId,
productId,
quantity: new Prisma.Decimal(quantity),
unit,
brand: item.brand ?? undefined,
origin: item.origin ?? undefined,
receiptName: item.rawName,
},
});
response.created++;
// Uppdatera local cache
inventoryByProductId.set(productId, {
id: -1,
productId,
quantity: new Prisma.Decimal(quantity),
unit,
});
}
// === Steg 3: Lär in enhetsmappning om requested ===
if (item.learnUnitMapping) {
const originalUnit = (item.rawName ?? '').trim().toLowerCase();
const preferredUnit = unit.toLowerCase();
if (originalUnit && preferredUnit && originalUnit !== preferredUnit) {
await txAny.unitMapping.upsert({
where: {
productId_originalUnit_userId: {
productId,
originalUnit,
userId,
},
},
update: {
preferredUnit,
},
create: {
productId,
userId,
originalUnit,
preferredUnit,
},
});
response.unitMappingsLearned++;
}
}
}
// === Steg 4: Lär in alias om requested ===
if (item.learnAlias) {
const normalizedReceiptName = (item.rawName ?? '').trim().toLowerCase();
if (normalizedReceiptName) {
await tx.receiptAlias.upsert({
where: {
receiptName_ownerId_isGlobal: {
receiptName: normalizedReceiptName,
ownerId: dto.isAdminLearning ? null : userId,
isGlobal: dto.isAdminLearning ? true : false,
},
},
update: {
productId,
},
create: {
receiptName: normalizedReceiptName,
productId,
ownerId: dto.isAdminLearning ? null : userId,
isGlobal: dto.isAdminLearning ? true : false,
},
});
response.aliasesLearned++;
}
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
this.logger.warn(
`saveReceipt item [${index}] error: ${errorMsg}`,
);
response.errors = response.errors ?? [];
response.errors.push({ index, error: errorMsg });
}
}
});
} catch (err) {
this.logger.error(`saveReceipt transaction failed: ${err}`);
throw new BadRequestException(
`Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`,
);
}
return response;
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
const form = new FormData();
form.append(