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 360pbom_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 486bom_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
| lines | BomLine[] | the bill of materials, as manufacturing.bom-explode takes it |
| item | string | the product to cost, one unit of it |
| material_costs | ItemCost[] | the standard cost of each bought-in item; entries for other items are ignored |
| operations | RoutingOperation[] | run time and rates per operation; operations on items outside the BOM are ignored |
| currency | string | every cost and rate must be in it |
| mode | RoundingMode | how each exact cost element becomes whole minor units |
| returns | CostRollup |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,034 |
| impl/python.py | 3,252 |
| impl/rust.rs | 5,136 |
| impl/typescript.ts | 3,210 |
| vectors.json | 16,300 |