import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; @Injectable() export class RecipesService { // Enhetsklassificering private static readonly WEIGHT_UNITS = ['g', 'kg']; private static readonly VOLUME_UNITS = ['ml', 'dl']; private static readonly PORTION_UNITS = ['tsk', 'msk']; // tesked, matsked private static readonly PIECE_UNITS = ['st']; // stycken // Konverteringsregler för varje enhetstyp private static readonly WEIGHT_CONVERSIONS: Record = { 'g': 1, 'kg': 1000, }; private static readonly VOLUME_CONVERSIONS: Record = { 'ml': 1, 'dl': 100, }; private static readonly PORTION_CONVERSIONS: Record = { 'tsk': 1, 'msk': 3, // 1 matsked ≈ 3 teskedar }; constructor(private readonly prisma: PrismaService) {} /** Normalisera enheter (t.ex. "tesked" → "tsk", "milliliter" → "ml") */ private normalizeUnit(unit: string): string { const normalized = unit.trim().toLowerCase(); const unitAliases: Record = { 'tesked': 'tsk', 'test': 'tsk', 'matsked': 'msk', 'matsled': 'msk', 'milliliter': 'ml', 'deciliter': 'dl', 'gram': 'g', 'kilo': 'kg', 'kilogram': 'kg', 'stycke': 'st', }; return unitAliases[normalized] || normalized; } /** Bestäm vilken enhetstyp en enhet tillhör */ private getUnitCategory(unit: string): string | null { const normalized = this.normalizeUnit(unit); if (RecipesService.WEIGHT_UNITS.includes(normalized)) return 'weight'; if (RecipesService.VOLUME_UNITS.includes(normalized)) return 'volume'; if (RecipesService.PORTION_UNITS.includes(normalized)) return 'portion'; if (RecipesService.PIECE_UNITS.includes(normalized)) return 'piece'; return null; } /** Kontrollera om en enhet är viktbaserad */ private isWeightUnit(unit: string): boolean { return this.getUnitCategory(unit) === 'weight'; } /** Kontrollera om en enhet är volymbaserad */ private isVolumeUnit(unit: string): boolean { return this.getUnitCategory(unit) === 'volume'; } /** * Konverterar kvantitet mellan enheter för en given produkt. * Stödjer vikt (g/kg), volym (ml/dl) och svenska måttenheter (tsk/msk). * Konverterar endast inom samma enhetstyp. * * @throws Error om quantity är negativ/noll, enheter är tomma, eller enheter är inkompatibla */ private convertUnit(quantity: number, fromUnit: string, toUnit: string, productName: string): number { // Input validation if (quantity <= 0) { throw new Error(`Invalid quantity: ${quantity}. Quantity must be positive.`); } if (!fromUnit?.trim()) { throw new Error('From unit cannot be empty.'); } if (!toUnit?.trim()) { throw new Error('To unit cannot be empty.'); } if (!productName?.trim()) { throw new Error('Product name cannot be empty.'); } // Normalisera och kontrollera enheter const normalizedFromUnit = this.normalizeUnit(fromUnit); const normalizedToUnit = this.normalizeUnit(toUnit); // Om enheterna är identiska efter normalisering, returnera direkt if (normalizedFromUnit === normalizedToUnit) { return quantity; } // Bestäm enhetstyp const fromCategory = this.getUnitCategory(normalizedFromUnit); const toCategory = this.getUnitCategory(normalizedToUnit); if (!fromCategory) { throw new Error(`Unknown unit: "${fromUnit}"`); } if (!toCategory) { throw new Error(`Unknown unit: "${toUnit}"`); } // Konvertera endast inom samma enhetstyp if (fromCategory !== toCategory) { throw new Error( `Cannot convert between incompatible unit types: "${fromUnit}" (${fromCategory}) and "${toUnit}" (${toCategory}) for product "${productName}"`, ); } // Hämta rätt konverteringstabll baserat på enhetstyp let conversions: Record; switch (fromCategory) { case 'weight': conversions = RecipesService.WEIGHT_CONVERSIONS; break; case 'volume': conversions = RecipesService.VOLUME_CONVERSIONS; break; case 'portion': conversions = RecipesService.PORTION_CONVERSIONS; break; case 'piece': // Kan inte konvertera stycken return quantity; default: throw new Error(`Unknown unit category: ${fromCategory}`); } // Konvertera via basenhet return (quantity * conversions[normalizedFromUnit]) / conversions[normalizedToUnit]; } // --- ÖVRIGA METODER (findAll, findOne, create) OFÖRÄNDRADE --- async getInventoryPreview(id: number) { const recipe = await this.prisma.recipe.findUnique({ where: { id }, include: { ingredients: { include: { product: true, }, orderBy: { id: 'asc', }, }, }, }); if (!recipe) { throw new NotFoundException(`Recipe with id ${id} not found`); } const ingredientPreviews = await Promise.all( recipe.ingredients.map(async (ingredient: any) => { const inventoryItems = await this.prisma.inventoryItem.findMany({ where: { productId: ingredient.productId }, orderBy: { createdAt: 'desc' }, }); // Hitta inventory-poster med samma enhet const sameUnitItems = inventoryItems.filter( (item: any) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(), ); const availableSameUnit = sameUnitItems.reduce( (sum: number, item: any) => sum + Number(item.quantity), 0, ); // Hitta inventory-poster med annan enhet och konvertera (endast viktbaserade enheter) const otherUnitItems = inventoryItems.filter( (item: any) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase(), ); let availableOtherUnit = 0; for (const item of otherUnitItems) { // Konvertera endast om enheter är kompatibla (samma kategori) try { const convertedQuantity = this.convertUnit( Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name, ); availableOtherUnit += convertedQuantity; } catch { // Om konvertering misslyckas, hoppa över denna post // (t.ex. st kan inte konverteras till g) } } const totalAvailable = availableSameUnit + availableOtherUnit; let status: 'enough' | 'missing' | 'unit_mismatch'; if (totalAvailable >= Number(ingredient.quantity)) { status = 'enough'; } else if (availableSameUnit === 0 && availableOtherUnit > 0) { status = 'unit_mismatch'; } else { status = 'missing'; } return { ingredientId: ingredient.id, productId: ingredient.productId, productName: ingredient.product.canonicalName || ingredient.product.name, requiredQuantity: Number(ingredient.quantity), requiredUnit: ingredient.unit, note: ingredient.note, availableQuantity: totalAvailable, availableUnit: ingredient.unit, matchingInventoryItems: sameUnitItems.map((item: any) => ({ id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, })), otherInventoryItems: otherUnitItems.map((item: any) => { // Kolla om konvertering är möjlig (samma enhetskategori) const canConvert = this.getUnitCategory(item.unit) === this.getUnitCategory(ingredient.unit) && this.getUnitCategory(ingredient.unit) !== 'piece'; let convertedQuantity = 0; if (canConvert) { try { convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name); } catch { convertedQuantity = 0; } } return { id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, convertedQuantity: canConvert ? convertedQuantity : 0, canConvert, }; }), status, missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0, }; }), ); const summary = { totalIngredients: ingredientPreviews.length, enoughCount: ingredientPreviews.filter((i: any) => i.status === 'enough').length, missingCount: ingredientPreviews.filter((i: any) => i.status === 'missing').length, unitMismatchCount: ingredientPreviews.filter((i: any) => i.status === 'unit_mismatch').length, canCookExactly: ingredientPreviews.every((i: any) => i.status === 'enough'), }; return { recipe: { id: recipe.id, name: recipe.name, description: recipe.description, }, ingredients: ingredientPreviews, summary, }; } }