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
bomCost(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 360pbomCost(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 486bomCost(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.
export function bomCost(lines: readonly BomLine[], item: string, materialCosts: readonly ItemCost[], operations: readonly RoutingOperation[], currency: string, 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 |
| materialCosts | 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. */
export interface ItemCost {
readonly item: string;
readonly unitCost: Money;
}
/** One operation on one unit of an item, with the rates it is costed at. */
export interface RoutingOperation {
/** the product or sub-assembly the operation works on */
readonly item: string;
/** run time per unit, whole seconds */
readonly seconds: number;
/** per hour of the operation */
readonly labourRate: Money;
/** per hour of the operation: overhead absorbed on its time */
readonly overheadRate: Money;
}
/** Cost of one unit by cost element; total is the sum of the three rounded elements. */
export interface CostRollup {
/** bought-in items, every level, scrap included */
readonly material: Money;
/** every operation on the product and its sub-assemblies */
readonly labour: Money;
readonly overhead: Money;
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { bomCost } from "#fune/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.
import { type BomLine, explodeBom } from "./manufacturing_bom_explode.ts"; ← from manufacturing.bom-explode ^1.0.0 · built alongside by fune
import { type Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts"; ← from math.rational ^1.0.0 · built alongside by fune
import { type RoundingMode } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type CostRollup, type ItemCost, type RoutingOperation } from "./manufacturing_bom_cost_types.ts";
/**
* 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.
*/
export function bomCost(
lines: readonly BomLine[],
item: string,
materialCosts: readonly ItemCost[],
operations: readonly RoutingOperation[],
currency: string,
mode: RoundingMode,
): CostRollup {
const zero = money(0, currency);
const needs = new Map<string, { quantity: Rational; leaf: boolean }>();
needs.set(item, { quantity: rational(1, 1), leaf: false });
for (const r of explodeBom(lines, item, 1)) {
needs.set(r.item, { quantity: r.quantity, leaf: r.leaf });
}
const costs = new Map<string, number>();
for (const c of materialCosts) {
assertSameCurrency(zero, c.unitCost);
if (costs.has(c.item)) {
throw new RangeError(`duplicate material cost for "${c.item}"`);
}
if (c.unitCost.minor < 0) {
throw new RangeError(`unitCost of "${c.item}" must not be negative, received ${c.unitCost.minor}`);
}
costs.set(c.item, c.unitCost.minor);
}
let material = rational(0, 1);
for (const [name, need] of needs) {
const cost = costs.get(name);
if (!need.leaf) {
if (cost !== undefined) {
throw new RangeError(`"${name}" is made from its own bill of materials, so its cost is rolled up; remove it from materialCosts`);
}
continue;
}
if (cost === undefined) {
throw new RangeError(`no material cost for "${name}"`);
}
material = addRational(material, multiplyRational(need.quantity, rational(cost, 1)));
}
let labour = rational(0, 1);
let overhead = rational(0, 1);
for (const op of operations) {
if (!Number.isSafeInteger(op.seconds) || op.seconds < 0) {
throw new RangeError(`seconds of an operation on "${op.item}" must be a whole number, not negative, received ${op.seconds}`);
}
assertSameCurrency(zero, op.labourRate);
assertSameCurrency(zero, op.overheadRate);
if (op.labourRate.minor < 0 || op.overheadRate.minor < 0) {
throw new RangeError(`the rates of an operation on "${op.item}" must not be negative`);
}
const need = needs.get(op.item);
if (need === undefined) continue;
const hours = multiplyRational(need.quantity, rational(op.seconds, 3600));
labour = addRational(labour, multiplyRational(hours, rational(op.labourRate.minor, 1)));
overhead = addRational(overhead, multiplyRational(hours, rational(op.overheadRate.minor, 1)));
}
const m = rationalToInteger(material, mode);
const l = rationalToInteger(labour, mode);
const o = rationalToInteger(overhead, mode);
return {
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 TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./manufacturing.bom-cost-1.0.0-typescript.fune, or fetch it from a terminal with fune pull manufacturing.bom-cost@1.0.0:typescript.
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 |