dodil/erp-brick-gl
DODIL ERP brick: general ledger
tests/test_allocations.py
"""GL-46 overheads assessed and distributed by agreed rules, GL-47 the key figures they follow."""
from decimal import Decimal
from conftest import ACCOUNTANT, CONTROLLER, api, must, person, refused
from erp_platform.seam import derived_id
from world import books, line, post
CTRL2 = person("ctrl2@firm.example", ["gl:allocation:write", "gl:allocation:approve", "gl:allocation:simulate", "gl:allocation:run",
"gl:keyfigure:write", "gl:journal:read", "gl:company:read", "gl:period:read"])
def setup():
books()
for cc in ("CC-IT", "CC-SHOP1", "CC-SHOP2", "CC-WHS", "CC-HQ"):
must(api("POST", "/cost-centres", ACCOUNTANT, {"cost_centre": cc, "company_code": "UK01"}))
for pc in ("PC-LON", "PC-WHS"):
must(api("POST", "/profit-centres", ACCOUNTANT, {"profit_centre": pc}))
must(api("POST", "/accounts", ACCOUNTANT, {"chart_id": "MAIN", "code": "6990A", "name": "Assessment", "type": "expense"}))
post("COST-IT", "x", "2026-10-10", [line(account="6000", debit="9000.00", cost_centre="CC-IT"), line(account="2100", credit="9000.00")], source_module="inventory")
post("COST-HQ", "x", "2026-10-11", [line(account="6100", debit="800.00", cost_centre="CC-HQ"), line(account="2100", credit="800.00")], source_module="inventory")
CYCLE = {"cycle_id": "OVH-2026", "valid_from_period": "2026-01", "segments": [
{"seq": 1, "kind": "assessment", "rule": "key_figure", "key_figure": "HEADCOUNT", "assessment_account": "6990A",
"sender": {"account_from": "6000", "account_to": "6999", "cost_centres": ["CC-IT"]},
"receivers": [{"cost_centre": "CC-SHOP1"}, {"cost_centre": "CC-SHOP2"}, {"cost_centre": "CC-WHS"}]},
{"seq": 2, "kind": "distribution", "rule": "fixed_pct", "sender": {"account_from": "6100", "cost_centres": ["CC-HQ"]},
"receivers": [{"profit_centre": "PC-LON", "pct": "50"}, {"profit_centre": "PC-WHS", "pct": "50"}]}]}
def figures():
for cc, v in (("CC-SHOP1", "5"), ("CC-SHOP2", "3"), ("CC-WHS", "2")):
must(api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": cc, "value": v}))
def approved_cycle():
setup()
must(api("POST", "/allocation-cycles", CONTROLLER, CYCLE))
must(api("POST", "/allocation-cycles/OVH-2026/approve-allocation-cycle", ACCOUNTANT, {}))
def test_a_controller_defines_but_cannot_approve_and_the_author_cannot_approve_their_own():
setup()
c = must(api("POST", "/allocation-cycles", CONTROLLER, CYCLE))
assert c["status"] == "draft" and c["version"] == 1
assert api("POST", "/allocation-cycles/OVH-2026/approve-allocation-cycle", CONTROLLER, {})[0] == 403
c2 = must(api("POST", "/allocation-cycles", CTRL2, {**CYCLE, "cycle_id": "OVH-B"}))
refused(api("POST", "/allocation-cycles/OVH-B/approve-allocation-cycle", CTRL2, {}), 409, "[second_person]")
assert c2["author"] == "ctrl2@firm.example"
assert must(api("POST", "/allocation-cycles/OVH-B/approve-allocation-cycle", ACCOUNTANT, {}))["status"] == "approved"
def test_the_gates_of_defining_a_cycle():
setup()
bad = {**CYCLE, "segments": [{**CYCLE["segments"][1], "seq": 1, "receivers": [{"profit_centre": "PC-LON", "pct": "50"}, {"profit_centre": "PC-WHS", "pct": "40"}]}]}
refused(api("POST", "/allocation-cycles", CONTROLLER, bad), 422, "total 90, not 100")
bad = {**CYCLE, "segments": [{**CYCLE["segments"][1], "seq": 1, "receivers": []}]}
assert api("POST", "/allocation-cycles", CONTROLLER, bad)[0] == 422
def test_the_simulation_shows_the_shares_and_posts_nothing_and_needs_the_key_figures():
approved_cycle()
refused(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}), 409, "[key_figures_present]", "HEADCOUNT")
figures()
sim = must(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}))
seg1 = sim["segments"][0]["movements"]
assert [(m["role"], m["side"], m["amount"], m["cost_centre"]) for m in seg1] == [
("sender", "credit", "9000.00", "CC-IT"), ("receiver", "debit", "4500.00", "CC-SHOP1"), ("receiver", "debit", "2700.00", "CC-SHOP2"),
("receiver", "debit", "1800.00", "CC-WHS")]
seg2 = sim["segments"][1]["movements"]
assert [(m["side"], m["amount"], m["profit_centre"]) for m in seg2] == [("credit", "800.00", ""), ("debit", "400.00", "PC-LON"), ("debit", "400.00", "PC-WHS")]
assert sim["status"] == "simulated" and sim["run_id"] == "OVH-2026:1:2026-10:LEAD:1"
n_before = len(must(api("GET", "/journals", ACCOUNTANT, {"limit": 100}))["journals"])
assert n_before == 2 # nothing posted by a simulation
def test_posting_writes_one_journal_per_segment_keyed_and_the_amounts_sum_exactly():
approved_cycle()
figures()
must(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}))
run = must(api("POST", "/allocation-cycles/OVH-2026/post-allocation", CONTROLLER, {"period_id": "2026-10"}))
assert run["status"] == "posted" and run["journal_ids"] == [str(derived_id("allocation", "OVH-2026", "2026-10", "LEAD", 1, s)) for s in (1, 2)]
j = must(api("GET", f"/journals/{run['journal_ids'][0]}", ACCOUNTANT))
assert j["book"] == "allocation" and j["allocation_run_id"] == run["run_id"] and j["journal_date"] == "2026-10-31"
assert sum(Decimal(ln["debit"]) for ln in j["lines"]) == sum(Decimal(ln["credit"]) for ln in j["lines"]) == Decimal("9000.00")
# a second post while a run is posted is refused; the key figures are frozen
refused(api("POST", "/allocation-cycles/OVH-2026/post-allocation", CONTROLLER, {"period_id": "2026-10"}), 409, "[prior_run_reversed]")
refused(api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": "CC-WHS", "value": "9"}), 409,
"OVH-2026:1:2026-10:LEAD:1")
# the gate allocations_posted follows
g = must(api("GET", "/periods/2026-10/readiness", ACCOUNTANT))
assert next(x for x in g["gates"] if x["gate"] == "allocations_posted")["met"] is True
def test_reversing_unfreezes_and_lets_run_two_post():
approved_cycle()
figures()
must(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}))
must(api("POST", "/allocation-cycles/OVH-2026/post-allocation", CONTROLLER, {"period_id": "2026-10"}))
rev = must(api("POST", "/allocation-cycles/OVH-2026/reverse-allocation", CONTROLLER, {"period_id": "2026-10", "reason": "wrong headcount"}))
assert rev["status"] == "reversed"
must(api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": "CC-WHS", "value": "4"}))
sim = must(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}))
assert sim["run_no"] == 2 and sim["segments"][0]["movements"][1]["amount"] == "3750.00"
run = must(api("POST", "/allocation-cycles/OVH-2026/post-allocation", CONTROLLER, {"period_id": "2026-10"}))
assert run["run_id"].endswith(":2") and run["status"] == "posted"
tbr = must(api("GET", "/reports/trial-balance", ACCOUNTANT, {"as_at": "2026-10-31"}))
assert Decimal(tbr["difference"]) == 0
def test_a_post_after_the_senders_moved_is_refused_simulate_again():
approved_cycle()
figures()
must(api("POST", "/allocation-cycles/OVH-2026/simulate-allocation", CONTROLLER, {"period_id": "2026-10"}))
post("COST-IT2", "x", "2026-10-12", [line(account="6000", debit="100.00", cost_centre="CC-IT"), line(account="2100", credit="100.00")], source_module="inventory")
refused(api("POST", "/allocation-cycles/OVH-2026/post-allocation", CONTROLLER, {"period_id": "2026-10"}), 409, "[simulation_current]")
def test_key_figures_are_bounded_and_receivers_must_exist():
setup()
assert api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": "CC-SHOP1", "value": "-1"})[0] == 422
assert api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": "CC-SHOP1", "value": "1e15"})[0] == 422
assert api("POST", "/key-figures", CONTROLLER, {"key_figure": "HEADCOUNT", "period_id": "2026-10", "receiver": "NOPE", "value": "1"})[0] == 422
out = must(api("POST", "/key-figures/import", CONTROLLER, {"csv": "key_figure,period_id,receiver,value\nFLOOR,2026-10,CC-SHOP1,120.5\nFLOOR,2026-10,CC-SHOP2,80"}))
assert out["recorded"] == 2
assert api("POST", "/key-figures/import", CONTROLLER, {"csv": "FLOOR,2026-10,NOPE,1"})[0] == 422