dodil/erp-brick-time-off
DODIL ERP brick: time off
tests/test_year_end.py
"""Slice B item 3: the year end (TOF-17) - carry-over limits, lapse, expiry, as an idempotent job; the cap in force on the year's last day (TOF-14)."""
from datetime import date
from decimal import Decimal
from conftest import EMPLOYEE, HR_ADMIN, OWNER, PARTTIMER, SYSTEM, api, as_role, balance, entries_of, must, on_day, put_entry, refused
from brick_time_off.application import use_cases
NEW_YEAR, APRIL = "2027-01-01T09:00:00+00:00", "2027-04-01T09:00:00+00:00"
E4425 = "emp4425@uk.example"
def run(year=2026, at=NEW_YEAR, who=None, **kw):
with on_day(at):
return api("POST", "/year-end/run", who or SYSTEM, {"leave_year": year, **kw})
def setup_people(hr):
hr.person(4425, E4425, manager=hr.as_manager(2280), manager2=hr.as_manager(1904))
put_entry(4410, "take:4410", "take", "-24", on=date(2026, 6, 1)) # 25 - 24 = 1.00 left at the year's end
put_entry(4425, "take:4425", "take", "-17.5", on=date(2026, 6, 1)) # 25 - 17.5 = 7.50 left
return as_role("employee", E4425, 4425)
def test_TOF_17_unused_days_carry_up_to_the_cap_and_the_rest_lapse_once(hr):
setup_people(hr)
got = must(run(person_ids=["4410", "4425"]))
assert got["carried"] == [{"person_id": "4410", "quantity": "1.00", "unit": "days"}, {"person_id": "4425", "quantity": "5.00", "unit": "days"}]
assert got["lapsed"] == [{"person_id": "4425", "quantity": "2.50", "unit": "days"}] and got["skipped"] == [] and got["unchanged"] == 0
assert entries_of(4410)[0:1] == [("carry:4410:ANNUAL:2026", "carry", "1.0000")]
assert [e for e in entries_of(4425) if e[1] in ("carry", "lapse")] == [("carry:4425:ANNUAL:2026", "carry", "5.0000"),
("lapse:4425:ANNUAL:2026", "lapse", "-2.5000")]
with on_day(NEW_YEAR):
assert balance(who=EMPLOYEE, on_date="2027-01-04")["carried"] == "1.00"
assert balance(who=as_role("employee", E4425, 4425), on_date="2027-01-04")["carried"] == "5.00"
old = balance(who=as_role("employee", E4425, 4425), on_date="2026-12-31")
assert (old["lapsed"], old["available"]) == ("-2.50", "5.00") # 2026 closes at what carried: the lapse is shown, not hidden
def test_TOF_17_a_second_run_writes_nothing_and_so_does_a_second_worker(hr):
setup_people(hr)
must(run(person_ids=["4410", "4425"]))
before = entries_of()
again = must(run(person_ids=["4410", "4425"]))
assert again["unchanged"] == 2 and again["carried"] == [] and again["lapsed"] == [] and entries_of() == before
assert must(run())["unchanged"] == 2 and entries_of() == before # without a roster: the people the brick holds entries for
def test_TOF_17_a_year_that_has_not_ended_carries_nothing(hr):
setup_people(hr)
got = must(run(at="2026-12-31T09:00:00+00:00", person_ids=["4410"]))
assert got["carried"] == [] and "ends on 2026-12-31" in got["skipped"][0]["skipped"] and not [e for e in entries_of(4410) if e[1] == "carry"]
def test_TOF_14_the_cap_in_force_on_the_years_last_day_decides_not_the_new_years(hr):
setup_people(hr)
with use_cases.repository() as repo: # the owner's row: 3 days from 2027-01-01
repo.settings.put("carry_over_max_days", "3", date(2027, 1, 1), "owner@uk.example", use_cases._now())
assert must(run(person_ids=["4425"]))["carried"][0]["quantity"] == "5.00" # the 2026 year end still carries up to 5
hr.person(4426, "emp4426@uk.example", manager=hr.as_manager(2280))
put_entry(4426, "take:4426", "take", "-17.5", year=2027, on=date(2027, 6, 1))
must(run(year=2027, at="2028-01-01T09:00:00+00:00", person_ids=["4426"]))
assert [e for e in entries_of(4426) if e[1] == "carry"][0][2] == "3.0000" # the 2027 year end is held to 3
def test_TOF_17_the_smaller_of_the_setting_and_the_policy_caps_it(hr):
setup_people(hr)
with use_cases.repository() as repo:
repo.settings.put("carry_over_max_days", "2", date(2026, 1, 1), "owner@uk.example", use_cases._now())
assert must(run(person_ids=["4425"]))["carried"][0]["quantity"] == "2.00"
def test_TOF_17_carried_days_expire_after_their_date_unless_used_and_only_once(hr):
setup_people(hr)
put_entry(4410, "take:4410:feb", "take", "-2", year=2027, on=date(2027, 2, 10)) # 4410 used 2 days of the 1 carried
put_entry(4425, "take:4425:feb", "take", "-4", year=2027, on=date(2027, 2, 10)) # 4425 used 4 of the 5 carried
put_entry(4425, "take:4425:may", "take", "-3", year=2027, on=date(2027, 5, 10)) # after the expiry date: does not count against the carry
must(run(person_ids=["4410", "4425"]))
on_the_day = must(run(at="2027-03-31T09:00:00+00:00", person_ids=["4410", "4425"]))
assert on_the_day["expired"] == [] and not [e for e in entries_of() if e[1] == "expire"] # the 31st is the last day they can be used
got = must(run(at=APRIL, person_ids=["4410", "4425"]))
assert got["expired"] == [{"person_id": "4425", "quantity": "1.00", "unit": "days"}]
assert ("expire:4425:ANNUAL:2026", "expire", "-1.0000") in entries_of(4425) and not [e for e in entries_of(4410) if e[1] == "expire"]
again = must(run(at=APRIL, person_ids=["4410", "4425"]))
assert again["expired"] == [] and len([e for e in entries_of() if e[1] == "expire"]) == 1
with on_day(APRIL):
got = balance(who=as_role("employee", E4425, 4425), on_date="2027-04-02")
assert (got["carried"], got["expired"]) == ("5.00", "-1.00")
def test_TOF_17_a_part_timer_carries_in_hours_at_their_contracted_day(hr):
with on_day(NEW_YEAR):
before = balance(who=PARTTIMER, on_date="2026-12-31")
assert before["unit"] == "hours"
got = must(run(person_ids=["5120"]))
assert got["carried"] == [{"person_id": "5120", "quantity": "37.50", "unit": "hours"}] # 5 days x 7.5 hours
assert Decimal(got["lapsed"][0]["quantity"]) == Decimal(before["available"]) - Decimal("37.5")
def test_TOF_17_a_negative_balance_carries_and_lapses_nothing(hr):
put_entry(4410, "take:4410", "take", "-30", on=date(2026, 6, 1))
got = must(run(person_ids=["4410"]))
assert got["carried"] == [] and got["lapsed"] == [] and not [e for e in entries_of(4410) if e[1] in ("carry", "lapse")]
def test_TOF_17_the_year_end_is_the_workers_alone_and_bounded(hr):
for who in (EMPLOYEE, HR_ADMIN, OWNER):
refused(run(who=who), 403, "tof:year_end:run")
for kw in ({"leave_year": 1999}, {"leave_year": 2101}, {"person_ids": [str(i) for i in range(1, 502)]}, {"person_ids": ["x"]}):
got = api("POST", "/year-end/run", SYSTEM, {"leave_year": 2026, **kw})
assert got[0] == 422, (kw, got)
def test_TOF_17_a_person_hr_cannot_answer_for_is_skipped_with_the_reason_and_the_rest_are_done(hr):
setup_people(hr)
got = must(run(person_ids=["4410", "999999", "4425"]))
assert [c["person_id"] for c in got["carried"]] == ["4410", "4425"]
assert got["skipped"][0]["person_id"] == "999999"
def test_TOF_17_hr_reviews_what_lapsed_and_nobody_else_does(hr):
setup_people(hr)
must(run(person_ids=["4410", "4425"]))
got = must(api("GET", "/year-end/lapses", HR_ADMIN, {"leave_year": 2026}))
assert got["lapses"] == [{"person_id": "4425", "entry_ref": "lapse:4425:ANNUAL:2026", "quantity": "2.50", "unit": "days", "reason": "over the carry-over limit of 5.00"}]
refused(api("GET", "/year-end/lapses", EMPLOYEE, {"leave_year": 2026}), 403)