dodil/erp-brick-inventory

DODIL ERP brick: inventory

tests/test_issues.py

"""Goods issue and scrap: INV-37, INV-38 and the second person above the threshold (SC-INV-09)."""
import fakes
import world
from conftest import ACCOUNTANT, CONTROLLER, MANAGER, OPERATOR, OWNER, api, must, person, refused


def gloves():
    world.catalogue()
    world.warehouse(bins=("A/05",))
    world.receive("GLOVES", "20", "BX100", price="8.00", location="WH1/A/05")                    # 2000 gloves at 0.0800


def issue(qty="3", cc="MAINT", who=OPERATOR, **kw):
    return api("POST", "/goods-issues", who, {"purpose": "cost_centre", "cost_centre": cc, "lines": [
        {"product_id": "GLOVES", "quantity": qty, "from_location_id": "WH1/A/05", **kw}]})


def test_inv37_an_issue_to_a_cost_centre_is_valued_posted_with_the_cost_centre_and_reversed_once():
    gloves()
    plan = must(issue())
    assert plan["lines"][0]["unit_code"] == "BX100" and plan["lines"][0]["quantity_base"] == "300"                    # the issue unit is proposed
    done = must(api("POST", f"/goods-issues/{plan['document_id']}/post", OPERATOR, {}))
    assert done["number"] == "WH1/GI/00001" and done["moves"][0]["to_location_id"] == "UK1:CONSUMPTION" and done["moves"][0]["quantity_base"] == "300"
    assert done["moves"][0]["quantity"] == "3" and "value_amount" not in str(must(api("GET", f"/goods-issues/{plan['document_id']}", OPERATOR)))
    full = must(api("GET", f"/goods-issues/{plan['document_id']}", ACCOUNTANT))
    assert full["lines"][0]["value_amount"] == "-24.00"
    (j,) = [j for j in fakes.JOURNALS.values() if j["event"] == "issue"]
    by = {l["purpose"]: l for l in j["lines"]}
    assert by["consumption"]["debit"] == "24.00" and by["consumption"]["cost_centre"] == "MAINT" and by["stock_valuation"]["credit"] == "24.00"
    assert must(api("POST", f"/goods-issues/{plan['document_id']}/post", OPERATOR, {}))["replay"] is True
    refused(issue(cc="NOPE", qty="2"), 409, "the ledger has no open cost centre with that code")
    body = refused(api("POST", f"/goods-issues/{plan['document_id']}/reverse", OPERATOR, {"reason": "wrong cost centre"}), 403, "inv:move:reverse")
    rev = must(api("POST", f"/goods-issues/{plan['document_id']}/reverse", MANAGER, {"reason": "wrong cost centre"}))
    assert rev["moves"][0]["quantity_base"] == "300" and rev["moves"][0]["reverses_move_id"]
    assert must(api("GET", "/stock/GLOVES", OPERATOR))["on_hand"] == "2000"
    assert [j for j in fakes.JOURNALS.values() if j["event"] == "reversal"][0]["lines"][0].get("credit") == "24.00"
    refused(api("POST", f"/goods-issues/{plan['document_id']}/reverse", MANAGER, {"reason": "again"}), 409, "already reversed")
    assert body is not None
    assert must(api("GET", "/valuation/products/GLOVES", ACCOUNTANT, {"company_code": "UK1"}))["value_amount"] == "160.00"       # back at the value it left at


def test_inv37_a_closed_ledger_period_refuses_the_reversal():
    gloves()
    plan = must(issue())
    must(api("POST", f"/goods-issues/{plan['document_id']}/post", OPERATOR, {}))
    fakes.CLOSED_PERIODS.add("2026-11")
    body = refused(api("POST", f"/goods-issues/{plan['document_id']}/reverse", MANAGER, {"reason": "late"}), 409, "closed")
    assert body["detail"]["gate"] == "period_open"


def test_inv37_over_the_threshold_a_second_person_decides_by_email_and_by_subject():
    gloves()
    must(api("POST", "/settings", OWNER, {"key": "issue_approval_threshold", "value": "20.00"}))
    plan = must(issue(qty="3"))
    body = refused(api("POST", f"/goods-issues/{plan['document_id']}/post", OPERATOR, {}), 409, "approval threshold")
    assert body["detail"]["gate"] == "under_issue_threshold" and "submit it for a stock controller" in body["detail"]["fix"]
    must(api("POST", f"/goods-issues/{plan['document_id']}/submit", OPERATOR, {}))
    refused(api("POST", f"/goods-issues/{plan['document_id']}/approve", person("stock_operator@shop.example", ["inv:issue:approve"]), {}), 409, "you planned or submitted")
    refused(api("POST", f"/goods-issues/{plan['document_id']}/approve", person("elsewhere@x", ["inv:issue:approve"], sub="stock_operator@shop.example"), {}), 409, "you planned or submitted")
    assert must(api("GET", "/stock/GLOVES", OPERATOR))["on_hand"] == "2000"                                       # nothing moved while it waited
    done = must(api("POST", f"/goods-issues/{plan['document_id']}/approve", CONTROLLER, {}))
    assert done["lifecycle"] == "posted" and done["approved_by"] == "stock_controller@shop.example"
    other = must(issue(qty="3"))
    must(api("POST", f"/goods-issues/{other['document_id']}/submit", OPERATOR, {}))
    assert must(api("POST", f"/goods-issues/{other['document_id']}/reject", CONTROLLER, {"reason": "not needed"}))["lifecycle"] == "cancelled"


def test_inv38_scrap_takes_a_lot_from_a_blocked_bin_to_loss_with_its_reason():
    world.catalogue()
    world.warehouse(extra_bins=[("DAMAGED", {"stock_status": "blocked"})])
    must(api("POST", "/settings", OWNER, {"key": "lots_enabled", "value": "true"}))
    must(api("POST", "/stocked-products", MANAGER, {"product_id": "COLA-075", "tracking": "lot"}))
    world.receive("COLA-075", "2", "PK24", price="21.60", location="WH1/DAMAGED", lot_code="L-2611")
    plan = must(api("POST", "/goods-issues", OPERATOR, {"purpose": "scrap", "scrap_reason": "DAMAGED", "lines": [
        {"product_id": "COLA-075", "quantity": "36", "unit_code": "LTR", "from_location_id": "WH1/DAMAGED", "lot_code": "L-2611", "stock_type": "blocked"}]}))
    done = must(api("POST", f"/goods-issues/{plan['document_id']}/post", OPERATOR, {}))
    assert done["moves"][0]["to_location_id"] == "UK1:LOSS"
    (j,) = [j for j in fakes.JOURNALS.values() if j["event"] == "issue"]
    assert {l["purpose"] for l in j["lines"]} == {"inventory_scrap", "stock_valuation"} and "DAMAGED" in j["lines"][0]["memo"]
    again = must(api("POST", "/goods-issues", OPERATOR, {"purpose": "scrap", "scrap_reason": "DAMAGED", "lines": [
        {"product_id": "COLA-075", "quantity": "9", "unit_code": "LTR", "from_location_id": "WH1/DAMAGED", "lot_code": "L-2611", "stock_type": "blocked"}]}))
    refused(api("POST", f"/goods-issues/{again['document_id']}/post", OPERATOR, {}), 409, "WH1/DAMAGED holds 0 LTR of COLA-075 lot L-2611")
    refused(api("POST", "/goods-issues", OPERATOR, {"purpose": "scrap", "lines": [
        {"product_id": "COLA-075", "quantity": "9", "unit_code": "LTR", "from_location_id": "WH1/DAMAGED", "lot_code": "L-2611"}]}), 422, "scrap names its reason")