impl/typescript.ts
3,210 bytes · the TypeScript 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 { 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),
};
}