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)