dodil/erp-brick-manufacturing

DODIL ERP brick: manufacturing

tests/test_domain.py

"""The business rules with no database and no HTTP: the BOM aggregate and settlement arithmetic, from the stories' own numbers."""
from datetime import date
from decimal import Decimal

import pytest

from brick_manufacturing.domain.bom import Bom, BomLine
from brick_manufacturing.domain.errors import OutOfBounds, RuleBroken
from brick_manufacturing.domain.settlement import settle
from brick_manufacturing.domain.values import quantity

D = Decimal


def roast() -> Bom:
    bom = Bom("ROAST-ETH:production", "ROAST-ETH", "production", "", "UK01")
    bom.write_version(1, valid_from=date(2026, 11, 16), output_quantity=D("84"), output_unit_code="KGM", output_quantity_base=D("84"),
                      output_base_unit_code="KGM", edges={},
                      lines=[BomLine(0, 1, "component", "GREEN-ETH", D("100"), "KGM", D("100"), "KGM")])
    return bom


def test_the_planned_yield_is_output_per_input_and_shown_never_typed():
    assert roast().versions[0].planned_yield_pct == D("84.0000")


def test_validity_is_computed_from_released_versions_so_they_never_overlap():
    bom = roast()
    bom.release_bom(1, today=date(2026, 11, 10), by="a", sub="a")
    bom.write_version(2, valid_from=date(2026, 12, 1), output_quantity=D("85"), output_unit_code="KGM", output_quantity_base=D("85"),
                      output_base_unit_code="KGM", edges={}, lines=[BomLine(0, 1, "component", "GREEN-ETH", D("100"), "KGM", D("100"), "KGM")])
    bom.release_bom(2, today=date(2026, 11, 10), by="a", sub="a")
    assert [bom.valid_on(date(2026, 11, d)).version for d in (16, 30)] == [1, 1]
    assert bom.valid_on(date(2026, 12, 1)).version == 2 and bom.valid_on(date(2026, 11, 15)) is None
    bom.obsolete_bom(2, valid_to=date(2026, 12, 31))
    assert bom.valid_on(date(2027, 1, 1)) is None


def test_explode_scales_by_the_requested_over_the_output_and_rounds_once_per_line():
    bom = roast()
    bom.release_bom(1, today=date(2026, 11, 10), by="a", sub="a")
    v, comps = bom.explode(D("1"), date(2026, 11, 16))
    assert comps == [("GREEN-ETH", D("1.1905"), "KGM")]          # 100 / 84, rounded half up at 4 places
    assert bom.explode(D("168"), date(2026, 11, 16))[1] == [("GREEN-ETH", D("200.0000"), "KGM")]


def test_a_cycle_through_another_bom_is_found_by_a_walk():
    bom = roast()
    with pytest.raises(RuleBroken, match="contain itself"):
        Bom("X:production", "X", "production").write_version(
            1, valid_from=date(2026, 12, 1), output_quantity=D("1"), output_unit_code="KGM", output_quantity_base=D("1"),
            output_base_unit_code="KGM", edges={"GREEN-ETH": {"Y"}, "Y": {"X"}},
            lines=[BomLine(0, 1, "component", "GREEN-ETH", D("1"), "KGM", D("1"), "KGM")])
    assert bom


@pytest.mark.parametrize("v", ["0", "-1", "1e15", "1.00001", "x", None, 1.5, True, "NaN", "Infinity"])
def test_quantities_outside_numeric_19_4_are_out_of_bounds(v):
    with pytest.raises(OutOfBounds):
        quantity(v)


def test_the_stories_settlement_numbers_with_activity_absorbed():
    """MFG-15: WIP debit 620.00 + 180.00 activity = 800.00, credit 774.67; material usage 11.07, activity 14.26, sum 25.33."""
    s = settle([{"planned_base": D("100"), "issued_base": D("100"), "issued_value": D("620.00")}], outputs_value=D("774.67"),
               activity_cost=D("180.00"), output_base=D("82.5"), planned_output_base=D("84"))
    assert (s.wip_debit, s.wip_credit, s.balance) == (D("800.00"), D("774.67"), D("25.33"))
    assert (s.standard_material, s.material_usage_variance, s.activity_variance) == (D("608.93"), D("11.07"), D("14.26"))
    assert s.material_usage_variance + s.activity_variance + s.abnormal_scrap == s.balance


def test_normal_roasting_loss_in_the_planned_yield_is_no_variance():
    s = settle([{"planned_base": D("100"), "issued_base": D("100"), "issued_value": D("620.00")}], outputs_value=D("788.75"),
               activity_cost=D("168.75"), output_base=D("84"), planned_output_base=D("84"))
    assert s.material_usage_variance == D("0.00") and s.activity_variance == D("0.00") and s.balance == D("0.00")


def test_a_balance_no_ledger_can_hold_is_out_of_bounds():
    with pytest.raises(OutOfBounds):
        settle([{"planned_base": D("1"), "issued_base": D("1"), "issued_value": D("9999999999999999.99")}], outputs_value=D("0"),
               activity_cost=D("9999999999999999.99"), output_base=D("1"), planned_output_base=D("1"))