from typing import Dict, Sequence, Tuple from .manufacturing_bom_cost_types import CostRollup, ItemCost, RoutingOperation from .manufacturing_bom_explode import BomLine, explode_bom from .math_rational import Rational, add_rational, multiply_rational, rational, rational_to_integer from .math_round_div import RoundingMode from .money_amount import assert_same_currency, money 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), )