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())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
normalizedName String @unique
|
normalizedName String @unique
|
||||||
category String?
|
|
||||||
canonicalName String?
|
canonicalName String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
@@ -201,6 +200,7 @@ model ReceiptAlias {
|
|||||||
@@unique([receiptName, ownerId, isGlobal])
|
@@unique([receiptName, ownerId, isGlobal])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([isGlobal])
|
@@index([isGlobal])
|
||||||
|
@@index([receiptName])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MealPlanEntry {
|
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 {
|
export class UpdateProductDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(191)
|
@MaxLength(191)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@@ -12,16 +11,6 @@ export class UpdateProductDto {
|
|||||||
@MaxLength(191)
|
@MaxLength(191)
|
||||||
canonicalName?: string;
|
canonicalName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(191)
|
|
||||||
category?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(191)
|
|
||||||
subcategory?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
categoryId?: number | null;
|
categoryId?: number | null;
|
||||||
|
|||||||
@@ -188,10 +188,6 @@ export class ProductsService {
|
|||||||
updateData.canonicalName = data.canonicalName.trim() || undefined;
|
updateData.canonicalName = data.canonicalName.trim() || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.category === 'string') {
|
|
||||||
updateData.category = data.category.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('categoryId' in data) {
|
if ('categoryId' in data) {
|
||||||
updateData.categoryId = data.categoryId ?? null;
|
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 { memoryStorage } from 'multer';
|
||||||
import { ReceiptImportService } from './receipt-import.service';
|
import { ReceiptImportService } from './receipt-import.service';
|
||||||
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
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 { CreateUnitMappingDto } from './dto/create-unit-mapping.dto';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
@@ -87,4 +89,31 @@ export class ReceiptImportController {
|
|||||||
dto.preferredUnit,
|
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,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
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 { AiService, CategorySuggestion } from '../ai/ai.service';
|
||||||
import { CategoriesService } from '../categories/categories.service';
|
import { CategoriesService } from '../categories/categories.service';
|
||||||
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
|
|
||||||
const IMPORTER_SERVICE_URL =
|
const IMPORTER_SERVICE_URL =
|
||||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
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[]> {
|
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(
|
form.append(
|
||||||
|
|||||||
@@ -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<Map<String, dynamic>> saveReceipt({
|
||||||
|
required List<Map<String, dynamic>> 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<String, dynamic>;
|
||||||
|
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].
|
/// Helper method to map HTTP status codes to [ApiErrorType].
|
||||||
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
|
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
|
||||||
if (statusCode == 401) return ApiErrorType.unauthorized;
|
if (statusCode == 401) return ApiErrorType.unauthorized;
|
||||||
|
|||||||
@@ -473,107 +473,74 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
int created = 0;
|
|
||||||
int merged = 0;
|
|
||||||
int pantryAdded = 0;
|
|
||||||
int pantrySkipped = 0;
|
|
||||||
int aliasesLearned = 0;
|
|
||||||
int unitMappingsLearned = 0;
|
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final repo = ref.read(importRepositoryProvider);
|
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);
|
final canManageAliases = ref.read(isAdminProvider);
|
||||||
|
|
||||||
|
// Bygg upp items för saveReceipt endpoint
|
||||||
|
final saveItems = <Map<String, dynamic>>[];
|
||||||
for (final i in toAdd) {
|
for (final i in toAdd) {
|
||||||
final edit = _edits[i]!;
|
final edit = _edits[i]!;
|
||||||
final item = items[i];
|
final item = items[i];
|
||||||
final pid = edit.productId!;
|
final pid = edit.productId!;
|
||||||
|
|
||||||
if (edit.destination == _Destination.pantry) {
|
final saveItem = <String, dynamic>{
|
||||||
if (_pantryProductIds.contains(pid)) {
|
'rawName': item.rawName,
|
||||||
pantrySkipped++;
|
'quantity': edit.quantity ?? item.quantity ?? 0,
|
||||||
} else {
|
'unit': (edit.unit ?? item.unit ?? 'st').trim(),
|
||||||
await pantryRepo.createPantryItem(pid, token: token);
|
'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
||||||
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,
|
'productId': pid,
|
||||||
'quantity': qty,
|
};
|
||||||
'unit': unit,
|
|
||||||
if (item.brand != null) 'brand': item.brand,
|
// Lägg till optional fält
|
||||||
}, token: token);
|
if (item.price != null) saveItem['price'] = item.price;
|
||||||
created++;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalUnit.isNotEmpty && preferredUnitForLearning.isNotEmpty) {
|
// Lär in alias om den inte redan matchades via alias
|
||||||
try {
|
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||||
await repo.upsertUnitMapping(
|
if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||||
productId: pid,
|
saveItem['learnAlias'] = true;
|
||||||
originalUnit: originalUnit,
|
}
|
||||||
preferredUnit: preferredUnitForLearning,
|
|
||||||
|
// 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,
|
token: token,
|
||||||
);
|
);
|
||||||
if (originalUnit.toLowerCase().trim() != preferredUnitForLearning.toLowerCase().trim()) {
|
|
||||||
unitMappingsLearned++;
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('ReceiptImportTab unit mapping upsert failed: $e');
|
|
||||||
debugPrintStack(stackTrace: st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
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 (!mounted) return;
|
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 = <String>[
|
final parts = <String>[
|
||||||
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||||
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
||||||
@@ -582,9 +549,25 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
||||||
if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda',
|
if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (errors.isNotEmpty) {
|
||||||
|
final errorParts = <String>[];
|
||||||
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(parts.join(', ') + '.')),
|
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Avmarkera sparade rader och uppdatera inventariet
|
// Avmarkera sparade rader och uppdatera inventariet
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
notifier.setSelectedForIndexes(toAdd, false);
|
notifier.setSelectedForIndexes(toAdd, false);
|
||||||
|
|||||||
Reference in New Issue
Block a user