feat: implement save receipt functionality with transaction handling and DTOs
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:
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: Remove Product.category field (redundant with categoryId)
|
||||
ALTER TABLE `Product` DROP COLUMN `category`;
|
||||
|
||||
-- AlterTable: Add index on ReceiptAlias.receiptName for faster lookups
|
||||
CREATE INDEX `ReceiptAlias_receiptName_idx` ON `ReceiptAlias`(`receiptName`);
|
||||
@@ -36,7 +36,6 @@ model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
normalizedName String @unique
|
||||
category String?
|
||||
canonicalName String?
|
||||
isActive Boolean @default(true)
|
||||
status String @default("active")
|
||||
@@ -201,6 +200,7 @@ model ReceiptAlias {
|
||||
@@unique([receiptName, ownerId, isGlobal])
|
||||
@@index([ownerId])
|
||||
@@index([isGlobal])
|
||||
@@index([receiptName])
|
||||
}
|
||||
|
||||
model MealPlanEntry {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsNumber, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(191)
|
||||
name?: string;
|
||||
|
||||
@@ -12,16 +11,6 @@ export class UpdateProductDto {
|
||||
@MaxLength(191)
|
||||
canonicalName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(191)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(191)
|
||||
subcategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
categoryId?: number | null;
|
||||
|
||||
@@ -188,10 +188,6 @@ export class ProductsService {
|
||||
updateData.canonicalName = data.canonicalName.trim() || undefined;
|
||||
}
|
||||
|
||||
if (typeof data.category === 'string') {
|
||||
updateData.category = data.category.trim() || null;
|
||||
}
|
||||
|
||||
if ('categoryId' in data) {
|
||||
updateData.categoryId = data.categoryId ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { IsNumber, IsOptional, IsString, IsBoolean, IsArray, ValidateNested, IsIn, Min, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SaveReceiptItemDto {
|
||||
@IsString()
|
||||
@MaxLength(191)
|
||||
rawName!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@IsString()
|
||||
unit!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
price?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
brand?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
origin?: string | null;
|
||||
|
||||
@IsIn(['inventory', 'pantry'])
|
||||
destination!: 'inventory' | 'pantry';
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
productId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(191)
|
||||
createProductName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
categoryId?: number | null;
|
||||
|
||||
// Paketfält (kan editeras i UI)
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
packQuantity?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
packUnit?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
packageCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
learnAlias?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
learnUnitMapping?: boolean;
|
||||
}
|
||||
|
||||
export class SaveReceiptDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SaveReceiptItemDto)
|
||||
items!: SaveReceiptItemDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isAdminLearning?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface SaveReceiptResponse {
|
||||
created: number;
|
||||
merged: number;
|
||||
pantryAdded: number;
|
||||
pantrySkipped: number;
|
||||
aliasesLearned: number;
|
||||
unitMappingsLearned: number;
|
||||
errors?: Array<{ index: number; error: string }>;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ 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 { SaveReceiptDto } from './dto/save-receipt.dto';
|
||||
import { SaveReceiptResponse } from './dto/save-receipt.response';
|
||||
import { CreateUnitMappingDto } from './dto/create-unit-mapping.dto';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@@ -87,4 +89,31 @@ export class ReceiptImportController {
|
||||
dto.preferredUnit,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Post('save')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Throttle({ default: { ttl: 60_000, limit: 10 } })
|
||||
async saveReceipt(
|
||||
@Body() dto: SaveReceiptDto,
|
||||
@Request() req?: any,
|
||||
): Promise<SaveReceiptResponse> {
|
||||
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.');
|
||||
}
|
||||
|
||||
const isAdmin = req?.user?.role === 'admin';
|
||||
if (dto.isAdminLearning && !isAdmin) {
|
||||
throw new BadRequestException('Endast administratörer kan spara globala aliaser.');
|
||||
}
|
||||
|
||||
return this.receiptImportService.saveReceipt(userId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user