Functional Weave
Code in TypeScript

hospitality.recipe-cost@1.0.1

impl/python.py

4,432 bytes · the Python 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 re
from typing import List, Sequence, Tuple

from .hospitality_recipe_cost_types import RecipeCost, RecipeCostLine, RecipeIngredient
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_sum import sum_money  ← from money.sum ^1.0.0 · built alongside by fune
from .units_convert import convert_units  ← from units.convert ^1.0.0 · built alongside by fune

_DECIMAL = re.compile(r"[0-9]+(\.[0-9]+)?")
_I128_MAX = (1 << 127) - 1
# Money must survive a JavaScript number in the TypeScript port.
_MAX_SAFE = (1 << 53) - 1
# Quantities are carried to 9 decimal places of the pack unit: a microgram of
# a kilogram pack, far below anything a kitchen weighs.
_QUANTITY_DECIMALS = 9


def _parse_decimal(text: str) -> Tuple[int, int]:
    whole, _, fraction = text.partition(".")
    return int(whole + fraction), len(fraction)


def _whole(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _line_cost(ingredient: RecipeIngredient) -> int:
    name = ingredient.name
    quantity = ingredient.quantity
    if not isinstance(quantity, str) or not _DECIMAL.fullmatch(quantity):
        raise ValueError('quantity must be a non-negative decimal like "12.5", received "%s" for "%s"' % (quantity, name))
    pack_size = ingredient.pack_size
    ok = isinstance(pack_size, str) and _DECIMAL.fullmatch(pack_size) is not None
    pack_digits, pack_scale = _parse_decimal(pack_size) if ok else (0, 0)
    if not ok or pack_digits == 0 or pack_scale > 6 or len(str(pack_digits)) > 15:
        raise ValueError(
            'packSize must be a positive decimal with at most 6 decimal places and 15 digits, received "%s" for "%s"'
            % (pack_size, name)
        )
    y = ingredient.yield_basis_points
    if not _whole(y) or y < 1 or y > 10000:
        raise ValueError('yieldBasisPoints must be from 1 to 10000, received %s for "%s"' % (y, name))
    price = ingredient.pack_price
    if price.minor < 0:
        raise ValueError('packPrice must not be negative, received %d for "%s"' % (price.minor, name))

    unit, pack_unit = ingredient.unit, ingredient.pack_unit
    if unit == "each" or pack_unit == "each":
        if unit != pack_unit:
            raise ValueError('cannot convert %s to %s: "each" only converts to "each"' % (unit, pack_unit))
        if _parse_decimal(quantity)[1] > _QUANTITY_DECIMALS:
            raise ValueError(
                'quantity may have at most %d decimal places, received "%s" for "%s"' % (_QUANTITY_DECIMALS, quantity, name)
            )
        in_pack_units = quantity
    else:
        in_pack_units = convert_units(quantity, unit, pack_unit, _QUANTITY_DECIMALS)

    # cost = price x (quantity / packSize) / yield, in one exact division.
    qty_digits, qty_scale = _parse_decimal(in_pack_units)
    numerator = price.minor * qty_digits * 10000
    denominator = pack_digits * y
    if pack_scale >= qty_scale:
        numerator *= 10 ** (pack_scale - qty_scale)
    else:
        denominator *= 10 ** (qty_scale - pack_scale)
    if numerator > _I128_MAX or denominator > _I128_MAX:
        raise ValueError('the cost of "%s" is too large to compute exactly' % (name,))
    rounded = (numerator * 2 + denominator) // (denominator * 2)
    if rounded > _MAX_SAFE:
        raise ValueError('the cost of "%s" is too large to compute exactly' % (name,))
    return rounded


def recipe_cost(ingredients: Sequence[RecipeIngredient], portions: int, currency: str) -> RecipeCost:
    """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.
    """
    if len(ingredients) == 0:
        raise ValueError("a recipe needs at least one ingredient")
    if not _whole(portions) or portions < 1:
        raise ValueError("portions must be at least 1, received %s" % (portions,))
    lines: List[RecipeCostLine] = [
        RecipeCostLine(name=i.name, cost=money(_line_cost(i), i.pack_price.currency)) for i in ingredients
    ]
    total = sum_money([line.cost for line in lines], currency)
    return RecipeCost(
        lines=lines,
        total=total,
        portions=portions,
        per_portion=money(round_div(total.minor, portions, "half-up"), currency),
    )