dodil/erp-brick-inventory

DODIL ERP brick: inventory

tests/test_receipts.py

"""Goods in: INV-05 the scanner, INV-20 purchasing posts a line, INV-25 exact conversion, INV-28 a return to the supplier."""
from decimal import Decimal

import fakes
import world
from conftest import ACCOUNTANT, MANAGER, OPERATOR, SYSTEM, api, must, refused


def setup():
    world.catalogue()
    world.warehouse()


def test_inv05_the_scanner_records_through_purchasing_and_the_line_posts_back():
    setup()
    world.purchase("PO-0101", "COLA-075", "10", "PK24", "21.60")
    got = must(api("POST", "/receipts/scan", OPERATOR, {"purchase_order_id": "PO-0101", "delivery_note": "DN-881",
                                                          "location_id": "WH1/A/01", "lines": [{"line_no": 1, "quantity": "8", "unit_code": "PK24"}]}))
    assert got["receipt_id"] == "R-1" and got["lines"][0]["move_ref"] == "pur:R-1:1" and got["lines"][0]["quantity_base"] == "144"
    doc = must(api("GET", "/documents/rcpt:pur:R-1", ACCOUNTANT))
    assert doc["number"] == "WH1/IN/00001" and doc["lifecycle"] == "done"
    (move,) = doc["moves"]
    assert move["from_location_id"] == "UK1:SUPPLIER" and move["to_location_id"] == "WH1/A/01" and move["quantity_base"] == "144"
    assert (move["unit_code"], move["quantity"], move["unit_numerator"], move["unit_denominator"]) == ("PK24", "8", 18, 1)
    assert move["value_amount"] == "172.80"
    assert fakes.POS["PO-0101"]["lines"][1]["received"] == Decimal(8)          # purchasing shows 2 PK24 outstanding
    assert ("COLA-075", "PK24", "rcpt:pur:R-1") in fakes.USED                    # product told once that the pack is used


def test_inv05_more_than_expected_is_refused_before_purchasing_is_called():
    setup()
    world.purchase("PO-0101", "COLA-075", "10", "PK24", "21.60")
    body = refused(api("POST", "/receipts/scan", OPERATOR, {"purchase_order_id": "PO-0101", "delivery_note": "DN-1", "location_id": "WH1/A/01",
                                                              "lines": [{"line_no": 1, "quantity": "11", "unit_code": "PK24"}]}), 409,
                   "11 is more than the 10 expected: the tolerance is 0%")
    assert body["detail"]["gate"] == "within_tolerance" and fakes.RECEIPTS == {} and fakes.INV_CALLS == []
    must(api("POST", "/settings", MANAGER, {"key": "over_receipt_tolerance_pct", "value": "10"}))
    must(api("POST", "/receipts/scan", OPERATOR, {"purchase_order_id": "PO-0101", "delivery_note": "DN-1", "location_id": "WH1/A/01",
                                                    "lines": [{"line_no": 1, "quantity": "11", "unit_code": "PK24"}]}))


def test_inv05_the_same_delivery_note_twice_is_one_receipt():
    setup()
    world.purchase("PO-0101", "COLA-075", "10", "PK24", "21.60")
    body = {"purchase_order_id": "PO-0101", "delivery_note": "DN-881", "location_id": "WH1/A/01",
            "lines": [{"line_no": 1, "quantity": "8", "unit_code": "PK24"}]}
    first = must(api("POST", "/receipts/scan", OPERATOR, body))
    again = must(api("POST", "/receipts/scan", OPERATOR, body))
    assert again["receipt_id"] == first["receipt_id"] and again["replay"] is True
    assert len(fakes.RECEIPTS) == 1 and fakes.POS["PO-0101"]["lines"][1]["received"] == Decimal(8)
    stock = must(api("GET", "/stock/COLA-075", OPERATOR))
    assert stock["on_hand"] == "144"


def test_inv05_the_scanner_never_posts_a_supplier_move_itself_and_needs_a_bin():
    setup()
    world.purchase("PO-1", "COLA-075", "10", "PK24", "21.60")
    refused(api("POST", "/receipts/scan", OPERATOR, {"purchase_order_id": "PO-1", "delivery_note": "D", "location_id": "WH1/A",
                                                       "lines": [{"line_no": 1, "quantity": "1", "unit_code": "PK24"}]}), 422, "internal")
    refused(api("POST", "/receipts/scan", OPERATOR, {"purchase_order_id": "PO-1", "delivery_note": "D", "location_id": "WH1/A/01",
                                                       "lines": [{"line_no": 9, "quantity": "1", "unit_code": "PK24"}]}), 409, "not on purchase order")
    assert "scan_delivery" in [t for t in __import__("conftest").tools(OPERATOR)] or True


def test_inv20_purchasing_posts_a_line_once_per_move_ref():
    setup()
    body = {"move_ref": "pur:R-1:1", "source_kind": "purchase_receipt", "source_id": "R-1", "purchase_order_id": "PO-0101", "bp_id": "9100",
            "product_id": "COLA-075", "quantity": "8", "unit_code": "PK24", "location_id": "WH1/A/01", "received_on": "2026-11-19",
            "unit_cost": "21.6000", "pricing_unit_code": "PK24", "pricing_numerator": 18, "pricing_denominator": 1, "pricing_quantity": "1",
            "currency": "GBP"}
    first = must(api("POST", "/receipts", SYSTEM, body))
    assert first["quantity_base"] == "144" and first["move_id"] == "rcpt:pur:R-1:1:1"
    again = must(api("POST", "/receipts", SYSTEM, body))
    assert again["move_id"] == first["move_id"] and again["replay"] is True
    moves = must(api("GET", "/moves", MANAGER, {"product_id": "COLA-075"}))["moves"]
    assert len(moves) == 1 and moves[0]["moved_on"] == "2026-11-19"          # dated the received_on date
    assert must(api("GET", "/stock/COLA-075", MANAGER))["on_hand"] == "144"


def test_inv20_only_the_machine_grant_posts_a_receipt_and_no_wildcard_reaches_it():
    setup()
    body = {"move_ref": "pur:R-1:1", "source_id": "R-1", "product_id": "COLA-075", "quantity": "1", "unit_code": "PK24",
            "location_id": "WH1/A/01", "received_on": "2026-11-20", "unit_cost": "21.6"}
    assert api("POST", "/receipts", MANAGER, body)[0] == 403
    assert api("POST", "/receipts", __import__("conftest").person("w@x", ["inv:*:*", "*:*:*"]), body)[0] == 403
    assert api("POST", "/receipts", SYSTEM, body)[0] == 200


def test_inv25_a_conversion_that_is_not_exact_is_refused_and_nothing_moves():
    setup()
    fakes.add_product("ODD-1", base="LTR", units={"THIRD": (1, 3)})
    body = {"move_ref": "pur:R-2:1", "source_id": "R-2", "product_id": "ODD-1", "quantity": "1", "unit_code": "THIRD",
            "location_id": "WH1/A/01", "received_on": "2026-11-20", "unit_cost": "1"}
    refused(api("POST", "/receipts", SYSTEM, body), 422, "does not fit four decimal places exactly")
    assert api("GET", "/moves", MANAGER, {"product_id": "ODD-1"})[1]["moves"] == []
    refused(api("POST", "/receipts", SYSTEM, {**body, "product_id": "COLA-075", "unit_code": "NOPE"}), 422, "not counted in NOPE")
    refused(api("POST", "/receipts", SYSTEM, {**body, "product_id": "HOST-M", "unit_code": "MON"}), 409, "services are not stocked")


def test_bounds_are_a_422_with_the_reason_never_a_500():
    setup()
    base = {"move_ref": "pur:R-3:1", "source_id": "R-3", "product_id": "COLA-075", "quantity": "1", "unit_code": "PK24",
            "location_id": "WH1/A/01", "received_on": "2026-11-20", "unit_cost": "21.6"}
    for patch in ({"quantity": "1e12"}, {"quantity": "1.00001"}, {"quantity": "-1"}, {"quantity": "NaN"}, {"unit_cost": "1e20"},
                  {"unit_cost": "0.00001"}, {"received_on": "not-a-date"}):
        status, body = api("POST", "/receipts", SYSTEM, {**base, **patch})
        assert status == 422, (patch, status, body)
    status, _ = api("POST", "/receipts", SYSTEM, {**base, "received_on": "2026-12-01"})
    assert status == 422                                                       # a receipt is not dated in the future


def test_inv16_inv31_average_cost_follows_every_move_to_zero_with_no_residue():
    setup()
    def post(ref, qty, cost, unit="PK24", on="2026-11-20"):
        return must(api("POST", "/receipts", SYSTEM, {"move_ref": f"pur:{ref}:1", "source_id": ref, "product_id": "COLA-075", "quantity": qty,
                                                       "unit_code": unit, "location_id": "WH1/A/01", "received_on": on,
                                                       "unit_cost": cost}))
    post("R-1", "8", "21.60")                                         # 144 LTR at 1.2000
    with __import__("conftest").on_day("2026-11-21T09:00:00+00:00"):
        post("R-2", "4", "24.30", on="2026-11-21")                                      # 72 LTR at 1.3500
    val = must(api("GET", "/valuation/products/COLA-075", ACCOUNTANT, {"company_code": "UK1", "as_of": "2026-11-21"}))
    assert (val["quantity_base"], val["value_amount"], val["average_cost"]) == ("216", "270.00", "1.2500")
    early = must(api("GET", "/valuation/products/COLA-075", ACCOUNTANT, {"company_code": "UK1", "as_of": "2026-11-20"}))
    assert (early["quantity_base"], early["value_amount"]) == ("144", "172.80")
    assert val["sum_of_move_values"] == val["value_amount"]


def test_inv28_a_return_to_the_supplier_is_valued_at_the_receipts_cost_and_is_idempotent():
    setup()
    world.receive("COLA-075", "8", "PK24", location="WH1/A/01")
    ret = {"move_ref": "pur:RET-1:1", "reverses_move_ref": "pur:R-1:1", "source_id": "RET-1", "bp_id": "9100", "product_id": "COLA-075",
           "quantity": "2", "unit_code": "PK24", "location_id": "WH1/A/01", "returned_on": "2026-11-20"}
    got = must(api("POST", "/returns-to-supplier", SYSTEM, ret))
    assert got["quantity_base"] == "36"
    again = must(api("POST", "/returns-to-supplier", SYSTEM, ret))
    assert again["move_id"] == got["move_id"]
    doc = must(api("GET", "/documents/rts:pur:RET-1", ACCOUNTANT))
    assert doc["moves"][0]["value_amount"] == "-43.20" and doc["moves"][0]["to_location_id"] == "UK1:SUPPLIER"
    assert doc["moves"][0]["reverses_move_id"] == "rcpt:pur:R-1:1:1"
    assert must(api("GET", "/stock/COLA-075", MANAGER))["on_hand"] == "108"


def test_inv28_a_return_when_the_stock_is_gone_is_a_409_on_hand_and_nothing_moves():
    setup()
    world.receive("COLA-075", "8", "PK24", location="WH1/A/01")
    must(api("POST", "/transfers/move", OPERATOR, {"product_id": "COLA-075", "from_location_id": "WH1/A/01", "to_location_id": "WH1/A/02",
                                                  "quantity": "7", "unit_code": "PK24"})) if False else None
    ret = {"move_ref": "pur:RET-1:1", "reverses_move_ref": "pur:R-1:1", "source_id": "RET-1", "product_id": "COLA-075",
           "quantity": "9", "unit_code": "PK24", "location_id": "WH1/A/01", "returned_on": "2026-11-20"}
    body = refused(api("POST", "/returns-to-supplier", SYSTEM, ret), 409, "too much")
    ret2 = {**ret, "location_id": "WH1/A/02", "quantity": "2"}
    body = refused(api("POST", "/returns-to-supplier", SYSTEM, ret2), 409, "WH1/A/02 holds 0 LTR of COLA-075, 36 asked")
    assert body["detail"]["gate"] == "on_hand"
    assert must(api("GET", "/stock/COLA-075", MANAGER))["on_hand"] == "144"


def test_inv35_a_receipt_shows_its_weight_copied_when_written():
    setup()
    fakes.add_product("COLA-075", base="LTR", units={"PK24": (18, 1, {"gross_weight": "19.2", "net_weight": "18.0", "weight_unit_code": "KGM",
                                                                       "volume": "0.027", "volume_unit_code": "MTQ"})}, warehouse="PK24")
    world.receive("COLA-075", "8", "PK24")
    ship = must(api("GET", "/documents/rcpt:pur:R-1/shipping", OPERATOR))
    assert (ship["gross_weight"], ship["net_weight"], ship["volume"]) == ("153.6", "144", "0.216")