Functional Weave
Code in Python

hospitality.recipe-cost@1.0.1

impl/rust.rs

7,104 bytes · the Rust 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune
use super::units_convert::convert_units;  ← from units.convert ^1.0.0 · built alongside by fune

// 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::<i128>().ok()?, fraction.len() as u32, significant))
}

fn pow10(n: u32) -> Option<i128> {
    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<RecipeCostLine> = ingredients
        .iter()
        .map(|i| RecipeCostLine { name: i.name.clone(), cost: money(line_cost(i), &i.pack_price.currency) })
        .collect();
    let costs: Vec<Money> = 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<RecipeIngredient> = 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()))
}