Files
formulation/src/lib/costing.ts
T
nicholasandnicholas 2a1e16ed30
Build & Deploy Formulation / Build & Push Image (push) Failing after 15s
Build & Deploy Formulation / deploy (push) Skipped
add core features (#14)
Reviewed-on: #14
Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
2026-08-18 18:22:34 -05:00

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,
};
}