Files
recipe-app/backend/src/recipes/recipes.service.ts
T

273 lines
9.3 KiB
TypeScript

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<string, number> = {
'g': 1,
'kg': 1000,
};
private static readonly VOLUME_CONVERSIONS: Record<string, number> = {
'ml': 1,
'dl': 100,
};
private static readonly PORTION_CONVERSIONS: Record<string, number> = {
'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<string, string> = {
'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<string, number>;
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,
brand: item.brand || null,
bestBeforeDate: item.bestBeforeDate || null,
})),
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,
};
}
}