dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/domain/sickness.py

"""Sickness: one spell of one person's sickness, RECORDED rather than requested (module.yaml workflows.sickness).

Plain Python - no SQLAlchemy, FastAPI or MCP. What is decided here:
  - the state machine only moves FORWARD: open -> closed | voided; a closed spell takes evidence and nothing else; voided is terminal
  - the facts payroll needs for statutory sick pay - and ONLY facts: time-off never computes pay
      * qualifying days: the days of the spell on which hr says the person is employed and contracted to work (a bank holiday on a contracted
        day is a qualifying day; the day claims, which are what the BALANCE and payroll's `days` read, leave public holidays out)
      * a period of incapacity for work (PIW): four or more consecutive calendar days
      * linked PIWs: a PIW within 56 calendar days (eight weeks) of the end of an earlier PIW is linked to it; payroll adds their weeks
        against the 28-week limit and waives the waiting days of a linked one
  - the fit note: asked from the (fit_note_after_days + 1)th calendar day; evidence_ok when the notes cover every day from there to the end
"""
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Optional

from brick_time_off.domain.errors import RuleBroken
from brick_time_off.domain.request import Identity

STATES = ("open", "closed", "voided")
TERMINAL = ("voided",)
PIW_DAYS = 4                 # SSCBA 1992 s.152: four or more consecutive days of incapacity
LINK_DAYS = 56               # SSCBA 1992 s.152(3): periods separated by not more than eight weeks link

# transition key -> (states it may start from, the state it ends in). Every arrow goes forward; evidence on a closed spell is closed -> closed.
MOVES = {
    "extend_sickness": (("open",), "open"),
    "add_fit_note": (("open",), "open"),
    "add_late_fit_note": (("closed",), "closed"),
    "confirm_return": (("open",), "closed"),
    "void_sickness": (("open",), "voided"),
}


@dataclass
class Sickness:
    absence_id: str
    person_id: int
    company_code: str
    first_day: date
    expected_return: date
    unit: str
    status: str = "open"
    revision: int = 1
    last_day_off: Optional[date] = None
    reason_detail: str = ""
    self_certified: bool = True
    evidence_ok: bool = True
    recorded_by: str = ""
    recorded_sub: str = ""
    person: Identity = Identity()                   # the sick person's own login names, known when they recorded it themselves
    approver_person_id: Optional[int] = None
    approver: Identity = Identity()
    confirmed_by: str = ""
    confirmed_sub: str = ""
    void_reason: str = ""
    qualifying_days: int = 0
    piw: bool = False
    linked_to: str = ""
    linked_group: str = ""
    changed_at: Optional[datetime] = None
    created_at: Optional[datetime] = None

    def move(self, key: str) -> None:
        starts, end = MOVES[key]
        if self.status not in starts:
            raise RuleBroken(f"Sickness {self.absence_id} is {self.status}: {key.replace('_', ' ')} needs it to be {' or '.join(starts)}")
        self.status = end

    @property
    def end(self) -> date:
        """The last day the spell covers now: the day before the expected return while open, the last day off once closed."""
        return self.last_day_off if self.status == "closed" and self.last_day_off else self.expected_return - timedelta(days=1)

    def is_approver(self, me: Identity) -> bool:
        return bool(self.approver.email or self.approver.sub) and me.same_as(self.approver)


def calendar_days(first: date, last: date) -> int:
    return (last - first).days + 1


def self_certified(first: date, last: date, after_days: int) -> bool:
    """No fit note is asked for a spell of at most `after_days` calendar days."""
    return calendar_days(first, last) <= after_days


def evidence_ok(first: date, last: date, after_days: int, covers: list) -> bool:
    """True when every calendar day from the (after_days + 1)th to `last` lies inside some fit note's [from, to] (nothing is asked of a short spell)."""
    need_from = first + timedelta(days=after_days)
    if need_from > last:
        return True
    day = need_from
    for a, b in sorted(covers):
        if b < day:
            continue
        if a > day:
            return False
        day = b + timedelta(days=1)
        if day > last:
            return True
    return day > last


def qualifying_days(days: list, first: date, last: date) -> int:
    """hr's days of [first, last] on which the person is employed and contracted to work - a public holiday on such a day counts."""
    return sum(1 for d in days if first <= d.date <= last and d.employed and d.minutes > 0)


def is_piw(first: date, last: date) -> bool:
    return calendar_days(first, last) >= PIW_DAYS


def link(first: date, earlier: list) -> tuple:
    """(linked_to, linked_group) of a spell starting on `first`, given the person's earlier non-voided spells as [(absence_id, group, first, end)]: the
    latest one that was itself a PIW and ended no more than 56 days before `first`. ('', '') when there is none."""
    best = None
    for aid, group, f, e in earlier:
        if is_piw(f, e) and 0 <= (first - e).days - 1 < LINK_DAYS + 1 and (best is None or e > best[3]):
            best = (aid, group or aid, f, e)
    return (best[0], best[1]) if best else ("", "")