impl/typescript.ts
4,153 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { convertUnits } from "./units_convert.ts"; ← from units.convert ^1.0.0 · built alongside by fune
import { type RecipeCost, type RecipeCostLine, type RecipeIngredient } from "./hospitality_recipe_cost_types.ts";
const DECIMAL = /^[0-9]+(\.[0-9]+)?$/;
const I128_MAX = (1n << 127n) - 1n;
// Quantities are carried to 9 decimal places of the pack unit: a microgram of
// a kilogram pack, far below anything a kitchen weighs.
const QUANTITY_DECIMALS = 9;
function parseDecimal(text: string): [bigint, number] {
const [whole, fraction = ""] = text.split(".");
return [BigInt(whole + fraction), fraction.length];
}
function lineCost(ingredient: RecipeIngredient): number {
const { name, quantity, unit, yieldBasisPoints, packSize, packUnit, packPrice } = ingredient;
if (typeof quantity !== "string" || !DECIMAL.test(quantity)) {
throw new RangeError(`quantity must be a non-negative decimal like "12.5", received "${quantity}" for "${name}"`);
}
const [packDigits, packScale] = DECIMAL.test(packSize) ? parseDecimal(packSize) : [0n, 0];
if (!DECIMAL.test(packSize) || packDigits === 0n || packScale > 6 || packDigits.toString().length > 15) {
throw new RangeError(`packSize must be a positive decimal with at most 6 decimal places and 15 digits, received "${packSize}" for "${name}"`);
}
if (!Number.isInteger(yieldBasisPoints) || yieldBasisPoints < 1 || yieldBasisPoints > 10000) {
throw new RangeError(`yieldBasisPoints must be from 1 to 10000, received ${yieldBasisPoints} for "${name}"`);
}
if (packPrice.minor < 0) throw new RangeError(`packPrice must not be negative, received ${packPrice.minor} for "${name}"`);
let inPackUnits: string;
if (unit === "each" || packUnit === "each") {
if (unit !== packUnit) throw new RangeError(`cannot convert ${unit} to ${packUnit}: "each" only converts to "each"`);
if (parseDecimal(quantity)[1] > QUANTITY_DECIMALS) {
throw new RangeError(`quantity may have at most ${QUANTITY_DECIMALS} decimal places, received "${quantity}" for "${name}"`);
}
inPackUnits = quantity;
} else {
inPackUnits = convertUnits(quantity, unit, packUnit, QUANTITY_DECIMALS);
}
// cost = price x (quantity / packSize) / yield, in one exact division.
const [qtyDigits, qtyScale] = parseDecimal(inPackUnits);
let numerator = BigInt(packPrice.minor) * qtyDigits * 10000n;
let denominator = packDigits * BigInt(yieldBasisPoints);
if (packScale >= qtyScale) numerator *= 10n ** BigInt(packScale - qtyScale);
else denominator *= 10n ** BigInt(qtyScale - packScale);
if (numerator > I128_MAX || denominator > I128_MAX) {
throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
}
const rounded = (numerator * 2n + denominator) / (denominator * 2n);
if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new RangeError(`the cost of "${name}" is too large to compute exactly`);
}
return Number(rounded);
}
/**
* The cost of a recipe, each ingredient and each portion.
*
* Each ingredient's quantity is converted to the unit its pack is sold in
* (250 g of a 1.5 kg bag), grossed up for trim and waste (400 g of peeled
* carrots needs 500 g bought at an 80% yield), and priced as that share of the
* pack, rounded half-up to the minor unit. The total is the sum of those line
* costs, so the costing sheet adds up.
*/
export function recipeCost(ingredients: readonly RecipeIngredient[], portions: number, currency: string): RecipeCost {
if (ingredients.length === 0) throw new RangeError("a recipe needs at least one ingredient");
if (!Number.isInteger(portions) || portions < 1) throw new RangeError(`portions must be at least 1, received ${portions}`);
const lines: RecipeCostLine[] = ingredients.map((ingredient) => ({
name: ingredient.name,
cost: money(lineCost(ingredient), ingredient.packPrice.currency),
}));
const total = sumMoney(
lines.map((line) => line.cost),
currency,
);
return { lines, total, portions, perPortion: money(roundDiv(total.minor, portions, "half-up"), currency) };
}