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