use super::funejson::Value; use super::math_round_div::round_div; use super::money_amount::{money, money_from_value, money_to_value, Money}; use super::money_sum::sum_money; use super::units_convert::convert_units; // 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: i64 = 9; fn is_decimal(text: &str) -> bool { let mut parts = text.splitn(2, '.'); let whole = parts.next().unwrap_or(""); let ok_whole = !whole.is_empty() && whole.bytes().all(|b| b.is_ascii_digit()); match parts.next() { None => ok_whole, Some(f) => ok_whole && !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()), } } /// Digits and scale of plain decimal text; None if the digits overflow. fn parse_decimal(text: &str) -> Option<(i128, u32, usize)> { let (whole, fraction) = match text.split_once('.') { Some((w, f)) => (w, f), None => (text, ""), }; let digits = format!("{}{}", whole, fraction); let trimmed = digits.trim_start_matches('0'); let significant = if trimmed.is_empty() { 1 } else { trimmed.len() }; if significant > 36 { return None; } Some((digits.parse::().ok()?, fraction.len() as u32, significant)) } fn pow10(n: u32) -> Option { 10i128.checked_pow(n) } fn line_cost(ingredient: &RecipeIngredient) -> i64 { let name = &ingredient.name; let quantity = ingredient.quantity.as_str(); if !is_decimal(quantity) { panic!( "quantity must be a non-negative decimal like \"12.5\", received \"{}\" for \"{}\"", quantity, name ); } let pack = if is_decimal(&ingredient.pack_size) { parse_decimal(&ingredient.pack_size) } else { None }; let (pack_digits, pack_scale) = match pack { Some((d, s, sig)) if d != 0 && s <= 6 && sig <= 15 => (d, s), _ => panic!( "packSize must be a positive decimal with at most 6 decimal places and 15 digits, received \"{}\" for \"{}\"", ingredient.pack_size, name ), }; let y = ingredient.yield_basis_points; if !(1..=10000).contains(&y) { panic!("yieldBasisPoints must be from 1 to 10000, received {} for \"{}\"", y, name); } let price = &ingredient.pack_price; if price.minor < 0 { panic!("packPrice must not be negative, received {} for \"{}\"", price.minor, name); } let unit = ingredient.unit.as_str(); let pack_unit = ingredient.pack_unit.as_str(); let in_pack_units = if unit == "each" || pack_unit == "each" { if unit != pack_unit { panic!("cannot convert {} to {}: \"each\" only converts to \"each\"", unit, pack_unit); } let scale = quantity.split_once('.').map_or(0, |(_, f)| f.len()) as i64; if scale > QUANTITY_DECIMALS { panic!( "quantity may have at most {} decimal places, received \"{}\" for \"{}\"", QUANTITY_DECIMALS, quantity, name ); } quantity.to_string() } else { convert_units(quantity, unit, pack_unit, QUANTITY_DECIMALS) }; // cost = price x (quantity / packSize) / yield, in one exact division. let too_large = || -> ! { panic!("the cost of \"{}\" is too large to compute exactly", name) }; let (qty_digits, qty_scale, _) = parse_decimal(&in_pack_units).unwrap_or_else(|| too_large()); let mut numerator = (price.minor as i128) .checked_mul(qty_digits) .and_then(|n| n.checked_mul(10000)) .unwrap_or_else(|| too_large()); let mut denominator = pack_digits.checked_mul(y as i128).unwrap_or_else(|| too_large()); if pack_scale >= qty_scale { numerator = pow10(pack_scale - qty_scale) .and_then(|p| numerator.checked_mul(p)) .unwrap_or_else(|| too_large()); } else { denominator = pow10(qty_scale - pack_scale) .and_then(|p| denominator.checked_mul(p)) .unwrap_or_else(|| too_large()); } // Half-up on non-negative values: floor((2n + d) / 2d), without the // doubling that could overflow near the i128 limit. let quotient = numerator / denominator; let remainder = numerator % denominator; let rounded = if remainder >= denominator - remainder { quotient + 1 } else { quotient }; // Money must survive a JavaScript number in the TypeScript port. if rounded > (1i128 << 53) - 1 { too_large(); } rounded as i64 } /// 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. /// /// # Panics /// Panics on an empty recipe, bad portions, a malformed ingredient, units that /// do not convert, or mixed currencies. pub fn recipe_cost(ingredients: &[RecipeIngredient], portions: i64, currency: &str) -> RecipeCost { if ingredients.is_empty() { panic!("a recipe needs at least one ingredient"); } if portions < 1 { panic!("portions must be at least 1, received {}", portions); } let lines: Vec = ingredients .iter() .map(|i| RecipeCostLine { name: i.name.clone(), cost: money(line_cost(i), &i.pack_price.currency) }) .collect(); let costs: Vec = lines.iter().map(|l| l.cost.clone()).collect(); let total = sum_money(&costs, currency); let per_portion = money(round_div(total.minor, portions, "half-up"), currency); RecipeCost { lines, total, portions, per_portion } } pub fn recipe_ingredient_from_value(v: &Value) -> RecipeIngredient { RecipeIngredient { name: v.get("name").as_str().to_string(), quantity: v.get("quantity").as_str().to_string(), unit: v.get("unit").as_str().to_string(), yield_basis_points: v.get("yieldBasisPoints").as_i64(), pack_size: v.get("packSize").as_str().to_string(), pack_unit: v.get("packUnit").as_str().to_string(), pack_price: money_from_value(v.get("packPrice")), } } pub fn recipe_cost_to_value(c: &RecipeCost) -> Value { Value::obj(vec![ ( "lines", Value::Arr( c.lines .iter() .map(|l| Value::obj(vec![("name", Value::str(&l.name)), ("cost", money_to_value(&l.cost))])) .collect(), ), ), ("total", money_to_value(&c.total)), ("portions", Value::Int(c.portions)), ("perPortion", money_to_value(&c.per_portion)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let ingredients: Vec = args[0].as_arr().iter().map(recipe_ingredient_from_value).collect(); recipe_cost_to_value(&recipe_cost(&ingredients, args[1].as_i64(), args[2].as_str())) }