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