dodil/erp-brick-product

tests/test_units.py

"""Units of measure and prices per unit (PRD-23, PRD-26): every conversion an exact rational, nothing rounded until the
unit's quantity_decimals, a used conversion fixed for good, a price per unit with the base-unit row as the exact fallback."""
from decimal import Decimal as D
from fractions import Fraction

import pytest
from conftest import CATALOGUE, PRICING, SALES, SELLING, api, categories, mk_list, mk_product, must, price, row

from brick_product.domain import units
from brick_product.domain.errors import RuleBroken


def cola(pid="COLA-075", **kw):
    """COLA-075 "Cola 0.75 L", base LTR: a bottle (C62) is 0.75 LTR; a pack PK24 (XBX here) is 24 bottles; bought in packs,
    sold in bottles, issued in litres."""
    mk_product(pid, name="Cola 0.75 L", base_unit_code="LTR", activate=False, **kw)
    return must(api("POST", f"/products/{pid}/set-units", CATALOGUE, {
        "sales_unit_code": "C62", "purchase_unit_code": "XBX", "issue_unit_code": "LTR", "units_of": [
            {"unit_code": "C62", "numerator": 3, "denominator": 4, "packaging_level": "each"},
            {"unit_code": "XBX", "contains_unit": "C62", "contains_quantity": "24", "packaging_level": "case"}]}))


def test_prd_23_one_product_counted_in_packs_bottles_and_litres_each_converting_exactly():
    """PRD-23 — Given COLA-075 with base unit LTR, a bottle = 0.75 LTR and a pack = 24 bottles, setting the pack as the
    purchase unit, the bottle as the sales unit and LTR as the issue unit: 2 packs bought = 48 bottles = 36 LTR."""
    made = cola()
    assert (made["base_unit_code"], made["sales_unit_code"], made["purchase_unit_code"], made["issue_unit_code"]) == ("LTR", "C62", "XBX", "LTR")
    assert {u["unit_code"]: (u["numerator"], u["denominator"]) for u in made["units"]} == {"LTR": (1, 1), "C62": (3, 4), "XBX": (18, 1)}
    got = must(api("GET", "/products/COLA-075/convert", CATALOGUE, {"quantity": "2", "from_unit": "XBX", "to_unit": "C62"}))
    assert (got["converted"], got["exact"], got["in_base_unit"]) == ("48", "48/1", "36.000")
    assert must(api("GET", "/products/COLA-075/convert", CATALOGUE, {"quantity": "36", "from_unit": "LTR", "to_unit": "XBX"}))["converted"] == "2"
    third = must(api("GET", "/products/COLA-075/convert", CATALOGUE, {"quantity": "1", "from_unit": "LTR", "to_unit": "C62"}))
    assert third["exact"] == "4/3" and third["converted"] == "1"                                  # exact; rounded at the end, to 0 places
    # a unit with a different meaning for another product is that product's own conversion
    mk_product("COLA-033", name="Cola 0.33 L", base_unit_code="LTR", activate=False)
    must(api("POST", "/products/COLA-033/set-units", CATALOGUE, {"units_of": [
        {"unit_code": "XBX", "numerator": 12, "denominator": 1}]}))
    assert must(api("GET", "/products/COLA-033/convert", CATALOGUE, {"quantity": "1", "from_unit": "XBX", "to_unit": "LTR"}))["converted"] == "12.000"
    # a unit that does not convert, or a float, is refused
    assert api("POST", "/products/COLA-033/set-units", CATALOGUE, {"sales_unit_code": "HUR"})[0] == 409
    assert api("POST", "/products/COLA-033/set-units", CATALOGUE, {"units_of": [{"unit_code": "DAY", "numerator": 1.5, "denominator": 1}]})[0] == 422
    assert api("POST", "/products/COLA-033/set-units", CATALOGUE, {"units_of": [{"unit_code": "DAY", "numerator": 0, "denominator": 1}]})[0] == 422
    assert api("POST", "/products/COLA-033/set-units", SALES, {})[0] == 403


def test_a_conversion_true_for_every_product_needs_no_row_and_has_none():
    mk_product("SAND", name="Sand", base_unit_code="KGM", activate=False)
    assert must(api("GET", "/products/SAND/convert", CATALOGUE, {"quantity": "2.5", "from_unit": "KGM", "to_unit": "GRM"}))["converted"] == "2500"
    assert must(api("GET", "/products/SAND/convert", CATALOGUE, {"quantity": "500", "from_unit": "GRM", "to_unit": "KGM"}))["converted"] == "0.500"
    must(api("POST", "/products/SAND/set-units", CATALOGUE, {"sales_unit_code": "GRM"}))             # GRM converts by KGM-GRM: allowed
    status, body = api("POST", "/products/SAND/set-units", CATALOGUE, {"units_of": [{"unit_code": "GRM", "numerator": 1, "denominator": 999}]})
    assert status == 409 and "the same way for every product" in body["detail"]
    mk_product("SVC", name="Consulting", base_unit_code="HUR", activate=False)
    assert must(api("GET", "/products/SVC/convert", CATALOGUE, {"quantity": "90", "from_unit": "MIN", "to_unit": "HUR"}))["converted"] == "1.50"
    assert api("GET", "/products/SVC/convert", CATALOGUE, {"quantity": "1", "from_unit": "KGM", "to_unit": "HUR"})[0] == 422


def test_activation_needs_a_base_unit_and_a_sales_unit():
    mk_product("NOUNIT", base_unit_code=None, activate=False)
    status, body = api("POST", "/products/NOUNIT/activate-product", CATALOGUE, {})
    assert status == 409 and "a base unit, a sales unit" in body["detail"]
    must(api("POST", "/products/NOUNIT/set-units", CATALOGUE, {"base_unit_code": "C62"}))          # the roles follow the base
    assert must(api("POST", "/products/NOUNIT/activate-product", CATALOGUE, {}))["sales_unit_code"] == "C62"


def test_invariant_a_conversion_used_by_a_document_or_stock_never_changes():
    """tests.invariants: a conversion used by a document or stock never changes; a changed pack size is a new unit code."""
    cola()
    must(api("POST", "/products/COLA-075/activate-product", CATALOGUE, {}))
    pack = {"unit": {"unit_code": "XBX", "numerator": 18, "denominator": 1}}
    again = must(api("POST", "/products/COLA-075/add-unit", CATALOGUE, pack))
    assert [u["unit_code"] for u in again["units"]] == ["LTR", "C62", "XBX"]                           # the same pack again: nothing
    change = {"unit": {"unit_code": "XBX", "contains_unit": "C62", "contains_quantity": "12"}}      # 12 bottles = 9 LTR
    assert must(api("POST", "/products/COLA-075/add-unit", CATALOGUE, change))["units"][2]["denominator"] == 1   # unused: may change
    must(api("POST", "/products/COLA-075/add-unit", CATALOGUE, pack))                               # and back
    used = must(api("POST", "/products/COLA-075/units/XBX/used", SELLING, {"document_ref": "SO-1"}))   # an order used it
    assert used["used"] is True and must(api("POST", "/products/COLA-075/units/XBX/used", SELLING, {})) == used | {}
    status, body = api("POST", "/products/COLA-075/add-unit", CATALOGUE, change)
    assert status == 409 and "already used" in body["detail"] and "NEW unit code" in body["detail"]
    assert must(api("GET", "/products/COLA-075/convert", CATALOGUE, {"quantity": "1", "from_unit": "XBX", "to_unit": "LTR"}))["converted"] == "18.000"
    new = must(api("POST", "/products/COLA-075/add-unit", CATALOGUE, {"unit": {"unit_code": "SET", "contains_unit": "C62",
                                                                                 "contains_quantity": "12"}, "use_for": ["purchase"]}))
    assert new["purchase_unit_code"] == "SET" and any(u["unit_code"] == "SET" and u["numerator"] == 9 for u in new["units"])
    assert api("POST", "/products/COLA-075/units/NOPE/used", SELLING, {})[0] == 404
    assert api("POST", "/products/COLA-075/set-units", CATALOGUE, {})[0] == 409                      # a draft's step: not once active


def test_invariant_a_quantity_converts_exactly_no_float_and_no_rounding_before_the_end():
    third = units.ratio(1, 3)
    assert units.convert(3, third, Fraction(1)) == 1 and units.convert("0.3", third, Fraction(1)) == Fraction(1, 10)
    assert units.convert("1", Fraction(1), third) == 3                                             # 1 base = 3 thirds, exactly
    seven = units.ratio(7, 11)
    assert units.convert(units.convert("123456789.123456789", seven, Fraction(1)), Fraction(1), seven) == Fraction("123456789.123456789")
    assert units.round_half_up(Fraction(5, 2), 0) == D("3") and units.round_half_up(Fraction(-5, 2), 0) == D("-3")
    assert units.round_half_up(Fraction(1, 3), 3) == D("0.333") and units.round_half_up(Fraction(2, 3), 3) == D("0.667")
    assert units.round_half_up(Fraction(1, 8), 2) == D("0.13") and units.round_half_up(Fraction(7), 0) == D("7")
    for bad in (0, -1, 1.5, True, 2 ** 63):
        with pytest.raises(RuleBroken):
            units.ratio(bad, 1)
    with pytest.raises(RuleBroken):
        units.exact(0.1)                                                                           # a float is never a quantity
    assert units.exact("0.1") == Fraction(1, 10)


def per_unit_world():
    cola()
    must(api("POST", "/products/COLA-075/activate-product", CATALOGUE, {}))
    mk_list("LIST-GBP", rows=[row("COLA-075", "2026-11-01", "1.20", unit_code="C62"),
                              row("COLA-075", "2026-11-01", "24.00", unit_code="XBX"),
                              row("COLA-075", "2026-11-01", "1.60", unit_code="LTR")], default_from="2026-10-02")


def test_prd_26_a_pack_has_its_own_price_and_the_answer_says_where_it_came_from():
    """PRD-26 — LIST-GBP: 1.20 a bottle and 24.00 a pack: 2 packs are 48.00 each way the price is stated, 48 bottles 57.60;
    a unit with no row of its own is priced from the base-unit row by its exact conversion, and the answer says so."""
    per_unit_world()
    packs = must(price("COLA-075", quantity="2", unit_code="XBX", priced_on="2026-12-01"))
    assert (packs["unit_price"], packs["unit_code"], packs["priced_from_note"]) == ("24.00", "XBX", "a price per XBX")
    assert D(packs["unit_price"]) * 2 == D("48.00") and packs["quantity_in_base_unit"] == "36.000"
    bottles = must(price("COLA-075", quantity="48", priced_on="2026-12-01"))                          # the sales unit by default
    assert (bottles["unit_price"], bottles["unit_code"]) == ("1.20", "C62") and D(bottles["unit_price"]) * 48 == D("57.60")
    assert bottles["line"]["unit_code"] == "C62" and (bottles["line"]["numerator"], bottles["line"]["denominator"]) == (3, 4)
    assert bottles["line"]["base_unit_code"] == "LTR" and bottles["quantity_in_base_unit"] == "36.000"
    mk_product("COLA-150", name="Cola 1.5 L", base_unit_code="LTR", activate=False)
    must(api("POST", "/products/COLA-150/set-units", CATALOGUE, {"sales_unit_code": "C62", "units_of": [{"unit_code": "C62", "numerator": 3, "denominator": 2}]}))
    must(api("POST", "/products/COLA-150/activate-product", CATALOGUE, {}))
    must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("COLA-150", "2026-12-01", "2.50", unit_code="LTR")]}))
    assert api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {})[0] == 200
    from_base = must(price("COLA-150", priced_on="2026-12-01"))                                       # a bottle: no row, so LTR x 3/2
    assert (from_base["unit_price"], from_base["priced_from_note"]) == ("3.75", "LTR price x its exact conversion")
    thirds = must(price("COLA-075", unit_code="LTR", priced_on="2026-12-01"))
    assert (thirds["unit_price"], thirds["priced_from_note"]) == ("1.60", "a price per LTR")
    no_row = must(price("COLA-075", unit_code="MLT", priced_on="2026-12-01"))                         # a fixed conversion: 1 LTR = 1000 MLT
    assert (no_row["unit_price"], no_row["priced_from_note"]) == ("0.0016", "LTR price x its exact conversion")
    assert api("GET", "/products/COLA-075/price", SALES, {"currency": "GBP", "unit_code": "KGM"})[0] == 422


def test_prd_26_quantity_breaks_compare_in_the_base_unit():
    per_unit_world()
    must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rules": [
        {"seq": 1, "applies_to": "product", "target": "COLA-075", "min_quantity": "36", "discount_pct": "10", "valid_from": "2026-12-01"}]}))
    assert api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {})[0] == 200
    two_packs = must(price("COLA-075", quantity="2", unit_code="XBX", priced_on="2026-12-01"))        # 36 LTR: the break fires
    assert (two_packs["unit_price"], two_packs["rule"]["seq"]) == ("21.60", 1)
    forty_seven = must(price("COLA-075", quantity="47", priced_on="2026-12-01"))                       # 35.25 LTR: it does not
    assert (forty_seven["unit_price"], forty_seven["rule"]) == ("1.20", None)
    forty_eight = must(price("COLA-075", quantity="48", priced_on="2026-12-01"))                       # 36 LTR in bottles: it does
    assert (forty_eight["unit_price"], forty_eight["rule"]["seq"]) == ("1.08", 1)


def test_a_price_row_is_keyed_by_unit_and_a_price_for_a_unit_the_product_lacks_is_refused():
    mk_product("HOST-M")
    mk_list("LIST-GBP", publish=False)
    assert api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "120.00", unit_code="KGM")]})[0] == 422
    must(api("POST", "/products/HOST-M/add-unit", CATALOGUE, {"unit": {"unit_code": "WK", "numerator": 1, "denominator": 4}}))
    must(api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "120.00"), row("HOST-M", "2026-11-01", "30.00", unit_code="WK")]}))
    rows = must(api("GET", "/price-lists/LIST-GBP/rows", PRICING))["rows"]
    assert sorted((r["unit_code"], r["unit_price"]) for r in rows) == [("MON", "120.00"), ("WK", "30.00")]
    status, body = api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "130.00")]})
    assert status == 409 and "already has a price of 120.00" in body["detail"]                     # the same unit: never edited


def test_listing_and_raising_by_category_use_the_category_on_the_date_not_the_latest_column():
    categories(("hosting", None), ("services", None))
    mk_product("HOST-M", category_code="hosting")
    mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from="2026-10-02")
    must(api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"category_code": "services"}))      # from tomorrow
    assert [p["product_id"] for p in must(api("GET", "/products", SALES, {"category_code": "hosting"}))["products"]] == ["HOST-M"]
    assert must(api("GET", "/products", SALES, {"category_code": "services"}))["products"] == []     # today it is still in hosting
    from conftest import on_day
    with on_day("2026-11-21T09:00:00+00:00"):
        assert must(api("GET", "/products", SALES, {"category_code": "hosting"}))["products"] == []
        assert [p["product_id"] for p in must(api("GET", "/products", SALES, {"category_code": "services"}))["products"]] == ["HOST-M"]
    raise_ = {"raise_pct": "10", "valid_from": "2026-11-30"}
    assert must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {**raise_, "category_code": "services"}))["written"]["rows"] == 1
    assert must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {**raise_, "category_code": "hosting"}))["written"]["rows"] == 0