feat: implement unit conversion utilities and centralize unit definitions for consistency across frontend and backend
This commit is contained in:
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateRecipeDto } from './dto/create-recipe.dto';
|
||||
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
import { downloadAndOptimizeImage } from '../common/utils/download-image';
|
||||
import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units';
|
||||
|
||||
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
|
||||
|
||||
@@ -24,140 +25,8 @@ interface ParsedRecipe {
|
||||
|
||||
@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 },
|
||||
@@ -202,11 +71,10 @@ export class RecipesService {
|
||||
for (const item of otherUnitItems) {
|
||||
// Konvertera endast om enheter är kompatibla (samma kategori)
|
||||
try {
|
||||
const convertedQuantity = this.convertUnit(
|
||||
const convertedQuantity = convertUnit(
|
||||
Number(item.quantity),
|
||||
item.unit,
|
||||
ingredient.unit,
|
||||
ingredient.product.name,
|
||||
);
|
||||
availableOtherUnit += convertedQuantity;
|
||||
} catch {
|
||||
@@ -245,12 +113,11 @@ export class RecipesService {
|
||||
})),
|
||||
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';
|
||||
const canConvertUnits = canConvert(item.unit, ingredient.unit);
|
||||
let convertedQuantity = 0;
|
||||
if (canConvert) {
|
||||
if (canConvertUnits) {
|
||||
try {
|
||||
convertedQuantity = this.convertUnit(Number(item.quantity), item.unit, ingredient.unit, ingredient.product.name);
|
||||
convertedQuantity = convertUnit(Number(item.quantity), item.unit, ingredient.unit);
|
||||
} catch {
|
||||
convertedQuantity = 0;
|
||||
}
|
||||
@@ -261,8 +128,8 @@ export class RecipesService {
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
location: item.location,
|
||||
convertedQuantity: canConvert ? convertedQuantity : 0,
|
||||
canConvert,
|
||||
convertedQuantity: canConvertUnits ? convertedQuantity : 0,
|
||||
canConvert: canConvertUnits,
|
||||
};
|
||||
}),
|
||||
status,
|
||||
|
||||
Reference in New Issue
Block a user