dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/application/sickness.py

"""Sickness (workflow `sickness`, TOF-07 / TOF-08) and what payroll reads of it (TOF-20): a spell is RECORDED, never requested.

The sick person (or their manager, or HR) records it; the manager or HR confirms the return; a fit note is asked from the 8th calendar day. The facts
payroll needs for statutory sick pay are kept on the spell - qualifying days, whether it is a period of incapacity for work (4+ days), and which earlier
spell it links to (within 56 days) - and payroll computes the money. time-off never does.

Who sees what: the sick person everything; HR and the owner the spell, the reason only with tof:sickness:read_detail (the owner has no such grant); the
manager the spell as "sick", never the reason; anyone else a 404. Every transition's key is a use case's name (the signed process names them).
Side effects: none leave this brick. The only outside write is the fit note's object, content-addressed (domain/evidence.py says why that may precede
the row's commit)."""
from contextlib import contextmanager
from datetime import date, timedelta
from typing import Literal, Optional

from erp_platform import auth
from erp_platform.bricks import Refused
from erp_platform.types import BigId

from brick_time_off.application import use_cases as hub
from brick_time_off.application.inputs import DateText, DocType, Document, Note, Reason, Ref
from brick_time_off.domain import calendar as cal
from brick_time_off.domain import evidence as ev
from brick_time_off.domain import request as rq
from brick_time_off.domain import sickness as sk
from brick_time_off.domain.errors import RuleBroken
from brick_time_off.infrastructure import objects, seams

TOF = hub.TOF
SICK_OWN, SICK_TEAM, SICK_DETAIL, ABSENCE_RECORD = "tof:sickness:record_own", "tof:sickness:record_team", "tof:sickness:read_detail", "tof:absence:record"
ABSENCES_FOR_PAYROLL = "tof:absence:read_for_payroll"
MAX_SPELL_DAYS = 366


@contextmanager
def _store():
    """The evidence store, or the reply that there is none (503) / that it failed (502)."""
    try:
        yield objects.ports.evidence()
    except objects.StoreDown as down:
        raise Refused(down.http, down.reason) from None


# ── who is the caller to a spell ──────────────────────────────────────────────────────────────────

def _my_pid(caller: dict) -> Optional[int]:
    sub = _me_sub(caller)
    person = seams.person_by_sub(sub, hub._who(caller)) if sub else None
    return int(person["person_id"]) if person else None


def _me_sub(caller: dict) -> str:
    return hub._me(caller).sub


def _is_sick_person(caller: dict, s: sk.Sickness) -> bool:
    return hub._me(caller).same_as(s.person) or (_my_pid(caller) == s.person_id)


def _level(repo, caller: dict, s: Optional[sk.Sickness]) -> str:
    """How the caller stands to the spell: own | hr | manager - or a 404 (a spell the caller may not see does not exist for them)."""
    if s is not None:
        if auth.has_permission(caller, SICK_OWN) and _is_sick_person(caller, s):
            return "own"
        if auth.has_permission(caller, hub.READ_ALL):
            return "hr"
        if auth.has_permission(caller, SICK_TEAM) and s.is_approver(hub._me(caller)):
            return "manager"
    if not any(auth.has_permission(caller, p) for p in (SICK_OWN, SICK_TEAM, hub.READ_ALL)):
        raise Refused(403, f"missing permission: {SICK_OWN}")
    raise Refused(404, "no such sickness record")


def _visible(repo, caller: dict, absence_id: str) -> tuple:
    s = repo.sickness.get(absence_id)
    return s, _level(repo, caller, s)


def _manager_may_record(repo, caller: dict, today: date) -> bool:
    return repo.settings.on("manager_records_sickness", today).lower() == "true"


def _may_change(repo, caller: dict, s: sk.Sickness, level: str, who: tuple, today: date) -> str:
    """The role the caller acts in - person, manager or hr - when the transition allows it, else a 403. A manager records or confirms only while the
    company lets managers handle sickness (manager_records_sickness); HR always may."""
    if "hr" in who and auth.has_permission(caller, ABSENCE_RECORD):
        return "hr"
    if "person" in who and level == "own":
        return "person"
    if "manager" in who and level == "manager" and _manager_may_record(repo, caller, today):
        return "manager"
    raise Refused(403, "you may not do this to this sickness record", fix="the sick person, their manager or HR does it")


def _no_self(caller: dict, s: sk.Sickness) -> None:
    if _is_sick_person(caller, s):
        raise Refused(409, "You are the person who was sick, so you cannot confirm your own return", gate="second_person", fix="your manager or HR confirms it")


# ── the view ──────────────────────────────────────────────────────────────────────────────────

def _iso(dt) -> Optional[str]:
    """A stored naive-UTC timestamp as an RFC 3339 instant."""
    return None if dt is None else dt.replace(microsecond=0).isoformat() + "Z"


def _view(repo, s: sk.Sickness, level: str, caller: dict, days: Optional[list] = None, notes: Optional[list] = None, with_days: bool = True) -> dict:
    """`days` (Slots) and `notes` (evidence rows) are given by a use case that has just written them: nothing it staged is readable in its own
    transaction (DataK3), so it reports from what it holds."""
    after = hub._int(repo, "fit_note_after_days", s.first_day)
    detail = level == "own" or auth.has_permission(caller, SICK_DETAIL)
    out = {"absence_id": s.absence_id, "person_id": str(s.person_id), "company_code": s.company_code, "leave_type_code": "SICK", "pay_treatment": "sick",
           "status": s.status, "revision": s.revision, "first_day": s.first_day.isoformat(), "expected_return": s.expected_return.isoformat(),
           "last_day_off": s.last_day_off.isoformat() if s.last_day_off else None, "unit": s.unit, "self_certified": s.self_certified,
           "evidence_ok": s.evidence_ok, "fit_note_due_from": (s.first_day + timedelta(days=after)).isoformat(),
           "gates": [{"gate": "fit_note", "met": s.evidence_ok, "reason": "" if s.evidence_ok else f"a fit note is asked from {s.first_day + timedelta(days=after)}, "
                      f"and the notes do not cover every day to {s.end}", "fix": "upload a fit note covering the days"}],
           "payroll": {"qualifying_days": s.qualifying_days, "piw": s.piw, "linked_to": s.linked_to or None, "linked_group": s.linked_group or None},
           "recorded_by": s.recorded_by or None, "confirmed_by": s.confirmed_by or None, "void_reason": s.void_reason or None,
           "reason": (s.reason_detail or None) if detail else "sick"}
    if with_days:
        if days is None:
            days = repo.days.slots(repo.days.in_range(s.person_id, s.first_day, max(s.end, s.expected_return)), s.absence_id)
        if notes is None:
            notes = repo.evidence.of_absence(s.absence_id)
        out["days"] = [{"date": x.date.isoformat(), "part": x.part, "quantity": cal.show(x.quantity, s.unit)} for x in days]
        out["fit_notes"] = [{"object_key": e["object_key"], "kind": e["kind"], "covers_from": e["covers_from"].isoformat(), "covers_to": e["covers_to"].isoformat()}
                            for e in notes]
    return out


def _refresh(repo, s: sk.Sickness, days: list, notes: Optional[list] = None) -> None:
    """The facts payroll reads, from hr's days and the notes held: refreshed whenever the days or the evidence change."""
    after = hub._int(repo, "fit_note_after_days", s.first_day)
    covers = [(e["covers_from"], e["covers_to"]) for e in (repo.evidence.of_absence(s.absence_id) if notes is None else notes)]
    s.qualifying_days = sk.qualifying_days(days, s.first_day, s.end)
    s.self_certified = sk.self_certified(s.first_day, s.end, after)
    s.evidence_ok = sk.evidence_ok(s.first_day, s.end, after, covers)
    s.piw = sk.is_piw(s.first_day, s.end)


# ── recording ─────────────────────────────────────────────────────────────────────────────────

@TOF.use_case(kind="command", route="POST /sickness", status=201)
def record_sickness(caller: dict, first_day: DateText, expected_return: DateText, reason_detail: Optional[Note] = None,
                    person_id: Optional[BigId] = None) -> dict:
    """Record sickness (sickness: none -> open): from the first day off sick (today or a past day) to the day you expect to be back. Your own, or - for a
    manager of the person, or HR - someone else's (person_id). The days so far are claimed in the same transaction as the spell, so a day already in leave
    or another sickness is refused 409 day_free (sickness during approved leave is HR's to correct, TOF-12). Self-certified up to seven calendar days; from
    the 8th a fit note is asked. Numbered like a request (UK01-TOF-2026-00412). The reason is seen only by you and holders of tof:sickness:read_detail."""
    first, back = hub._date(first_day, "first_day"), hub._date(expected_return, "expected_return")
    with hub.repository() as repo, hub._decide():
        today, actor, me = hub._today(repo), hub._who(caller), hub._me(caller)
        mine = _my_pid(caller)
        own = person_id is None or (mine is not None and int(person_id) == mine)
        if own:
            hub._need(caller, SICK_OWN)
        else:
            hub._need(caller, SICK_TEAM)
        if first > today:
            raise hub._bounds(f"sickness is recorded from today ({today}) or a past day, not from {first}")
        if back <= first or (back - first).days > MAX_SPELL_DAYS:
            raise hub._bounds(f"the expected return is after {first} and at most {MAX_SPELL_DAYS} days on")
        pid = int(hub._person(me, actor)["person_id"]) if own else int(person_id)
        end = back - timedelta(days=1)
        emp = seams.employment(pid, first, actor)
        company = emp.get("company_code")
        if not emp.get("employed") or not company:
            raise Refused(409, f"not employed on {first} (hr)", gate="employed", fix="HR checks the start or last day")
        lt = repo.catalog.leave_type(company, "SICK", first)
        if lt is None:
            raise Refused(409, f"No leave type SICK is in force for {company} on {first}", gate="leave_type")
        mgr_pid, mgr = rq.derive_approver(me if own else rq.Identity(), pid, lambda lvl: seams.manager(pid, first, lvl, actor))
        if not own and not auth.has_permission(caller, ABSENCE_RECORD) and not (mgr.same_as(me) and _manager_may_record(repo, caller, today)):
            raise Refused(403, "you record sickness for the people you manage, or as HR", fix="ask the person's manager or HR")
        days = seams.work_pattern(pid, first, end, actor)
        gone = cal.unemployed_day(days, first, end)
        if gone:
            raise Refused(409, f"not employed on {gone} (hr)", gate="employed", fix="HR checks the start or last day")
        live = [d for d in days if d.employed]
        unit = "hours" if live and live[0].fte < 1 else "days"
        slots = cal.slots_of(days, first, end, "full", "full", unit)
        if not slots:
            raise Refused(422, "there is no working day in these dates: nobody is sick on a day they do not work", gate="empty", fix="pick days the person works")
        hub._check_free(repo, pid, slots, first, end)
        number = repo.claims.take("series", f"{company}-{today.year}", hub._now(), actor)
        s = sk.Sickness(f"{company}-TOF-{today.year}-{number:05d}", pid, company, first, back, unit, recorded_by=me.email or me.sub, recorded_sub=me.sub,
                        person=me if own else rq.Identity(), approver_person_id=mgr_pid, approver=mgr, reason_detail=(reason_detail or "").strip())
        s.linked_to, s.linked_group = sk.link(first, repo.sickness.earlier_of_person(pid, first))
        s.linked_group = s.linked_group or s.absence_id
        _refresh(repo, s, days)
        repo.sickness.create(s, hub._now())
        repo.days.add(pid, s.absence_id, slots, hub._now())
        return _view(repo, s, "own" if own else "hr", caller, days=slots, notes=[])


@TOF.use_case(kind="command", route="POST /sickness/{absence_id}/extend")
def extend_sickness(caller: dict, absence_id: Ref, expected_return: DateText) -> dict:
    """Extend the expected return of an open sickness (open -> open, revision +1): the extra days are claimed in the same transaction, so a day already
    booked is refused 409 day_free. By the sick person, their manager or HR. Payroll sees the new revision with changed_since. Bringing the return
    forward is not an extension: the manager confirms the return (confirm_return)."""
    back = hub._date(expected_return, "expected_return")
    with hub.repository() as repo, hub._decide():
        s, level = _visible(repo, caller, absence_id)
        today, actor = hub._today(repo), hub._who(caller)
        _may_change(repo, caller, s, level, ("person", "manager", "hr"), today)
        s.move("extend_sickness")
        if back <= s.expected_return or (back - s.first_day).days > MAX_SPELL_DAYS:
            raise hub._bounds(f"the new return is after {s.expected_return} and at most {MAX_SPELL_DAYS} days from the first day")
        old_end, new_end = s.end, back - timedelta(days=1)
        days = seams.work_pattern(s.person_id, s.first_day, new_end, actor)
        gone = cal.unemployed_day(days, old_end + timedelta(days=1), new_end)
        if gone:
            raise Refused(409, f"not employed on {gone} (hr)", gate="employed")
        slots = cal.slots_of(days, old_end + timedelta(days=1), new_end, "full", "full", s.unit)
        hub._check_free(repo, s.person_id, slots, old_end + timedelta(days=1), new_end)
        repo.claims.once("sick_extend", f"{s.absence_id}:r{s.revision}", hub._now(), actor)
        held = repo.days.slots(repo.days.in_range(s.person_id, s.first_day, old_end), s.absence_id)
        s.revision, s.expected_return = s.revision + 1, back
        _refresh(repo, s, days)
        repo.days.add(s.person_id, s.absence_id, slots, hub._now())
        repo.sickness.save(s, hub._now())
        return _view(repo, s, level, caller, days=held + slots)


def _add_note(caller: dict, absence_id: str, key: str, covers_from: str, covers_to: str, content_type: str, content_base64: str, kind: str) -> dict:
    a, b = hub._date(covers_from, "covers_from"), hub._date(covers_to, "covers_to")
    with hub.repository() as repo, hub._decide(), _store() as store:
        s, level = _visible(repo, caller, absence_id)
        today, actor = hub._today(repo), hub._who(caller)
        _may_change(repo, caller, s, level, ("person", "hr"), today)
        s.move(key)
        if a < s.first_day or b < a or (b - a).days > 400:
            raise hub._bounds(f"a fit note covers days from {s.first_day} on, up to 400 of them")
        try:
            body = ev.decode(content_type, content_base64)
        except RuleBroken as broken:
            raise hub._bounds(str(broken), "send a PDF, JPEG or PNG of at most 3 MiB as base64") from None
        digest = ev.sha256(body)
        okey = ev.object_key(s.absence_id, digest, content_type)
        notes = repo.evidence.of_absence(s.absence_id)
        if repo.evidence.get(okey) is None:
            store.put(okey, body, content_type)
            me = hub._me(caller)
            row = {"object_key": okey, "absence_ref": s.absence_id, "kind": kind, "covers_from": a, "covers_to": b, "sha256": digest,
                   "content_type": content_type, "size_bytes": len(body), "uploaded_by": me.email or me.sub, "uploaded_sub": me.sub,
                   "uploaded_at": hub._now(), "retain_until": ev.retain_until(b)}
            repo.evidence.add(row, hub._now())
            notes = notes + [row]
            _refresh(repo, s, seams.work_pattern(s.person_id, s.first_day, s.end, actor), notes)
            repo.sickness.save(s, hub._now())               # the days never change; payroll reads changed_at
        return _view(repo, s, level, caller, notes=notes)


@TOF.use_case(kind="command", route="POST /sickness/{absence_id}/fit-note")
def add_fit_note(caller: dict, absence_id: Ref, covers_from: DateText, covers_to: DateText, content_type: DocType, content_base64: Document,
                 kind: Literal["fit_note", "other"] = "fit_note") -> dict:
    """Add a fit note to an open sickness (open -> open): the document (PDF, JPEG or PNG, at most 3 MiB, base64) is stored as the object
    raw/time-off/<absence_id>/<sha256>.<ext> with the days it covers. evidence_ok turns true when the notes cover every day from the 8th calendar day to the
    end of the spell; what a missing note does to sick pay is payroll's decision. By the sick person or HR. The same document twice is stored once."""
    return _add_note(caller, absence_id, "add_fit_note", covers_from, covers_to, content_type, content_base64, kind)


@TOF.use_case(kind="command", route="POST /sickness/{absence_id}/late-fit-note")
def add_late_fit_note(caller: dict, absence_id: Ref, covers_from: DateText, covers_to: DateText, content_type: DocType, content_base64: Document,
                      kind: Literal["fit_note", "other"] = "fit_note") -> dict:
    """Add a fit note after the return (closed -> closed): evidence only, the days never change; payroll reads the new evidence_ok with changed_since."""
    return _add_note(caller, absence_id, "add_late_fit_note", covers_from, covers_to, content_type, content_base64, kind)


@TOF.use_case(kind="command", route="POST /sickness/{absence_id}/confirm-return")
def confirm_return(caller: dict, absence_id: Ref, last_day_off: DateText) -> dict:
    """Confirm the return to work (open -> closed): the manager or HR, never the person who was sick (a second login of theirs is refused too). The days
    are fixed at last_day_off (not after today, not after the expected return - extend first) and the half days after it are released in the same
    transaction. Payroll reads the closed spell with its qualifying days."""
    last = hub._date(last_day_off, "last_day_off")
    with hub.repository() as repo, hub._decide():
        s, level = _visible(repo, caller, absence_id)
        today, actor = hub._today(repo), hub._who(caller)
        _no_self(caller, s)
        _may_change(repo, caller, s, level, ("manager", "hr"), today)
        s.move("confirm_return")
        if last < s.first_day or last > today:
            raise hub._bounds(f"the last day off is between {s.first_day} and today ({today})")
        if last > s.end:
            raise Refused(422, f"{last} is after the expected return ({s.expected_return}): extend the sickness first", gate="extend_first", fix="run extend_sickness")
        old_end, me = s.end, hub._me(caller)
        repo.claims.once("sick_close", s.absence_id, hub._now(), actor)
        s.last_day_off, s.confirmed_by, s.confirmed_sub, s.revision = last, me.email or me.sub, me.sub, s.revision + 1
        held = repo.days.slots(repo.days.in_range(s.person_id, s.first_day, old_end), s.absence_id)
        repo.days.release_after(s.person_id, s.absence_id, last, old_end)
        _refresh(repo, s, seams.work_pattern(s.person_id, s.first_day, last, actor))
        repo.sickness.save(s, hub._now())
        return _view(repo, s, level, caller, days=[x for x in held if x.date <= last])


@TOF.use_case(kind="command", route="POST /sickness/{absence_id}/void", destructive=True)
def void_sickness(caller: dict, absence_id: Ref, reason: Reason) -> dict:
    """Void a mistaken record (open -> voided, terminal): HR only, with a reason. Its half days are released in the same transaction; payroll sees it as
    cancelled with changed_since. GAP: the signed gate 'no day of it already in a calculated pay run' needs a seam from payroll that the spec does not
    list, so it is not checked - a spell payroll has already calculated is closed and corrected with an adjustment (TOF-12), by HR's judgement."""
    if not reason.strip():
        raise Refused(422, "a reason is required to void a sickness record", gate="bounds")
    with hub.repository() as repo, hub._decide():
        s, level = _visible(repo, caller, absence_id)
        hub._need(caller, ABSENCE_RECORD)
        s.move("void_sickness")
        repo.claims.once("sick_void", s.absence_id, hub._now(), hub._who(caller))
        repo.days.release(s.person_id, s.absence_id, s.first_day, s.end)
        s.void_reason, s.revision = reason.strip(), s.revision + 1
        repo.sickness.save(s, hub._now())
        return _view(repo, s, level, caller, with_days=False)


# ── reading ───────────────────────────────────────────────────────────────────────────────────

@TOF.use_case(kind="query", route="GET /sickness")
def list_time_off_sickness(caller: dict, scope: Literal["mine", "team", "all"] = "mine") -> dict:
    """The sickness records the caller may see: mine (the sick person), team (a manager's people - with the ones whose fit note is missing flagged),
    all (HR and the owner). Summaries only, reasons never; get_time_off_sickness gives one spell whole."""
    with hub.repository() as repo, hub._decide():
        me = hub._me(caller)
        if scope == "all":
            hub._need(caller, hub.READ_ALL)
            rows = repo.sickness.summaries(everything=True)
        elif scope == "team":
            hub._need(caller, SICK_TEAM)
            rows = repo.sickness.summaries(approver=me)
        else:
            hub._need(caller, SICK_OWN)
            mine = _my_pid(caller)
            rows = repo.sickness.summaries(person_id=mine) if mine is not None else []
        return {"sickness": [{"absence_id": r["absence_id"], "person_id": str(r["person_id"]), "status": r["status"], "first_day": r["first_day"].isoformat(),
                              "expected_return": r["expected_return"].isoformat(), "fit_note_missing": r["status"] == "open" and not r["evidence_ok"],
                              "company_code": r["company_code"]} for r in rows]}


@TOF.use_case(kind="query", route="GET /sickness/{absence_id}")
def get_time_off_sickness(caller: dict, absence_id: Ref) -> dict:
    """One sickness record, whole: its days, fit notes, the gates (a missing fit note) and the facts payroll reads. The reason is shown only to the sick
    person and to holders of tof:sickness:read_detail; a manager sees 'sick'. A record the caller may not see is a 404."""
    with hub.repository() as repo, hub._decide():
        s, level = _visible(repo, caller, absence_id)
        return _view(repo, s, level, caller)


# ── payroll's and timesheets' read (TOF-20) ──────────────────────────────────────────────────────

def _instant(v: Optional[str]):
    from datetime import datetime
    try:
        at = datetime.fromisoformat(str(v).strip().replace("Z", "+00:00"))
    except ValueError:
        raise hub._bounds("changed_since is an RFC 3339 instant like 2026-11-20T09:00:00Z") from None
    return at


@TOF.use_case(kind="query", route="GET /absences", permission=ABSENCES_FOR_PAYROLL, tool=False, aliases={"from_date": "from", "to_date": "to"})
def list_tof_absences_for_payroll(caller: dict, company_code: Ref, from_date: DateText, to_date: DateText, person_id: Optional[BigId] = None,
                                  changed_since: Optional[Ref] = None) -> dict:
    """Payroll and timesheets read the absences of a period (TOF-20): approved requests and recorded sickness only - a request still waiting or rejected is
    never visible - one row per absence with its current revision and its days (date, part, minutes) inside the period, ordered by person then absence_ref.
    Another company's absences never appear. A cancelled or voided absence appears only with changed_since, so a reader learns what to undo. Sickness rows
    carry the facts for statutory sick pay (qualifying days, PIW, linked spell) and evidence_ok; time-off computes no money."""
    a, b = hub._date(from_date, "from"), hub._date(to_date, "to")
    if b < a or (b - a).days > 93:
        raise hub._bounds("from and to are at most 93 days apart, from first")
    since = _instant(changed_since) if changed_since else None
    with hub.repository() as repo, hub._decide():
        reqs = repo.requests.of_company(company_code, a, b, None if person_id is None else int(person_id), since)
        sick = [r for r in repo.sickness.of_company(company_code, a, b, since) if person_id is None or r["person_id"] == int(person_id)]
        refs = [r["request_id"] for r in reqs] + [r["absence_id"] for r in sick]
        held: dict = {}
        for c in repo.days.of_refs(refs, a, b):
            held.setdefault(c["absence_ref"], []).append({"date": c["absence_date"].isoformat(), "part": c["part"], "minutes": c["minutes"]})
        types = {code: repo.catalog.leave_type(company_code, code, a) for code in {r["leave_type_code"] for r in reqs} | {"SICK"}}
        notes: dict = {}
        for e in repo.evidence.of_absences([r["absence_id"] for r in sick]):
            notes.setdefault(e["absence_ref"], []).append(e)
        rows = []
        family = repo.family.of_requests([r["request_id"] for r in reqs])
        kit = repo.family.kit_days(list(family))
        for r in reqs:
            f = family.get(r["request_id"])
            statutory = None if f is None else {"kind": str((types.get(r["leave_type_code"]) or {}).get("pay_treatment", "")).removeprefix("statutory_"),
                                                "ewc": f["ewc"].isoformat() if f["ewc"] else None,
                                                "evidence_received_on": f["evidence_received_on"].isoformat() if f["evidence_received_on"] else None,
                                                "kit_days": len(kit.get(r["request_id"], []))}
            rows.append({"absence_ref": r["request_id"], "revision": r["revision"], "person_id": str(r["person_id"]), "leave_type_code": r["leave_type_code"],
                         "pay_treatment": (types.get(r["leave_type_code"]) or {}).get("pay_treatment"), "status": r["status"], "evidence_ok": True,
                         "statutory": statutory, "changed_at": _iso(r["updated_at"]), "days": held.get(r["request_id"], [])})
        for r in sick:
            rows.append({"absence_ref": r["absence_id"], "revision": r["revision"], "person_id": str(r["person_id"]), "leave_type_code": "SICK",
                         "pay_treatment": (types.get("SICK") or {}).get("pay_treatment"),
                         "status": {"open": "recorded", "closed": "closed", "voided": "cancelled"}[r["status"]], "evidence_ok": bool(r["evidence_ok"]),
                         "statutory": None, "changed_at": _iso(r["changed_at"]), "days": held.get(r["absence_id"], []),
                         "sickness": {"first_day": r["first_day"].isoformat(), "last_day_off": r["last_day_off"].isoformat() if r["last_day_off"] else None,
                                      "self_certified": bool(r["self_certified"]), "qualifying_days": r["qualifying_days"], "piw": bool(r["piw"]),
                                      "linked_to": r["linked_to"], "linked_group": r["linked_group"],
                                      "fit_notes": [{"object_key": e["object_key"], "covers_from": e["covers_from"].isoformat(),
                                                     "covers_to": e["covers_to"].isoformat()} for e in notes.get(r["absence_id"], [])]}})
        rows.sort(key=lambda x: (int(x["person_id"]), x["absence_ref"]))
        return {"company_code": company_code, "from": a.isoformat(), "to": b.isoformat(), "as_of": hub._now().isoformat(), "absences": rows}