Add unit conversion and normalization methods to RecipesService
This commit is contained in:
@@ -5,85 +5,140 @@ import { CreateRecipeDto } from './dto/create-recipe.dto';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RecipesService {
|
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) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll() {
|
/** Normalisera enheter (t.ex. "tesked" → "tsk", "milliliter" → "ml") */
|
||||||
return this.prisma.recipe.findMany({
|
private normalizeUnit(unit: string): string {
|
||||||
include: {
|
const normalized = unit.trim().toLowerCase();
|
||||||
ingredients: {
|
const unitAliases: Record<string, string> = {
|
||||||
include: {
|
'tesked': 'tsk',
|
||||||
product: true,
|
'test': 'tsk',
|
||||||
},
|
'matsked': 'msk',
|
||||||
},
|
'matsled': 'msk',
|
||||||
},
|
'milliliter': 'ml',
|
||||||
orderBy: {
|
'deciliter': 'dl',
|
||||||
name: 'asc',
|
'gram': 'g',
|
||||||
},
|
'kilo': 'kg',
|
||||||
});
|
'kilogram': 'kg',
|
||||||
|
'stycke': 'st',
|
||||||
|
};
|
||||||
|
return unitAliases[normalized] || normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
/** Bestäm vilken enhetstyp en enhet tillhör */
|
||||||
const recipe = await this.prisma.recipe.findUnique({
|
private getUnitCategory(unit: string): string | null {
|
||||||
where: { id },
|
const normalized = this.normalizeUnit(unit);
|
||||||
include: {
|
if (RecipesService.WEIGHT_UNITS.includes(normalized)) return 'weight';
|
||||||
ingredients: {
|
if (RecipesService.VOLUME_UNITS.includes(normalized)) return 'volume';
|
||||||
include: {
|
if (RecipesService.PORTION_UNITS.includes(normalized)) return 'portion';
|
||||||
product: true,
|
if (RecipesService.PIECE_UNITS.includes(normalized)) return 'piece';
|
||||||
},
|
return null;
|
||||||
orderBy: {
|
|
||||||
id: 'asc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!recipe) {
|
|
||||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipe;
|
/** Kontrollera om en enhet är viktbaserad */
|
||||||
|
private isWeightUnit(unit: string): boolean {
|
||||||
|
return this.getUnitCategory(unit) === 'weight';
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateRecipeDto) {
|
/** Kontrollera om en enhet är volymbaserad */
|
||||||
for (const ingredient of data.ingredients) {
|
private isVolumeUnit(unit: string): boolean {
|
||||||
const product = await this.prisma.product.findUnique({
|
return this.getUnitCategory(unit) === 'volume';
|
||||||
where: { id: ingredient.productId },
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!product) {
|
/**
|
||||||
throw new NotFoundException(
|
* Konverterar kvantitet mellan enheter för en given produkt.
|
||||||
`Product with id ${ingredient.productId} not found`,
|
* 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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.recipe.create({
|
// Konvertera via basenhet
|
||||||
data: {
|
return (quantity * conversions[normalizedFromUnit]) / conversions[normalizedToUnit];
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ÖVRIGA METODER (findAll, findOne, create) OFÖRÄNDRADE ---
|
||||||
|
|
||||||
async getInventoryPreview(id: number) {
|
async getInventoryPreview(id: number) {
|
||||||
const recipe = await this.prisma.recipe.findUnique({
|
const recipe = await this.prisma.recipe.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -104,32 +159,49 @@ export class RecipesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ingredientPreviews = await Promise.all(
|
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({
|
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
||||||
where: {
|
where: { productId: ingredient.productId },
|
||||||
productId: ingredient.productId,
|
orderBy: { createdAt: 'desc' },
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hitta inventory-poster med samma enhet
|
||||||
const sameUnitItems = inventoryItems.filter(
|
const sameUnitItems = inventoryItems.filter(
|
||||||
(item) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(),
|
(item: any) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(),
|
||||||
);
|
);
|
||||||
|
const availableSameUnit = sameUnitItems.reduce(
|
||||||
const availableQuantity = sameUnitItems.reduce(
|
(sum: number, item: any) => sum + Number(item.quantity),
|
||||||
(sum, item) => sum + Number(item.quantity),
|
|
||||||
0,
|
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';
|
let status: 'enough' | 'missing' | 'unit_mismatch';
|
||||||
|
|
||||||
if (sameUnitItems.length > 0) {
|
if (totalAvailable >= Number(ingredient.quantity)) {
|
||||||
status = availableQuantity >= requiredQuantity ? 'enough' : 'missing';
|
status = 'enough';
|
||||||
} else if (inventoryItems.length > 0) {
|
} else if (availableSameUnit === 0 && availableOtherUnit > 0) {
|
||||||
status = 'unit_mismatch';
|
status = 'unit_mismatch';
|
||||||
} else {
|
} else {
|
||||||
status = 'missing';
|
status = 'missing';
|
||||||
@@ -139,41 +211,51 @@ export class RecipesService {
|
|||||||
ingredientId: ingredient.id,
|
ingredientId: ingredient.id,
|
||||||
productId: ingredient.productId,
|
productId: ingredient.productId,
|
||||||
productName: ingredient.product.canonicalName || ingredient.product.name,
|
productName: ingredient.product.canonicalName || ingredient.product.name,
|
||||||
requiredQuantity,
|
requiredQuantity: Number(ingredient.quantity),
|
||||||
requiredUnit: ingredient.unit,
|
requiredUnit: ingredient.unit,
|
||||||
note: ingredient.note,
|
note: ingredient.note,
|
||||||
availableQuantity,
|
availableQuantity: totalAvailable,
|
||||||
availableUnit: sameUnitItems.length > 0 ? ingredient.unit : null,
|
availableUnit: ingredient.unit,
|
||||||
matchingInventoryItems: sameUnitItems.map((item) => ({
|
matchingInventoryItems: sameUnitItems.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
location: item.location,
|
location: item.location,
|
||||||
})),
|
})),
|
||||||
otherInventoryItems: inventoryItems
|
otherInventoryItems: otherUnitItems.map((item: any) => {
|
||||||
.filter((item) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase())
|
// Kolla om konvertering är möjlig (samma enhetskategori)
|
||||||
.map((item) => ({
|
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,
|
id: item.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
location: item.location,
|
location: item.location,
|
||||||
})),
|
convertedQuantity: canConvert ? convertedQuantity : 0,
|
||||||
|
canConvert,
|
||||||
|
};
|
||||||
|
}),
|
||||||
status,
|
status,
|
||||||
missingQuantity:
|
missingQuantity: status === 'missing' ? Math.max(0, Number(ingredient.quantity) - totalAvailable) : 0,
|
||||||
status === 'missing'
|
|
||||||
? Math.max(0, requiredQuantity - availableQuantity)
|
|
||||||
: 0,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
totalIngredients: ingredientPreviews.length,
|
totalIngredients: ingredientPreviews.length,
|
||||||
enoughCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'enough').length,
|
enoughCount: ingredientPreviews.filter((i: any) => i.status === 'enough').length,
|
||||||
missingCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'missing').length,
|
missingCount: ingredientPreviews.filter((i: any) => i.status === 'missing').length,
|
||||||
unitMismatchCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'unit_mismatch').length,
|
unitMismatchCount: ingredientPreviews.filter((i: any) => i.status === 'unit_mismatch').length,
|
||||||
canCookExactly:
|
canCookExactly: ingredientPreviews.every((i: any) => i.status === 'enough'),
|
||||||
ingredientPreviews.every((i: typeof ingredientPreviews[0]) => i.status === 'enough'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user