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")