From 8354abbc8fc80408c22274e681662401093cb7ac Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 9 May 2026 15:04:23 +0200 Subject: [PATCH] feat: implement save receipt functionality with transaction handling and DTOs --- .../migration.sql | 5 + backend/prisma/schema.prisma | 2 +- .../src/products/dto/update-product.dto.ts | 13 +- backend/src/products/products.service.ts | 4 - .../receipt-import/dto/save-receipt.dto.ts | 77 +++++++ .../dto/save-receipt.response.ts | 9 + .../receipt-import.controller.ts | 29 +++ .../receipt-import/receipt-import.service.ts | 216 ++++++++++++++++++ .../import/data/import_repository.dart | 58 +++++ .../presentation/receipt_import_tab.dart | 147 ++++++------ 10 files changed, 461 insertions(+), 99 deletions(-) create mode 100644 backend/prisma/migrations/20260509000000_remove_product_category_and_add_indexes/migration.sql create mode 100644 backend/src/receipt-import/dto/save-receipt.dto.ts create mode 100644 backend/src/receipt-import/dto/save-receipt.response.ts diff --git a/backend/prisma/migrations/20260509000000_remove_product_category_and_add_indexes/migration.sql b/backend/prisma/migrations/20260509000000_remove_product_category_and_add_indexes/migration.sql new file mode 100644 index 00000000..3fdcac2d --- /dev/null +++ b/backend/prisma/migrations/20260509000000_remove_product_category_and_add_indexes/migration.sql @@ -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`); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8ac595bb..b66443f6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/products/dto/update-product.dto.ts b/backend/src/products/dto/update-product.dto.ts index 96f33c95..19bb90b9 100644 --- a/backend/src/products/dto/update-product.dto.ts +++ b/backend/src/products/dto/update-product.dto.ts @@ -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; diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index ce90d281..e3dd0e51 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -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; } diff --git a/backend/src/receipt-import/dto/save-receipt.dto.ts b/backend/src/receipt-import/dto/save-receipt.dto.ts new file mode 100644 index 00000000..1cd430d9 --- /dev/null +++ b/backend/src/receipt-import/dto/save-receipt.dto.ts @@ -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; +} diff --git a/backend/src/receipt-import/dto/save-receipt.response.ts b/backend/src/receipt-import/dto/save-receipt.response.ts new file mode 100644 index 00000000..fb857e9a --- /dev/null +++ b/backend/src/receipt-import/dto/save-receipt.response.ts @@ -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 }>; +} diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index 1d4cc078..b4da9e66 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -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 { + 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); + } } + diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index e0133278..708ca8a5 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -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 { + 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(); + 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 { const form = new FormData(); form.append( diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index d0d666f0..ba23c971 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -255,6 +255,64 @@ class ImportRepository { } } + /// Save receipt items in a single atomic transaction. + /// + /// This endpoint handles: + /// - Creating/validating products + /// - Creating/merging inventory items + /// - Adding to pantry + /// - Learning aliases + /// - Learning unit mappings + Future> saveReceipt({ + required List> items, + bool isAdminLearning = false, + String? token, + }) async { + try { + developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository'); + + final uri = Uri.parse('$_baseUrl/receipt-import/save'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'items': items, + if (isAdminLearning) 'isAdminLearning': true, + }), + ).timeout( + const Duration(seconds: 60), + onTimeout: () { + developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout'); + throw ApiException( + type: ApiErrorType.network, + message: 'Sparandet tok för lång tid. Försök igen.', + ); + }, + ); + + developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid sparande: ${response.body}', + statusCode: response.statusCode, + ); + } + + final result = _parseResponse(response) as Map; + developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository'); + return result; + } catch (e) { + developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e); + rethrow; + } + } + /// Helper method to map HTTP status codes to [ApiErrorType]. ApiErrorType _mapStatusCodeToErrorType(int statusCode) { if (statusCode == 401) return ApiErrorType.unauthorized; diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 78df31a4..63d3085a 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -473,107 +473,74 @@ class _ReceiptImportTabState extends ConsumerState { } setState(() => _isSaving = true); - int created = 0; - int merged = 0; - int pantryAdded = 0; - int pantrySkipped = 0; - int aliasesLearned = 0; - int unitMappingsLearned = 0; try { final token = await ref.read(authStateProvider.future); final repo = ref.read(importRepositoryProvider); - final invRepo = ref.read(inventoryRepositoryProvider); - final pantryRepo = ref.read(pantryRepositoryProvider); - final adminRepo = ref.read(adminRepositoryProvider); final canManageAliases = ref.read(isAdminProvider); + // Bygg upp items för saveReceipt endpoint + final saveItems = >[]; for (final i in toAdd) { final edit = _edits[i]!; final item = items[i]; final pid = edit.productId!; - if (edit.destination == _Destination.pantry) { - if (_pantryProductIds.contains(pid)) { - pantrySkipped++; - } else { - await pantryRepo.createPantryItem(pid, token: token); - pantryAdded++; - } - } else { - final inferred = inferPackageFields( - rawName: item.rawName, - quantity: edit.quantity ?? item.quantity, - unit: edit.unit ?? item.unit, - ); - final packageCount = edit.packageCount ?? inferred.packageCount; - final packQuantity = edit.packQuantity ?? inferred.packQuantity; - final packUnit = edit.packUnit ?? inferred.packUnit ?? edit.unit ?? item.unit ?? 'st'; - final qty = packQuantity != null - ? (packQuantity * packageCount) - : (edit.quantity ?? inferred.totalQuantity ?? item.quantity ?? 1.0); - final unit = packUnit; - final existing = _inventoryByProduct[pid]; - final originalUnit = (item.unit ?? '').trim(); - final preferredUnitForLearning = (existing?.unit ?? unit).trim(); - final qtyInExistingUnit = existing == null - ? null - : convertQuantity(qty, unit, existing.unit); - if (existing != null && qtyInExistingUnit != null) { - await invRepo.updateInventoryItem( - existing.id, - {'quantity': existing.quantity + qtyInExistingUnit}, - token: token, - ); - merged++; - } else { - await invRepo.createInventoryItem({ - 'productId': pid, - 'quantity': qty, - 'unit': unit, - if (item.brand != null) 'brand': item.brand, - }, token: token); - created++; - } + final saveItem = { + 'rawName': item.rawName, + 'quantity': edit.quantity ?? item.quantity ?? 0, + 'unit': (edit.unit ?? item.unit ?? 'st').trim(), + 'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory', + 'productId': pid, + }; - if (originalUnit.isNotEmpty && preferredUnitForLearning.isNotEmpty) { - try { - await repo.upsertUnitMapping( - productId: pid, - originalUnit: originalUnit, - preferredUnit: preferredUnitForLearning, - token: token, - ); - if (originalUnit.toLowerCase().trim() != preferredUnitForLearning.toLowerCase().trim()) { - unitMappingsLearned++; - } - } catch (e, st) { - debugPrint('ReceiptImportTab unit mapping upsert failed: $e'); - debugPrintStack(stackTrace: st); - } - } + // Lägg till optional fält + if (item.price != null) saveItem['price'] = item.price; + if (item.brand != null) saveItem['brand'] = item.brand; + if (item.origin != null) saveItem['origin'] = item.origin; + + // Päckfält för inventory + if (edit.destination == _Destination.inventory) { + if (edit.packQuantity != null) saveItem['packQuantity'] = edit.packQuantity; + if (edit.packUnit != null) saveItem['packUnit'] = edit.packUnit; + if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount; } - final normalizedReceiptName = item.rawName.trim().toLowerCase(); - // Spara alias för alla användare (user-scope) när raden inte redan matchades via alias, - // eller admin sparar global alias. + // Lär in alias om den inte redan matchades via alias final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid; - final shouldLearnAlias = normalizedReceiptName.isNotEmpty && !alreadyAliasMatch; - if (shouldLearnAlias) { - try { - await adminRepo.upsertReceiptAlias( - receiptName: normalizedReceiptName, - productId: pid, - isGlobal: canManageAliases, - ); - aliasesLearned++; - } catch (e, st) { - debugPrint('ReceiptImportTab alias upsert failed: $e'); - debugPrintStack(stackTrace: st); + if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) { + saveItem['learnAlias'] = true; + } + + // Lär in enhetsmappning för inventory + if (edit.destination == _Destination.inventory) { + final originalUnit = (item.unit ?? '').trim().toLowerCase(); + final preferredUnit = (edit.unit ?? item.unit ?? 'st').trim().toLowerCase(); + if (originalUnit.isNotEmpty && preferredUnit.isNotEmpty && originalUnit != preferredUnit) { + saveItem['learnUnitMapping'] = true; } } + + saveItems.add(saveItem); } + // Gör ett enda anrop till saveReceipt + final response = await repo.saveReceipt( + items: saveItems, + isAdminLearning: canManageAliases, + token: token, + ); + if (!mounted) return; + + // Visa feedback från response + final created = response['created'] as int? ?? 0; + final merged = response['merged'] as int? ?? 0; + final pantryAdded = response['pantryAdded'] as int? ?? 0; + final pantrySkipped = response['pantrySkipped'] as int? ?? 0; + final aliasesLearned = response['aliasesLearned'] as int? ?? 0; + final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0; + final errors = response['errors'] as List? ?? []; + final parts = [ if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie', if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie', @@ -582,9 +549,25 @@ class _ReceiptImportTabState extends ConsumerState { if (aliasesLearned > 0) '$aliasesLearned alias inlärda', if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda', ]; + + if (errors.isNotEmpty) { + final errorParts = []; + for (final err in errors) { + final index = err['index'] as int?; + final error = err['error'] as String?; + if (index != null && error != null) { + errorParts.add('Rad $index: $error'); + } + } + if (errorParts.isNotEmpty) { + parts.add('⚠️ ${errorParts.join(', ')}'); + } + } + ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(parts.join(', ') + '.')), ); + // Avmarkera sparade rader och uppdatera inventariet final notifier = ref.read(receiptImportSessionProvider.notifier); notifier.setSelectedForIndexes(toAdd, false);