dodil/erp-brick-time-off
DODIL ERP brick: time off
tests/test_closures_adjustments.py
"""Slice B item 4: company closure days that consume allowance (TOF-13) and manual adjustments with a reason and a second person (TOF-12)."""
from datetime import date
from decimal import Decimal
from conftest import (
EMPLOYEE,
HR_ADMIN,
MANAGER,
OWNER,
PARTTIMER,
SYSTEM,
api,
as_role,
balance,
entries_of,
must,
on_day,
put_entry,
refused,
request,
tool,
tools,
)
HR_OTHER = as_role("hr_admin", "hr3199@uk.example", 3199)
HR_THIRD = as_role("hr_admin", "hr3198@uk.example", 3198)
XMAS = ("2026-12-29", "2026-12-30", "2026-12-31")
def close(days=XMAS, who=None, company="UK01", **kw):
return [must(api("POST", "/closure-days", who or HR_ADMIN, {"company_code": company, "closure_date": d, "name": "Christmas shutdown", **kw}), 201) for d in days]
# ── TOF-13 ──────────────────────────────────────────────────────────────────────────────────
def test_TOF_13_a_closure_is_taken_from_everyones_allowance_without_anyone_asking(hr):
assert balance()["available"] == "25.00"
close()
got = balance()
assert (got["available"], got["closure_days"]) == ("22.00", "3.00")
assert [(c["date"], c["cost"], c["covered_by"]) for c in got["closures"]] == [(d, "1.00", []) for d in XMAS]
assert entries_of(4410) == [] # derived on read: nothing is booked per person
def test_TOF_13_a_day_already_in_the_persons_own_request_is_covered_and_never_deducted_twice(hr):
rid = request("2026-12-29", "2026-12-31")["request_id"]
close()
got = balance()
assert (got["available"], got["closure_days"]) == ("22.00", "0.00") # the request pays for the three days, the closure does not
assert [c["covered_by"] for c in got["closures"]] == [[rid]] * 3
must(api("POST", f"/leave-requests/{rid}/withdraw", EMPLOYEE))
assert balance()["available"] == "22.00" and balance()["closure_days"] == "3.00" # withdrawn: the closure pays again
def test_TOF_13_booking_a_closure_day_is_not_a_second_cost(hr):
put_entry(4410, "take:4410", "take", "-22", on=date(2026, 6, 1)) # 3.00 left
close()
assert balance()["available"] == "0.00"
got = request("2026-12-29", "2026-12-31") # exactly the closure days: allowed
assert got["cost"] == "3.00"
assert balance()["available"] == "0.00"
def test_TOF_13_a_starter_and_a_part_timer_and_a_leaver_pay_what_their_days_are(hr):
close()
hr.person(4455, "starter@uk.example", start=date(2026, 11, 2), manager=hr.as_manager(2280))
assert balance(who=as_role("employee", "starter@uk.example", 4455))["closure_days"] == "3.00" # derived when the employment starts
assert balance(who=PARTTIMER)["closure_days"] == "15.00" # Tue + Wed at 7.5 hours; Thursday is not theirs
hr.people[4410]["end"] = date(2026, 12, 30)
with on_day("2026-12-30T09:00:00+00:00"):
assert balance(on_date="2026-12-30")["closure_days"] == "2.00" # nothing for the days after the last day
def test_TOF_13_a_public_holiday_and_a_sick_day_are_not_charged(hr):
close(("2026-12-28", "2026-12-29")) # Boxing Day (substitute) is a bank holiday for 4410
assert balance()["closure_days"] == "1.00"
with on_day("2026-12-29T09:00:00+00:00"):
sick = must(api("POST", "/sickness", EMPLOYEE, {"first_day": "2026-12-29", "expected_return": "2026-12-30"}), 201)
got = balance()
assert got["closure_days"] == "0.00" and got["closures"][-1]["covered_by"] == [sick["absence_id"]]
def test_TOF_13_below_zero_is_shown_never_silent(hr):
put_entry(4410, "take:4410", "take", "-24", on=date(2026, 6, 1))
assert balance()["below_zero"] is False
close()
got = balance()
assert (got["available"], got["below_zero"]) == ("-2.00", True)
def test_TOF_13_the_payroll_balance_and_the_year_end_see_the_closure_too(hr):
close()
from conftest import PAYROLL_CLIENT
assert must(api("GET", "/persons/4410/balance", PAYROLL_CLIENT, {"on": "2026-12-31"}))["available"] == "22.00"
with on_day("2027-01-01T09:00:00+00:00"):
got = must(api("POST", "/year-end/run", SYSTEM, {"leave_year": 2026, "person_ids": ["4410"]}))
assert got["carried"][0]["quantity"] == "5.00" and got["lapsed"][0]["quantity"] == "17.00" # 22 left: 5 carried, 17 lapse
def test_TOF_13_publishing_is_hrs_once_per_day_and_not_in_the_past(hr):
body = {"company_code": "UK01", "closure_date": "2026-12-29", "name": "Shutdown"}
for who in (EMPLOYEE, MANAGER, OWNER):
refused(api("POST", "/closure-days", who, body), 403, "tof:closure:write")
close(("2026-12-29",))
refused(api("POST", "/closure-days", HR_ADMIN, body), 409, "already a closure day")
refused(api("POST", "/closure-days", HR_ADMIN, {**body, "closure_date": "2026-11-01"}), 422, "bounds")
refused(api("POST", "/closure-days", HR_ADMIN, {**body, "closure_date": "2026-12-30", "leave_type_code": "UNPAID"}), 409, "leave_type")
refused(api("POST", "/closure-days", HR_ADMIN, {**body, "closure_date": "2026-12-30", "name": " "}), 422, "name")
for bad in ({"closure_date": "soon"}, {"closure_date": "2101-01-01"}, {"name": "x" * 501}, {"company_code": ""}):
assert api("POST", "/closure-days", HR_ADMIN, {**body, "closure_date": "2026-12-31", **bad})[0] == 422, bad
got = must(api("GET", "/closure-days", EMPLOYEE, {"company_code": "UK01"}))
assert [c["closure_date"] for c in got["closure_days"]] == ["2026-12-29"]
assert must(api("GET", "/closure-days", EMPLOYEE, {"company_code": "UK01", "year": 2027}))["closure_days"] == []
refused(api("GET", "/closure-days", EMPLOYEE, {"company_code": "UK01", "year": 1}), 422, "bounds")
def test_TOF_13_another_companys_closure_does_not_touch_this_persons_balance(hr):
close(company="DE01")
assert balance()["available"] == "25.00"
# ── TOF-12 ──────────────────────────────────────────────────────────────────────────────────
def propose(q="1.00", reason="sick during annual leave, fit note 2026-10-07", who=None, person="4410", **kw):
return api("POST", "/balance-adjustments", who or HR_ADMIN, {"person_id": person, "quantity": q, "reason": reason, **kw})
def decide(aid, how="approve", who=None, **kw):
return api("POST", f"/balance-adjustments/{aid}/{how}", who or HR_OTHER, kw or None)
def test_TOF_12_an_adjustment_is_proposed_by_one_hr_user_and_becomes_an_entry_only_when_another_approves(hr):
p = must(propose(), 201)
assert (p["adjustment_id"], p["status"], p["quantity"], p["unit"]) == ("adj:4410:ANNUAL:2026:1", "proposed", "1.00", "days")
assert balance()["available"] == "25.00" and entries_of(4410) == [] # nothing moves on a proposal
got = must(decide(p["adjustment_id"]))
assert (got["status"], got["decided_by"]) == ("approved", "hr3199@uk.example")
assert entries_of(4410) == [("adj:4410:ANNUAL:2026:1", "adjust", "1.0000")]
after = balance()
assert (after["available"], after["adjustments"]) == ("26.00", "1.00")
entry = [e for e in after["entries"] if e["kind"] == "adjust"][0]
assert "sick during annual leave" in entry["reason"] and "approved by hr3199@uk.example" in entry["reason"]
assert must(propose("-0.5"), 201)["adjustment_id"].endswith(":2") # the next proposal is the next number
def test_TOF_12_nobody_decides_their_own_proposal_or_their_own_balance_under_either_login(hr):
p = must(propose(), 201)["adjustment_id"]
refused(decide(p, who=HR_ADMIN), 409, "second_person") # the proposer
refused(decide(p, who=as_role("hr_admin", "hr3100.alt@uk.example", 3100)), 409, "second_person") # the proposer under another email, same sub
refused(propose(person="3100"), 409, "another HR user adjusts it") # HR's own balance
own = must(propose(person="3100", who=HR_OTHER), 201)["adjustment_id"]
refused(decide(own, who=HR_ADMIN), 409, "your own balance") # decided by the person it is about
refused(decide(own, who=as_role("hr_admin", "hr3100.alt@uk.example", 3100)), 409, "your own balance")
assert entries_of(3100) == [] and entries_of(4410) == []
assert must(decide(own, "reject", who=HR_THIRD, reason="not needed"))["status"] == "rejected"
def test_TOF_12_a_decision_is_made_once_and_never_moves_back(hr):
p = must(propose(), 201)["adjustment_id"]
must(decide(p))
for how, kw in (("approve", {}), ("reject", {"reason": "changed my mind"})):
refused(decide(p, how, **kw), 409, "needs it to be proposed")
q = must(propose("2"), 201)["adjustment_id"]
must(decide(q, "reject", reason="not this one"))
refused(decide(q), 409, "needs it to be proposed")
assert entries_of(4410) == [("adj:4410:ANNUAL:2026:1", "adjust", "1.0000")] # the rejected one wrote nothing
assert [(a["adjustment_id"][-1], a["status"]) for a in must(api("GET", "/balance-adjustments", HR_ADMIN))["adjustments"]] == [("1", "approved"), ("2", "rejected")]
assert len(must(api("GET", "/balance-adjustments", OWNER, {"status": "rejected"}))["adjustments"]) == 1
refused(api("GET", "/balance-adjustments", EMPLOYEE), 403)
def test_TOF_12_a_reason_a_sensible_quantity_and_the_permission_are_needed(hr):
refused(propose(reason=" "), 422, "reason")
for q in ("0", "0.00", "abc", "", "1.00001", "401", "-400.5", "NaN", "Infinity", "1e999", "9" * 30):
got = propose(q)
assert got[0] == 422, (q, got)
for who in (EMPLOYEE, MANAGER, OWNER):
refused(propose(who=who), 403, "tof:balance:adjust")
refused(decide("adj:4410:ANNUAL:2026:1", who=who), 403, "tof:balance:adjust")
refused(propose(leave_type_code="UNPAID"), 404, "no balance")
refused(propose(person="99999"), 404)
refused(decide("adj:1:ANNUAL:2026:9"), 404)
refused(propose(on_date="never"), 422, "bounds")
assert "propose_time_off_adjustment" in tools(HR_ADMIN) and "run_tof_year_end" not in tools(HR_ADMIN)
status, got = tool(HR_ADMIN, "propose_time_off_adjustment", {"person_id": "4410", "quantity": "1", "reason": "via the agent"})
assert status == "ok" and got["status"] == "proposed"
def test_TOF_12_an_adjustment_in_hours_for_a_part_timer_and_in_a_past_year(hr):
got = must(propose("7.5", person="5120"), 201)
assert (got["unit"], got["quantity"], got["adjustment_id"]) == ("hours", "7.50", "adj:5120:ANNUAL:2026:1")
must(decide(got["adjustment_id"]))
assert Decimal(balance(who=PARTTIMER)["adjustments"]) == Decimal("7.5")
old = must(propose("1", on_date="2025-06-01"), 201)
assert old["leave_year"] == 2025 and old["adjustment_id"] == "adj:4410:ANNUAL:2025:1"
def test_TOF_17_hr_overrides_a_lapse_by_an_adjustment_in_the_new_year(hr):
put_entry(4410, "take:4410", "take", "-17.5", on=date(2026, 6, 1))
with on_day("2027-01-01T09:00:00+00:00"):
must(api("POST", "/year-end/run", SYSTEM, {"leave_year": 2026, "person_ids": ["4410"]}))
p = must(propose("2.5", reason="sickness prevented the leave: such days carry by law", on_date="2027-01-05"), 201)
must(decide(p["adjustment_id"]))
got = balance(on_date="2027-01-05")
assert (got["carried"], got["adjustments"]) == ("5.00", "2.50")