From 4fd3c8dc207e74aefef71495437c7087a2425a96 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 9 Apr 2026 17:17:08 +0200 Subject: [PATCH] Add unit conversion and normalization methods to RecipesService --- backend/src/recipes/recipes.service.ts | 280 ++++++++++++++++--------- 1 file changed, 181 insertions(+), 99 deletions(-) diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 2e28cf03..c8d073c5 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -5,85 +5,140 @@ 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) {} - async findAll() { - return this.prisma.recipe.findMany({ - include: { - ingredients: { - include: { - product: true, - }, - }, - }, - orderBy: { - name: 'asc', - }, - }); + /** 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; } - async findOne(id: number) { - const recipe = await this.prisma.recipe.findUnique({ - where: { id }, - include: { - ingredients: { - include: { - product: true, - }, - orderBy: { - id: 'asc', - }, - }, - }, - }); + /** 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; + } - if (!recipe) { - throw new NotFoundException(`Recipe with id ${id} not found`); + /** 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.'); } - return recipe; - } + // Normalisera och kontrollera enheter + const normalizedFromUnit = this.normalizeUnit(fromUnit); + const normalizedToUnit = this.normalizeUnit(toUnit); - async create(data: CreateRecipeDto) { - for (const ingredient of data.ingredients) { - const product = await this.prisma.product.findUnique({ - where: { id: ingredient.productId }, - }); - - if (!product) { - throw new NotFoundException( - `Product with id ${ingredient.productId} not found`, - ); - } + // Om enheterna är identiska efter normalisering, returnera direkt + if (normalizedFromUnit === normalizedToUnit) { + return quantity; } - return this.prisma.recipe.create({ - data: { - name: data.name.trim(), - description: data.description?.trim() || null, - instructions: data.instructions?.trim() || null, - ingredients: { - create: data.ingredients.map((ingredient) => ({ - productId: ingredient.productId, - quantity: new Prisma.Decimal(ingredient.quantity), - unit: ingredient.unit.trim(), - note: ingredient.note?.trim() || null, - })), - }, - }, - include: { - ingredients: { - include: { - product: true, - }, - orderBy: { - id: 'asc', - }, - }, - }, - }); + // 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 }, @@ -104,32 +159,49 @@ export class RecipesService { } const ingredientPreviews = await Promise.all( - recipe.ingredients.map(async (ingredient: typeof recipe.ingredients[0]) => { + recipe.ingredients.map(async (ingredient: any) => { const inventoryItems = await this.prisma.inventoryItem.findMany({ - where: { - productId: ingredient.productId, - }, - orderBy: { - createdAt: 'desc', - }, + where: { productId: ingredient.productId }, + orderBy: { createdAt: 'desc' }, }); + // Hitta inventory-poster med samma enhet const sameUnitItems = inventoryItems.filter( - (item) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(), + (item: any) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(), ); - - const availableQuantity = sameUnitItems.reduce( - (sum, item) => sum + Number(item.quantity), + const availableSameUnit = sameUnitItems.reduce( + (sum: number, item: any) => sum + Number(item.quantity), 0, ); - const requiredQuantity = Number(ingredient.quantity); + // 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 (sameUnitItems.length > 0) { - status = availableQuantity >= requiredQuantity ? 'enough' : 'missing'; - } else if (inventoryItems.length > 0) { + if (totalAvailable >= Number(ingredient.quantity)) { + status = 'enough'; + } else if (availableSameUnit === 0 && availableOtherUnit > 0) { status = 'unit_mismatch'; } else { status = 'missing'; @@ -139,41 +211,51 @@ export class RecipesService { ingredientId: ingredient.id, productId: ingredient.productId, productName: ingredient.product.canonicalName || ingredient.product.name, - requiredQuantity, + requiredQuantity: Number(ingredient.quantity), requiredUnit: ingredient.unit, note: ingredient.note, - availableQuantity, - availableUnit: sameUnitItems.length > 0 ? ingredient.unit : null, - matchingInventoryItems: sameUnitItems.map((item) => ({ + availableQuantity: totalAvailable, + availableUnit: ingredient.unit, + matchingInventoryItems: sameUnitItems.map((item: any) => ({ id: item.id, quantity: item.quantity, unit: item.unit, location: item.location, })), - otherInventoryItems: inventoryItems - .filter((item) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase()) - .map((item) => ({ + 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, requiredQuantity - availableQuantity) - : 0, + missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0, }; }), ); const summary = { totalIngredients: ingredientPreviews.length, - enoughCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'enough').length, - missingCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'missing').length, - unitMismatchCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'unit_mismatch').length, - canCookExactly: - ingredientPreviews.every((i: typeof ingredientPreviews[0]) => i.status === 'enough'), + 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 {