Functional Weave
Code in Python

manufacturing.bom-cost

Rolled-up standard cost of one unit of a product from its BOM, bought-in costs, and labour and overhead rates.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 15 tests, run in TypeScript, Python and Rust.

What it does

The standard cost of one unit of a product, rolled up through its bill of materials by cost element:

- **material**: every bought-in item (a BOM leaf) at its standard unit cost, times the total quantity one product needs across every level, scrap included; - **labour**: every operation on the product and on each sub-assembly, run time x labour rate per hour, times how many of that item one product needs; - **overhead**: the same run time x overhead rate per hour (overhead absorbed on operation hours).

For example

  • bom_cost(lines ×4, TABLE, material costs ×4, operations ×3, GBP, half-up) → material £25.45, labour £5.25, overhead £3.60, total £34.30 a table: material 2545.2p (timber 3.3 m with scrap, screws 8.4 with scrap), labour 525p, overhead 360p
  • bom_cost(lines ×4, TABLE, material costs ×4, operations ×2, GBP, down) → material £25.45, labour £4.86, overhead £3.33, total £33.64 each element is rounded once from its exact sum: the leg and table labour rounded down one by one would be 194 + 291 = 485, the exact 486.11 gives 486
  • bom_cost(lines ×4, TABLE, material costs ×4, operations ×3, GBP, up) → material £25.46, labour £5.25, overhead £3.60, total £34.31 the same table rounded up: 2545.2p of material is 2546

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def bom_cost(lines: Sequence[BomLine], item: str, material_costs: Sequence[ItemCost], operations: Sequence[RoutingOperation], currency: str, mode: RoundingMode) -> CostRollup
linesBomLine[]the bill of materials, as manufacturing.bom-explode takes it
itemstringthe product to cost, one unit of it
material_costsItemCost[]the standard cost of each bought-in item; entries for other items are ignored
operationsRoutingOperation[]run time and rates per operation; operations on items outside the BOM are ignored
currencystringevery cost and rate must be in it
modeRoundingModehow each exact cost element becomes whole minor units
returnsCostRollup

The types it declares, generated into your project

@dataclass(frozen=True)
class ItemCost:
    """The standard cost of one unit of a bought-in item."""

    item: str
    unit_cost: Money

@dataclass(frozen=True)
class RoutingOperation:
    """One operation on one unit of an item, with the rates it is costed at."""

    #: the product or sub-assembly the operation works on
    item: str
    #: run time per unit, whole seconds
    seconds: int
    #: per hour of the operation
    labour_rate: Money
    #: per hour of the operation: overhead absorbed on its time
    overhead_rate: Money

@dataclass(frozen=True)
class CostRollup:
    """Cost of one unit by cost element; total is the sum of the three rounded elements."""

    #: bought-in items, every level, scrap included
    material: Money
    #: every operation on the product and its sub-assemblies
    labour: Money
    overhead: Money
    total: Money

Your code names it in one line, in the file that uses it

from fune.manufacturing.bom_cost import bom_cost  # manufacturing.bom-cost@^1
impl/python.py · 77 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import Dict, Sequence, Tuple

from .manufacturing_bom_cost_types import CostRollup, ItemCost, RoutingOperation
from .manufacturing_bom_explode import BomLine, explode_bom  ← from manufacturing.bom-explode ^1.0.0 · built alongside by fune
from .math_rational import Rational, add_rational, multiply_rational, rational, rational_to_integer  ← from math.rational ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune

MAX_SAFE = 2**53 - 1


def bom_cost(
    lines: Sequence[BomLine],
    item: str,
    material_costs: Sequence[ItemCost],
    operations: Sequence[RoutingOperation],
    currency: str,
    mode: RoundingMode,
) -> CostRollup:
    """Standard cost of one unit, by cost element, rolled up through the BOM.
    Every element is an exact fraction of a minor unit until it is rounded once.
    """
    zero = money(0, currency)
    needs: Dict[str, Tuple[Rational, bool]] = {item: (rational(1, 1), False)}
    for r in explode_bom(lines, item, 1):
        needs[r.item] = (r.quantity, r.leaf)

    costs: Dict[str, int] = {}
    for c in material_costs:
        assert_same_currency(zero, c.unit_cost)
        if c.item in costs:
            raise ValueError('duplicate material cost for "%s"' % (c.item,))
        if c.unit_cost.minor < 0:
            raise ValueError('unitCost of "%s" must not be negative, received %d' % (c.item, c.unit_cost.minor))
        costs[c.item] = c.unit_cost.minor

    material = rational(0, 1)
    for name, (quantity, leaf) in needs.items():
        cost = costs.get(name)
        if not leaf:
            if cost is not None:
                raise ValueError(
                    '"%s" is made from its own bill of materials, so its cost is rolled up; remove it from materialCosts'
                    % (name,)
                )
            continue
        if cost is None:
            raise ValueError('no material cost for "%s"' % (name,))
        material = add_rational(material, multiply_rational(quantity, rational(cost, 1)))

    labour = rational(0, 1)
    overhead = rational(0, 1)
    for op in operations:
        if isinstance(op.seconds, bool) or not isinstance(op.seconds, int) or op.seconds < 0 or op.seconds > MAX_SAFE:
            raise ValueError(
                'seconds of an operation on "%s" must be a whole number, not negative, received %r' % (op.item, op.seconds)
            )
        assert_same_currency(zero, op.labour_rate)
        assert_same_currency(zero, op.overhead_rate)
        if op.labour_rate.minor < 0 or op.overhead_rate.minor < 0:
            raise ValueError('the rates of an operation on "%s" must not be negative' % (op.item,))
        need = needs.get(op.item)
        if need is None:
            continue
        hours = multiply_rational(need[0], rational(op.seconds, 3600))
        labour = add_rational(labour, multiply_rational(hours, rational(op.labour_rate.minor, 1)))
        overhead = add_rational(overhead, multiply_rational(hours, rational(op.overhead_rate.minor, 1)))

    m = rational_to_integer(material, mode)
    l = rational_to_integer(labour, mode)
    o = rational_to_integer(overhead, mode)
    return CostRollup(
        material=money(m, currency),
        labour=money(l, currency),
        overhead=money(o, currency),
        total=money(m + l + o, currency),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add manufacturing.bom-cost
Download for Python manufacturing.bom-cost-1.0.0-python.fune · 28,151 bytes sha256 d893c7c2a26e81eeb56fd5402e13b1da9d30d2c384a491ae506e1c7a0208f6c9

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./manufacturing.bom-cost-1.0.0-python.fune, or fetch it from a terminal with fune pull manufacturing.bom-cost@1.0.0:python.

The whole function, every language, is one file too: manufacturing.bom-cost-1.0.0.fune, 36,826 bytes, sha256 7fcf50a7bb83472208973c51088f91af2ab7d3d023c4f24f25a2af7a39516952. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before manufacturing.bom-cost

after — your function gets the result and the arguments, and returns the final result.

# fune: after manufacturing.bom-cost

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace manufacturing.bom-explode in manufacturing.bom-cost
# fune: replace math.rational in manufacturing.bom-cost
# fune: replace math.round-div in manufacturing.bom-cost
# fune: replace money.amount in manufacturing.bom-cost

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show manufacturing.bom-cost --steps.

# fune: step manufacturing.bom-cost after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a table: material 2545.2p (timber 3.3 m with scrap, screws 8.4 with scrap), labour 525p, overhead 360p lines ×4, TABLE, material costs ×4, operations ×3, GBP, half-up → material £25.45, labour £5.25, overhead £3.60, total £34.30
each element is rounded once from its exact sum: the leg and table labour rounded down one by one would be 194 + 291 = 485, the exact 486.11 gives 486 lines ×4, TABLE, material costs ×4, operations ×2, GBP, down → material £25.45, labour £4.86, overhead £3.33, total £33.64
the same table rounded up: 2545.2p of material is 2546 lines ×4, TABLE, material costs ×4, operations ×3, GBP, up → material £25.46, labour £5.25, overhead £3.60, total £34.31
bought-in parts only: 348.5p rounds half-even to 348 lines ×2, KIT, material costs ×2, , GBP, half-even → material £3.48, labour £0.00, overhead £0.00, total £3.48
the same kit rounded half-up is 349 lines ×2, KIT, material costs ×2, , GBP, half-up → material £3.49, labour £0.00, overhead £0.00, total £3.49
costing a sub-assembly on its own lines ×4, LEG, material costs ×4, operations ×3, GBP, half-up → material £3.30, labour £0.58, overhead £0.40, total £4.28
zero-cost parts and zero-time operations cost nothing lines ×1, KIT, material costs ×1, operations ×1, EUR, half-up → material €0.00, labour €0.00, overhead €0.00, total €0.00
a bought-in item without a cost is an error lines ×4, TABLE, material costs ×2, operations ×3, GBP, half-up → error: no material cost for "TIMBER"
a cost for an item with its own bill is an error lines ×4, TABLE, material costs ×5, operations ×3, GBP, half-up → error: "LEG" is made from its own bill of materials, so its cost is rolled up
two costs for one item is an error lines ×4, TABLE, material costs ×5, operations ×3, GBP, half-up → error: duplicate material cost for "TOP"
Show the other 5 tests
CaseArgumentsExpected
a cost in another currency is an error lines ×4, TABLE, material costs ×3, operations ×3, GBP, half-up → error: currency mismatch
a negative cost is an error lines ×4, TABLE, material costs ×3, operations ×3, GBP, half-up → error: unitCost of "TOP" must not be negative
negative operation time is an error lines ×4, TABLE, material costs ×4, operations ×1, GBP, half-up → error: seconds of an operation on "LEG" must be a whole number, not negative
a negative rate is an error lines ×4, TABLE, material costs ×4, operations ×1, GBP, half-up → error: the rates of an operation on "LEG" must not be negative
a cycle in the bill is refused as bom-explode refuses it lines ×2, A, , , GBP, half-up → error: bill of materials has a cycle: A -> B -> A

More from the author

Quantities come from `manufacturing.bom-explode` for a build of one, so scrap, fractional quantities (3/4 m of timber) and parts used at several levels are handled exactly as there, and a BOM with a cycle is refused with the same error.

**Cost element roll-up.** A sub-assembly's labour stays labour and its materials stay material all the way up (the "cost component split" of ERP systems), rather than the sub-assembly's total becoming "material" of its parent. That is what a cost sheet or variance analysis needs.

**Exact, then rounded once per element.** Every product is computed as an exact fraction of a minor unit (120 seconds at £17.50 an hour is 58.33p) and each element is rounded once, by `mode`, at the end. Rounding each operation separately drifts: in the second vector the operations rounded down one by one come to 485p of labour, the exact sum rounded down to 486p. `total` is material + labour + overhead after rounding, so the cost sheet adds up.

**Rules.** Every bought-in item in the BOM needs exactly one entry in `materialCosts`; a missing one, a duplicate, or an entry for an item that has its own BOM (its cost is rolled up, not looked up) is an error. Costs and rates must be in `currency` and not negative; operation times are whole seconds, not negative. Entries for items outside this product are ignored, so a whole cost file and routing file can be passed. Set-up time is not included: amortise it into `seconds` per unit for the batch size you cost at.

Files

PathBytes
README.md2,034
impl/python.py3,252
impl/rust.rs5,136
impl/typescript.ts3,210
vectors.json16,300