Reviewed-on: #14 Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
170 lines
7.9 KiB
TypeScript
170 lines
7.9 KiB
TypeScript
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types.ts";
|
|
import { convertWithIngredientMeasures } from "./measurement.ts";
|
|
|
|
export type CostResult = {
|
|
batch?: number;
|
|
perServing?: number;
|
|
per100g?: number;
|
|
currency: string;
|
|
inputWeightG: number;
|
|
pricedWeightG: number;
|
|
completeness: number;
|
|
warnings: string[];
|
|
lines: CostLine[];
|
|
};
|
|
|
|
export type CostLine = {
|
|
id: string;
|
|
subjectId: string;
|
|
name: string;
|
|
kind: "ingredient" | "recipe";
|
|
cost?: number;
|
|
weightG?: number;
|
|
completeness: number;
|
|
purchase?: {
|
|
id: string;
|
|
name: string;
|
|
packageQuantity: number;
|
|
packageUnitId: string;
|
|
price: number;
|
|
currency: string;
|
|
effectiveAt: string;
|
|
supplier?: string;
|
|
sku?: string;
|
|
};
|
|
children?: CostLine[];
|
|
};
|
|
|
|
type Catalogs = {
|
|
recipes: Map<string, Recipe>;
|
|
ingredients: Map<string, Ingredient>;
|
|
units: Map<string, Unit>;
|
|
purchaseItems: Map<string, PurchaseItem>;
|
|
prepActions?: Map<string, PrepAction>;
|
|
};
|
|
|
|
function prepYieldFactor(item: RecipeItem, catalogs: Catalogs): number {
|
|
return (item.prep ?? []).reduce((factor, prep) => factor * (prep.yield_factor ?? catalogs.prepActions?.get(prep.action_id)?.default_yield_factor ?? 1), 1);
|
|
}
|
|
|
|
function grams(quantity: number, unitId: string, subject: Ingredient, units: Map<string, Unit>): number {
|
|
return convertWithIngredientMeasures({ quantity, unit_id: unitId }, "gram", subject, units).quantity;
|
|
}
|
|
|
|
function latestPrice(item: PurchaseItem, currency: string) {
|
|
return item.prices
|
|
.filter((price) => price.currency === currency)
|
|
.sort((a, b) => b.effective_at.localeCompare(a.effective_at))[0];
|
|
}
|
|
|
|
function usableCostPerGram(ingredient: Ingredient, item: PurchaseItem, currency: string, units: Map<string, Unit>): number | undefined {
|
|
const price = latestPrice(item, currency);
|
|
if (!price) return undefined;
|
|
try {
|
|
const packageWeightG = grams(item.package.quantity, item.package.unit_id, ingredient, units) * (item.package.units_per_case ?? 1);
|
|
const usableWeightG = packageWeightG * (item.package.usable_yield_factor ?? 1);
|
|
return usableWeightG > 0 ? price.amount / usableWeightG : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function ingredientCostSource(ingredient: Ingredient, catalogs: Catalogs, currency: string) {
|
|
return [...catalogs.purchaseItems.values()]
|
|
.filter((item) => item.ingredient_id === ingredient.id && item.status === "active")
|
|
.map((item) => ({ item, rate: usableCostPerGram(ingredient, item, currency, catalogs.units), price: latestPrice(item, currency) }))
|
|
.filter((entry): entry is typeof entry & { rate: number; price: NonNullable<typeof entry.price> } => entry.rate != null && entry.price != null)
|
|
.sort((a, b) => a.rate - b.rate)[0];
|
|
}
|
|
|
|
export function calculateCost(recipe: Recipe, catalogs: Catalogs, currency = "USD", stack: string[] = []): CostResult {
|
|
if (stack.includes(recipe.id)) throw new Error(`Circular sub-recipe reference: ${[...stack, recipe.id].join(" -> ")}`);
|
|
let batch = 0;
|
|
let inputWeightG = 0;
|
|
let pricedWeightG = 0;
|
|
const warnings: string[] = [];
|
|
const lines: CostLine[] = [];
|
|
|
|
for (const item of recipe.components.flatMap((component) => component.items)) {
|
|
if (item.optional) continue;
|
|
if ("ingredient_id" in item.reference) {
|
|
const ingredient = catalogs.ingredients.get(item.reference.ingredient_id);
|
|
if (!ingredient) throw new Error(`Unknown ingredient: ${item.reference.ingredient_id}`);
|
|
let usableWeightG: number;
|
|
try {
|
|
usableWeightG = grams(item.amount.quantity, item.amount.unit_id, ingredient, catalogs.units);
|
|
} catch (error) {
|
|
warnings.push(`${ingredient.name}: ${(error as Error).message}`);
|
|
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", completeness:0 });
|
|
continue;
|
|
}
|
|
inputWeightG += usableWeightG;
|
|
const source = ingredientCostSource(ingredient, catalogs, currency);
|
|
if (!source) {
|
|
warnings.push(`${ingredient.name}: no active ${currency} purchase price with a convertible package size`);
|
|
lines.push({ id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", weightG:usableWeightG, completeness:0 });
|
|
continue;
|
|
}
|
|
const lineCost=(usableWeightG / prepYieldFactor(item, catalogs)) * source.rate;
|
|
batch += lineCost;
|
|
pricedWeightG += usableWeightG;
|
|
lines.push({
|
|
id:item.id, subjectId:ingredient.id, name:ingredient.name, kind:"ingredient", cost:lineCost, weightG:usableWeightG, completeness:1,
|
|
purchase:{ id:source.item.id, name:source.item.name, packageQuantity:source.item.package.quantity, packageUnitId:source.item.package.unit_id, price:source.price.amount, currency:source.price.currency, effectiveAt:source.price.effective_at, supplier:source.item.supplier_id, sku:source.item.supplier_sku },
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const child = catalogs.recipes.get(item.reference.recipe_id);
|
|
if (!child) throw new Error(`Unknown sub-recipe: ${item.reference.recipe_id}`);
|
|
if (item.reference.component_id) {
|
|
warnings.push(`${child.title}: component-specific costing is not available for ${item.reference.component_id}`);
|
|
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0 });
|
|
continue;
|
|
}
|
|
const childResult = calculateCost(child, catalogs, currency, [...stack, recipe.id]);
|
|
let usedWeightG: number;
|
|
try {
|
|
usedWeightG = convertWithIngredientMeasures(item.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity;
|
|
} catch (error) {
|
|
warnings.push(`${child.title}: ${(error as Error).message}`);
|
|
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", completeness:0, children:childResult.lines });
|
|
continue;
|
|
}
|
|
inputWeightG += usedWeightG;
|
|
const childYieldG = childResult.inputWeightG > 0
|
|
? (() => { try { return convertWithIngredientMeasures(child.yield.amount, "gram", { id: child.id, name: child.title, schema_version: 2, status: "active", categories: [],measure_conversions:child.measure_conversions }, catalogs.units).quantity; } catch { return undefined; } })()
|
|
: undefined;
|
|
if (!childYieldG || childResult.batch == null) {
|
|
warnings.push(`${child.title}: sub-recipe cost requires a positive mass yield and at least one priced input`);
|
|
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", weightG:usedWeightG, completeness:0, children:childResult.lines });
|
|
continue;
|
|
}
|
|
const factor = usedWeightG / childYieldG;
|
|
const lineCost=childResult.batch * factor / prepYieldFactor(item, catalogs);
|
|
batch += lineCost;
|
|
pricedWeightG += usedWeightG * childResult.completeness;
|
|
lines.push({ id:item.id, subjectId:child.id, name:child.title, kind:"recipe", cost:lineCost, weightG:usedWeightG, completeness:childResult.completeness, children:childResult.lines });
|
|
warnings.push(...childResult.warnings.map((warning) => `${child.title}: ${warning}`));
|
|
}
|
|
|
|
let yieldWeightG: number | undefined;
|
|
try {
|
|
yieldWeightG = convertWithIngredientMeasures(recipe.yield.amount, "gram", { id: recipe.id, name: recipe.title, schema_version: 2, status: "active", categories: [],measure_conversions:recipe.measure_conversions }, catalogs.units).quantity;
|
|
} catch {
|
|
warnings.push("Recipe yield is not expressed as a mass; per-100 g cost unavailable");
|
|
}
|
|
const hasCost = pricedWeightG > 0;
|
|
return {
|
|
...(hasCost ? { batch } : {}),
|
|
...(hasCost && recipe.yield.servings ? { perServing: batch / recipe.yield.servings } : {}),
|
|
...(hasCost && yieldWeightG && yieldWeightG > 0 ? { per100g: batch * 100 / yieldWeightG } : {}),
|
|
currency,
|
|
inputWeightG,
|
|
pricedWeightG,
|
|
completeness: inputWeightG > 0 ? pricedWeightG / inputWeightG : 0,
|
|
warnings: [...new Set(warnings)],
|
|
lines,
|
|
};
|
|
}
|