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.
pub fn bom_cost(lines: &[BomLine], item: &str, material_costs: &[ItemCost], operations: &[RoutingOperation], currency: &str, mode: &str) -> 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
/// The standard cost of one unit of a bought-in item.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemCost {
pub item: String,
pub unit_cost: Money,
}
/// One operation on one unit of an item, with the rates it is costed at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutingOperation {
/// the product or sub-assembly the operation works on
pub item: String,
/// run time per unit, whole seconds
pub seconds: i64,
/// per hour of the operation
pub labour_rate: Money,
/// per hour of the operation: overhead absorbed on its time
pub overhead_rate: Money,
}
/// Cost of one unit by cost element; total is the sum of the three rounded elements.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CostRollup {
/// bought-in items, every level, scrap included
pub material: Money,
/// every operation on the product and its sub-assemblies
pub labour: Money,
pub overhead: Money,
pub total: Money,
}
Your code names it in one line, in the file that uses it
fune!(manufacturing.bom-cost@^1); // then call bom_cost(…)
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::manufacturing_bom_explode::{bom_line_from_value, explode_bom, BomLine}; ← from manufacturing.bom-explode ^1.0.0 · built alongside by fune
use super::math_rational::{add_rational, multiply_rational, rational, rational_to_integer, Rational}; ← from math.rational ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
/// 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.
///
/// # Panics
/// Panics on anything `explode_bom` refuses, a missing, duplicate or negative
/// material cost, a cost for a made item, negative operation time or rates,
/// or a cost or rate in another currency.
pub fn bom_cost(
lines: &[BomLine],
item: &str,
material_costs: &[ItemCost],
operations: &[RoutingOperation],
currency: &str,
mode: &str,
) -> CostRollup {
let zero = money(0, currency);
let mut needs: Vec<(String, Rational, bool)> = vec![(item.to_string(), rational(1, 1), false)];
for r in explode_bom(lines, item, 1) {
needs.push((r.item, r.quantity, r.leaf));
}
let mut costs: Vec<(&str, i64)> = Vec::new();
for c in material_costs {
assert_same_currency(&zero, &c.unit_cost);
if costs.iter().any(|(n, _)| *n == c.item) {
panic!("duplicate material cost for \"{}\"", c.item);
}
if c.unit_cost.minor < 0 {
panic!("unitCost of \"{}\" must not be negative, received {}", c.item, c.unit_cost.minor);
}
costs.push((&c.item, c.unit_cost.minor));
}
let mut material = rational(0, 1);
for (name, quantity, leaf) in &needs {
let cost = costs.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
if !leaf {
if cost.is_some() {
panic!(
"\"{}\" is made from its own bill of materials, so its cost is rolled up; remove it from materialCosts",
name
);
}
continue;
}
let cost = match cost {
Some(c) => c,
None => panic!("no material cost for \"{}\"", name),
};
material = add_rational(&material, &multiply_rational(quantity, &rational(cost, 1)));
}
let mut labour = rational(0, 1);
let mut overhead = rational(0, 1);
for op in operations {
if op.seconds < 0 || op.seconds > (1i64 << 53) - 1 {
panic!(
"seconds of an operation on \"{}\" must be a whole number, not negative, received {}",
op.item, op.seconds
);
}
assert_same_currency(&zero, &op.labour_rate);
assert_same_currency(&zero, &op.overhead_rate);
if op.labour_rate.minor < 0 || op.overhead_rate.minor < 0 {
panic!("the rates of an operation on \"{}\" must not be negative", op.item);
}
let need = match needs.iter().find(|(n, _, _)| *n == op.item) {
Some((_, q, _)) => *q,
None => continue,
};
let hours = multiply_rational(&need, &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)));
}
let m = rational_to_integer(&material, mode);
let l = rational_to_integer(&labour, mode);
let o = rational_to_integer(&overhead, mode);
CostRollup {
material: money(m, currency),
labour: money(l, currency),
overhead: money(o, currency),
total: money(m + l + o, currency),
}
}
pub fn cost_rollup_to_value(c: &CostRollup) -> Value {
Value::obj(vec![
("material", money_to_value(&c.material)),
("labour", money_to_value(&c.labour)),
("overhead", money_to_value(&c.overhead)),
("total", money_to_value(&c.total)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let lines: Vec<BomLine> = args[0].as_arr().iter().map(bom_line_from_value).collect();
let costs: Vec<ItemCost> = args[2]
.as_arr()
.iter()
.map(|v| ItemCost {
item: v.get("item").as_str().to_string(),
unit_cost: money_from_value(v.get("unitCost")),
})
.collect();
let operations: Vec<RoutingOperation> = args[3]
.as_arr()
.iter()
.map(|v| {
if let Value::Float(f) = v.get("seconds") {
panic!(
"seconds of an operation on \"{}\" must be a whole number, not negative, received {}",
v.get("item").as_str(),
f
);
}
RoutingOperation {
item: v.get("item").as_str().to_string(),
seconds: v.get("seconds").as_i64(),
labour_rate: money_from_value(v.get("labourRate")),
overhead_rate: money_from_value(v.get("overheadRate")),
}
})
.collect();
cost_rollup_to_value(&bom_cost(&lines, args[1].as_str(), &costs, &operations, args[4].as_str(), args[5].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./manufacturing.bom-cost-1.0.0-rust.fune, or fetch it from a terminal with fune pull manufacturing.bom-cost@1.0.0:rust.
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 |