dodil/erp-brick-time-off

DODIL ERP brick: time off

src/brick_time_off/application/migration.py

"""The Odoo slice (TOF-23, workflow tof_migration_run): Odoo 17's validated leaves as approved requests, its allocations as opening entries - reconciled
per person and type, and per month, to Odoo, then signed by the owner.

The load is READ-ONLY on Odoo's side (a reader port, infrastructure/odoo.py) and idempotent here: every request is keyed odoo:<db>:hr.leave:<id> and recorded in
tof_migration_map, so a second load writes nothing new. History is never re-priced: a leave's cost is Odoo's own number, and the opening entry brings each
person's AVAILABLE days to Odoo's (allocated - taken) whatever this module derives from the policy and hr. A SIGNED run is frozen. Nothing leaves this brick:
there is no outbox, and payroll pulls the absences. Planning reads everything first and only then writes (no read-your-writes inside a transaction)."""
import json
from contextlib import contextmanager
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
from typing import Annotated, Literal

from erp_platform.bricks import Refused
from erp_platform.types import BigId
from pydantic import Field

from brick_time_off.application import use_cases as hub
from brick_time_off.application.inputs import Code, DateText, Reason
from brick_time_off.domain import calendar as cal
from brick_time_off.domain import odoo as od
from brick_time_off.domain import request as rq
from brick_time_off.domain.errors import RuleBroken
from brick_time_off.infrastructure import odoo as odoo_port
from brick_time_off.infrastructure import seams

TOF = hub.TOF
MIG_LOAD, MIG_RECONCILE, MIG_READ, MIG_SIGN = "tof:migration:load", "tof:migration:reconcile", "tof:migration:read", "tof:migration:sign"
CHECKS = ("available_per_person_and_type", "approved_days_per_month")
EmployeeMap = Annotated[dict[Annotated[str, Field(max_length=20)], BigId], Field(max_length=20000)]
LeaveTypeMap = Annotated[dict[Annotated[str, Field(max_length=20)], Code], Field(max_length=200)]
Years = Annotated[list[Annotated[int, Field(ge=2000, le=2100)]], Field(min_length=1, max_length=6)]


@contextmanager
def _odoo():
    """The reader, or the reply that there is none (409) / that Odoo failed (502)."""
    try:
        yield odoo_port.ports.get()
    except odoo_port.OdooUnavailable as down:
        raise Refused(down.http, down.reason) from None


def sign_gates(checks: list) -> list:
    """The gate of sign_tof_migration as {gate, met, reason, fix}: every check equal, or explained by the owner."""
    open_ = [c["check"] for c in checks if not c["equal"] and not c.get("explained")]
    return [{"gate": "checks_reconciled", "met": not open_ and bool(checks), "reason": ("checks neither equal nor explained: " + "; ".join(open_)) if open_ else
             ("the run has not been reconciled" if not checks else ""), "fix": "fix the mapping and load again, or explain each difference"}]


def _run_view(run: dict) -> dict:
    checks = json.loads(run["checks_json"] or "[]")
    return {"run_id": run["run_id"], "odoo_db": run["odoo_db"], "cutover_date": run["cutover_date"].isoformat(), "company_code": run["company_code"],
            "status": run["status"], "doors": json.loads(run["doors_json"] or "{}"), "checks": checks, "summary": json.loads(run["summary_json"] or "{}"),
            "loaded_by": run["loaded_by"], "signed_by": run["signed_by"], "signed_on": run["signed_on"].isoformat() if run["signed_on"] else None,
            "discard_reason": run["discard_reason"], "readiness": sign_gates(checks) if run["status"] == "reconciled" else None}


def _run_id(repo, db: str, cutover: date) -> str:
    base = f"odoo:{db}:{cutover.isoformat()}"
    n, rid = 1, base
    while (run := repo.migration.run(rid)) is not None and run["status"] == "discarded":
        n += 1
        rid = f"{base}:{n}"
    return rid


def _need_run(repo, run_id: str) -> dict:
    run = repo.migration.run(run_id)
    if run is None:
        raise Refused(404, f"no migration run {run_id}")
    return run


def _q2(v: Decimal) -> str:
    return f"{v.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP):.2f}"


class _Years:
    """hr's days and the policy for a person's leave year, asked ONCE per person, type and year however many leaves and allocations the person has."""

    def __init__(self, repo, company: str, actor: str):
        self.repo, self.company, self.actor = repo, company, actor
        self.years: dict = {}
        self.plain: dict = {}

    def year(self, pid: int, lt: dict, on: date):
        for y in self.years.get((pid, lt["leave_type_code"]), []):
            if y.first <= on <= y.last:
                return y
        y = hub._year(self.repo, pid, self.company, lt, on, self.actor)
        self.years.setdefault((pid, lt["leave_type_code"]), []).append(y)
        return y

    def days(self, pid: int, first: date, last: date) -> list:
        key = (pid, first.year, last.year)
        if key not in self.plain:
            span = (date(first.year, 1, 1), date(first.year, 12, 31)) if first.year == last.year else (first, last)
            self.plain[key] = seams.work_pattern(pid, span[0], span[1], self.actor)
        return self.plain[key]

    def day_minutes(self, pid: int, on: date) -> int:
        live = [d.minutes for d in self.days(pid, on, on) if d.employed and d.minutes > 0]
        return max(live, default=450)


def _plan_leave(yrs: _Years, x: dict, lt: dict, pid: int, db: str, company_code: str) -> dict:
    """What one validated Odoo leave becomes: (request, its half days, the take entry), or a reason in words (RuleBroken / Refused)."""
    first, last = od.iso_date(x.get("request_date_from")), od.iso_date(x.get("request_date_to"))
    if first is None or last is None or last < first or (last - first).days >= cal.MAX_SPAN_DAYS:
        raise RuleBroken(f"leave {x['id']} has dates {x.get('request_date_from')} .. {x.get('request_date_to')}")
    fp, tp = od.parts_of(x)
    counts = lt["counts_against"] not in (None, "none")
    if counts:
        y = yrs.year(pid, lt, first)
        if y.ent is None:
            raise RuleBroken(f"leave {x['id']}: no policy for {lt['leave_type_code']} was in force on {first}")
        days, unit, label, policy, day_minutes = y.days, y.ent.unit, y.label, y.ent.policy_code, y.ent.day_minutes
        if last > y.last:
            days = days + yrs.days(pid, y.last + timedelta(days=1), last)
    else:
        days = yrs.days(pid, first, last)
        live = [d for d in days if d.employed]
        unit, label, policy = ("hours" if live and live[0].fte < 1 else "days"), first.year, ""
        day_minutes = yrs.day_minutes(pid, first)
    gone = cal.unemployed_day(days, first, last)
    if gone:
        raise RuleBroken(f"leave {x['id']}: hr says the person was not employed on {gone}")
    slots = cal.slots_of(days, first, last, fp, tp, unit)
    if not slots:
        raise RuleBroken(f"leave {x['id']}: hr says there is no working day in {first} .. {last}")
    rid = od.request_id(db, x["id"])
    cost = od.cost_of(x, unit, day_minutes)
    r = rq.Request(rid, pid, company_code, rq.Identity(), lt["leave_type_code"], policy, label, first, fp, last, tp, cost, unit,
                   status="approved", revision=1, approved_by=f"odoo:{db}", reason="migrated from Odoo")
    return {"request": r, "slots": slots}


@TOF.use_case(kind="command", route="POST /migration/odoo/load", permission=MIG_LOAD, idempotent=True)
def load_tof_from_odoo(caller: dict, odoo_db: Code, cutover_date: DateText, company_code: Code, years: Years, employee_map: EmployeeMap,
                       leave_types: LeaveTypeMap, upstream_signed: bool, waiting: Literal["finished_in_odoo", "re_enter"]) -> dict:
    """Load Odoo's time off READ-ONLY: the leaves in state validate dated in `years` become approved requests keyed odoo:<db>:hr.leave:<id> (with their half
    days, so nobody can book them again, and a take entry), and each person's allocations of a type that counts against a policy become ONE opening entry per
    leave year, open:<person>:<type>:<year> = Odoo's allocated days less what this module derives, so the available days equal Odoo's to the hundredth. The
    doors are answered up front: employee_map (Odoo employee id -> hr person_id: hr offers no migration map), leave_types (Odoo leave-type id -> a leave_type_code
    of this module), upstream_signed (hr's migration is signed: attested), waiting (requests waiting for approval are finished in Odoo first - refused here while
    any remain - or re-entered after cut-over; never carried). Idempotent on the Odoo key: a second load writes nothing new; a signed run is never rewritten."""
    if not upstream_signed:
        raise Refused(409, "hr's migration must be signed first: Odoo's people are hr's", gate="upstream_signed", fix="sign it, then load")
    cutover, who = hub._date(cutover_date, "cutover_date"), hub._who(caller)
    with _odoo() as reader, hub._decide():
        leaves = [x for x in reader.leaves(odoo_db, list(years)) if x.get("state") == "validate"]
        allocs = [x for x in reader.allocations(odoo_db, list(years)) if x.get("state") == "validate"]
        waits = reader.waiting(odoo_db, list(years))
        odoo_types = {od.ref_id(t["id"]) for t in reader.leave_types(odoo_db)}
        problems = [f"Odoo has no leave type {t} (named in leave_types)" for t in sorted(leave_types, key=str) if od.ref_id(t) not in odoo_types]
        people = {od.ref_id(x.get("employee_id")) for x in leaves + allocs}
        problems += [f"Odoo employee {e} has no person in employee_map" for e in sorted(p for p in people if p is not None and str(p) not in employee_map)]
        used = {od.ref_id(x.get("holiday_status_id")) for x in leaves + allocs}
        problems += [f"Odoo leave type {t} has no leave_type_code in leave_types" for t in sorted(u for u in used if u is not None and str(u) not in leave_types)]
        if problems:
            raise Refused(409, "The doors are not answered for everything Odoo holds: " + "; ".join(problems[:20]) + (f" (and {len(problems) - 20} more)" if len(problems) > 20 else ""),
                          gate="doors_decided", fix="complete employee_map and leave_types, then load again")
        if waits and waiting == "finished_in_odoo":
            raise Refused(409, f"{len(waits)} requests are still waiting for approval in Odoo ({', '.join(str(w['id']) for w in waits[:10])}): approve or refuse them in Odoo first, "
                          "or load with waiting re_enter and enter them again here after the cut-over", gate="waiting_requests", fix="finish them in Odoo, or choose re_enter")
        with hub.repository() as repo:
            rid = _run_id(repo, odoo_db, cutover)
            run = repo.migration.run(rid)
            if run is not None and run["status"] == "signed":
                raise Refused(409, f"run {rid} is signed: signed history is never rewritten - start a new run for what changed after the cut-over", gate="not_signed")
            have_l = repo.migration.mapped(odoo_db, "hr.leave", [x["id"] for x in leaves])
            have_a = repo.migration.mapped(odoo_db, "hr.leave.allocation", [x["id"] for x in allocs])
            for model, have in (("hr.leave", have_l), ("hr.leave.allocation", have_a)):
                for oid, row in have.items():
                    prior = repo.migration.run(row["run_id"])
                    if prior and prior["status"] == "signed" and row["run_id"] != rid:
                        raise Refused(409, f"{model} {oid} was carried by the signed run {row['run_id']}: it is not loaded again", gate="not_signed")
            codes = {}
            for t, code in leave_types.items():
                lt = repo.catalog.leave_type(company_code, code, cutover)
                if lt is None:
                    problems.append(f"leave type {code} (Odoo {t}) is not a leave type of {company_code} on {cutover}")
                codes[t] = lt
            yrs = _Years(repo, company_code, who)
            planned, opened = [], []
            for x in sorted((x for x in leaves if str(x["id"]) not in have_l), key=lambda x: int(x["id"])):
                lt = codes.get(str(od.ref_id(x.get("holiday_status_id"))))
                if lt is None:
                    continue
                pid = int(employee_map[str(od.ref_id(x.get("employee_id")))])
                try:
                    p = _plan_leave(yrs, x, lt, pid, odoo_db, company_code)
                    planned.append(p)
                except (RuleBroken, Refused) as e:
                    problems.append(getattr(e, "why", None) or str(e))
            held = {}
            for p in planned:
                r = p["request"]
                for s in p["slots"]:
                    key = (r.person_id, s.date, s.part)
                    if key in held:
                        problems.append(f"{r.request_id} and {held[key]} both hold {s.date} {s.part} of person {r.person_id}")
                    held[key] = r.request_id
            for pid in {p["request"].person_id for p in planned}:
                rows = [p for p in planned if p["request"].person_id == pid]
                first, last = min(p["request"].from_date for p in rows), max(p["request"].to_date for p in rows)
                for c in repo.days.in_range(pid, first, last):
                    for p in rows:
                        if any((s.date, s.part) == (c["absence_date"], c["part"]) for s in p["slots"]):
                            problems.append(f"{p['request'].request_id} overlaps {c['absence_ref']} on {c['absence_date']} {c['part']}")
            groups: dict = {}
            for a in sorted((a for a in allocs if str(a["id"]) not in have_a), key=lambda a: int(a["id"])):
                lt = codes.get(str(od.ref_id(a.get("holiday_status_id"))))
                if lt is None or lt["counts_against"] in (None, "none"):
                    continue                                                 # Odoo's allocation of a type that has no balance here (unpaid, sick) carries no days
                start = od.iso_date(a.get("date_from"))
                if start is None:
                    problems.append(f"allocation {a['id']} has no date_from")
                    continue
                pid = int(employee_map[str(od.ref_id(a.get("employee_id")))])
                try:
                    y = yrs.year(pid, lt, start)
                    amount = od.number(a.get("number_of_days"), "number_of_days")
                except (RuleBroken, Refused) as e:
                    problems.append(f"allocation {a['id']}: {getattr(e, 'why', None) or e}")
                    continue
                if y.ent is None:
                    problems.append(f"allocation {a['id']}: no policy for {lt['leave_type_code']} was in force on {start}")
                    continue
                g = groups.setdefault((pid, lt["leave_type_code"], y.label), {"y": y, "days": Decimal(0), "ids": []})
                g["days"] += amount
                g["ids"].append(a["id"])
            for (pid, code, label), g in sorted(groups.items()):
                ref = f"open:{pid}:{code}:{label}"
                if repo.entries.refs([ref]):
                    for i in g["ids"]:
                        opened.append(("map", i, ref))
                    continue
                y = g["y"]
                bal0, _, _ = hub._balance(repo, pid, code, y, cutover)
                amount = od.in_unit(g["days"], y.ent.unit, y.ent.day_minutes)
                opened.append(("entry", ref, pid, code, label, cal.q4(amount - bal0.available), y.ent.unit, y.first, g["ids"]))
            if problems:
                raise Refused(409, "Odoo's time off cannot be carried as it stands: " + "; ".join(problems[:20]) + (f" (and {len(problems) - 20} more)" if len(problems) > 20 else ""),
                              gate="doors_decided", fix="fix what is named in Odoo or in hr, or the doors, then load again")
            now = hub._now()
            requests = [p["request"] for p in planned]
            claims = [{"person_id": p["request"].person_id, "absence_date": s.date, "part": s.part, "absence_ref": p["request"].request_id, "minutes": s.minutes,
                       "quantity": s.quantity} for p in planned for s in p["slots"]]
            entries = [{"entry_ref": f"req:{r.request_id}:r1", "person_id": r.person_id, "leave_type_code": r.leave_type_code, "leave_year": r.leave_year, "kind": "take",
                        "quantity": -r.cost, "unit": r.unit, "on_date": r.from_date, "by_email": f"odoo:{odoo_db}", "by_sub": None, "reason": "migrated from Odoo"}
                       for r in requests]
            map_rows = [{"odoo_db": odoo_db, "odoo_model": "hr.leave", "odoo_id": r.request_id.rsplit(":", 1)[1], "ours": r.request_id, "run_id": rid} for r in requests]
            for o in opened:
                if o[0] == "map":
                    map_rows.append({"odoo_db": odoo_db, "odoo_model": "hr.leave.allocation", "odoo_id": str(o[1]), "ours": o[2], "run_id": rid})
                    continue
                _, ref, pid, code, label, amount, unit, first, ids = o
                entries.append({"entry_ref": ref, "person_id": pid, "leave_type_code": code, "leave_year": label, "kind": "opening", "quantity": amount, "unit": unit,
                                "on_date": first, "by_email": f"odoo:{odoo_db}", "by_sub": None, "reason": "opening balance from Odoo's allocations"})
                map_rows += [{"odoo_db": odoo_db, "odoo_model": "hr.leave.allocation", "odoo_id": str(i), "ours": ref, "run_id": rid} for i in ids]
            stats = {"leaves": len(leaves), "leaves_written": len(requests), "leaves_unchanged": len(leaves) - len(requests),
                     "openings_written": sum(1 for o in opened if o[0] == "entry"), "allocations": len(allocs),
                     "waiting_not_carried": sorted(int(w["id"]) for w in waits), "days_written": len(claims)}
            if map_rows:
                repo.migration.add_map(map_rows, now)
            repo.migration.add_requests(requests, now)
            repo.migration.add_claims(claims, now)
            repo.migration.add_entries(entries, now)
            doors = {"employees": len(employee_map), "leave_types": dict(leave_types), "years": sorted(set(years)), "upstream_signed": True, "waiting": waiting,
                     "employee_map": dict(employee_map)}
            row = {"run_id": rid, "odoo_db": odoo_db, "cutover_date": cutover, "company_code": company_code, "status": "loaded", "doors_json": json.dumps(doors, sort_keys=True),
                   "checks_json": "[]", "summary_json": json.dumps(stats, sort_keys=True), "loaded_by": who, "signed_by": None, "signed_sub": None, "signed_on": None,
                   "discarded_by": None, "discard_reason": None}
            if run is None:
                repo.migration.add_run(row, now)
            elif map_rows:
                repo.migration.save_run({**run, "status": "loaded", "checks_json": "[]", "summary_json": json.dumps(stats, sort_keys=True)}, now)   # new rows: reconcile again
                row = {**run, "status": "loaded", "checks_json": "[]", "summary_json": json.dumps(stats, sort_keys=True)}
            else:
                row = run
            return _run_view(row) | {"stats": stats}


# ── reconcile, explain, sign, discard ─────────────────────────────────────────────────────────────

@TOF.use_case(kind="command", route="POST /migration/odoo/{run_id}/reconcile", permission=MIG_RECONCILE, idempotent=True)
def reconcile_tof_migration(caller: dict, run_id: Code) -> dict:
    """Compare what was loaded with Odoo, read again, and keep the checks on the run (loaded -> reconciled): the available days per person and leave type
    (Odoo's allocated less taken against this module's available, in Odoo's days) and the approved leave days per month (Odoo's number_of_days against the cost
    of the migrated requests). Each check says Odoo's number, ours and whether they are equal; one that differs has to be fixed or explained before the owner
    signs. A signed run is not touched."""
    with _odoo() as reader, hub._decide():
        with hub.repository() as repo:
            run = _need_run(repo, run_id)
            if run["status"] not in ("loaded", "reconciled"):
                raise Refused(409, f"run {run_id} is {run['status']}: only a loaded run is reconciled")
            db, doors, actor = run["odoo_db"], json.loads(run["doors_json"]), hub._who(caller)
            emp = {int(e): int(p) for e, p in doors["employee_map"].items()}
            leaves = [x for x in reader.leaves(db, doors["years"]) if x.get("state") == "validate"]
            allocs = [x for x in reader.allocations(db, doors["years"]) if x.get("state") == "validate"]
            yrs = _Years(repo, run["company_code"], actor)
            types = {t: repo.catalog.leave_type(run["company_code"], c, run["cutover_date"]) for t, c in doors["leave_types"].items()}
            odoo_av: dict = {}
            on_of: dict = {}
            for kind, rows, field in (("a", allocs, "date_from"), ("t", leaves, "request_date_from")):
                for x in rows:
                    lt = types.get(str(od.ref_id(x.get("holiday_status_id"))))
                    start = od.iso_date(x.get(field))
                    if lt is None or lt["counts_against"] in (None, "none") or start is None:
                        continue
                    pid = emp.get(od.ref_id(x.get("employee_id")))
                    if pid is None:
                        continue
                    y = yrs.year(pid, lt, start)
                    k = (od.ref_id(x.get("employee_id")), lt["leave_type_code"], y.label)
                    odoo_av[k] = odoo_av.get(k, Decimal(0)) + (1 if kind == "a" else -1) * od.number(x.get("number_of_days"), "number_of_days")
                    on_of.setdefault(k, start)
            per_person, ours_total, odoo_total = {}, Decimal(0), Decimal(0)
            for (e, code, label), amount in sorted(odoo_av.items()):
                pid, lt = emp[e], next(t for t in types.values() if t and t["leave_type_code"] == code)
                y = yrs.year(pid, lt, on_of[(e, code, label)])
                bal, _, _ = hub._balance(repo, pid, code, y, run["cutover_date"])
                ours = od.in_days(bal.available, bal.unit, y.ent.day_minutes)
                per_person[f"{e}:{code}:{label}"] = {"odoo": _q2(amount), "ours": _q2(ours)}
                ours_total, odoo_total = ours_total + Decimal(_q2(ours)), odoo_total + Decimal(_q2(amount))
            c1 = {"check": CHECKS[0], "must": "equal", "odoo": _q2(odoo_total), "ours": _q2(ours_total), "detail": per_person,
                  "equal": all(v["odoo"] == v["ours"] for v in per_person.values())}
            odoo_m: dict = {}
            for x in leaves:
                m = od.month_of(od.iso_date(x.get("request_date_from")) or date(2000, 1, 1))
                odoo_m[m] = odoo_m.get(m, Decimal(0)) + od.number(x.get("number_of_days"), "number_of_days")
            ours_m: dict = {}
            mine = [m["ours"] for m in repo.migration.of_run(run_id, "hr.leave")]
            for rid in mine:
                r = repo.requests.get(rid)
                if r is not None:
                    d = od.in_days(r.cost, r.unit, yrs.day_minutes(r.person_id, r.from_date) if r.unit == "hours" else 450)
                    m = od.month_of(r.from_date)
                    ours_m[m] = ours_m.get(m, Decimal(0)) + d
            months = {m: {"odoo": _q2(odoo_m.get(m, Decimal(0))), "ours": _q2(ours_m.get(m, Decimal(0)))} for m in sorted({*odoo_m, *ours_m})}
            c2 = {"check": CHECKS[1], "must": "equal", "odoo": _q2(sum(odoo_m.values(), Decimal(0))), "ours": _q2(sum(ours_m.values(), Decimal(0))), "detail": months,
                  "equal": all(v["odoo"] == v["ours"] for v in months.values())}
            old = {c["check"]: c for c in json.loads(run["checks_json"] or "[]")}
            checks = [{**c, "explained": (old.get(c["check"]) or {}).get("explained") if not c["equal"] else None} for c in (c1, c2)]
            row = {**run, "status": "reconciled", "checks_json": json.dumps(checks, sort_keys=True)}
            repo.migration.save_run(row, hub._now())
            return _run_view(row)


@TOF.use_case(kind="command", route="POST /migration/odoo/{run_id}/explain", permission=MIG_SIGN, idempotent=True)
def explain_tof_check(caller: dict, run_id: Code, check: Literal["available_per_person_and_type", "approved_days_per_month"], reason: Reason) -> dict:
    """The owner explains one difference the reconciliation found (a leave Odoo counts over a bank holiday, a half day Odoo rounded), so that it can be signed
    with it. A check that is equal needs no explanation; the reason is kept on the run."""
    if not reason.strip():
        raise Refused(422, "an explanation needs a reason", gate="bounds")
    with hub.repository() as repo, hub._decide():
        run = _need_run(repo, run_id)
        if run["status"] != "reconciled":
            raise Refused(409, f"run {run_id} is {run['status']}: only a reconciled run has differences to explain")
        checks = json.loads(run["checks_json"])
        c = next(x for x in checks if x["check"] == check)
        if c["equal"]:
            raise Refused(409, f"{check} is equal: there is nothing to explain")
        c["explained"] = f"{reason.strip()} ({hub._who(caller)}, {hub._today(repo)})"
        row = {**run, "checks_json": json.dumps(checks, sort_keys=True)}
        repo.migration.save_run(row, hub._now())
        return _run_view(row)


@TOF.use_case(kind="command", route="POST /migration/odoo/{run_id}/sign", permission=MIG_SIGN, idempotent=True)
def sign_tof_migration(caller: dict, run_id: Code) -> dict:
    """The owner signs the reconciliation (reconciled -> signed, terminal): that nobody gained or lost a day against Odoo, every check equal or explained. A signed
    run is frozen - a later load never rewrites it. Signing twice is one signature."""
    with hub.repository() as repo, hub._decide():
        run = _need_run(repo, run_id)
        if run["status"] == "signed":
            return _run_view(run)
        if run["status"] != "reconciled":
            raise Refused(409, f"run {run_id} is {run['status']}: only a reconciled run is signed", gate="checks_reconciled", fix="reconcile it first")
        gates = sign_gates(json.loads(run["checks_json"]))
        unmet = [g for g in gates if not g["met"]]
        if unmet:
            raise Refused(409, "Cannot sign: " + "; ".join(f"{g['gate']}: {g['reason']}" for g in unmet), gate=unmet[0]["gate"], fix=unmet[0]["fix"], fields={"gates": gates})
        me = hub._me(caller)
        repo.claims.once("migration_sign", run_id, hub._now(), hub._who(caller))
        row = {**run, "status": "signed", "signed_by": me.email or me.sub, "signed_sub": me.sub, "signed_on": hub._today(repo)}
        repo.migration.save_run(row, hub._now())
        return _run_view(row)


@TOF.use_case(kind="command", route="POST /migration/odoo/{run_id}/discard", permission=MIG_SIGN, destructive=True)
def discard_tof_migration(caller: dict, run_id: Code, reason: Reason) -> dict:
    """The owner discards a run that is loaded or reconciled (a reason is required; terminal): the requests, half days, entries and map it wrote are taken back
    out. Refused for a signed run (history) and once a request it carried has moved (cancelled or cut back) - then fix it and load again instead. This is the one
    place a ledger entry is removed: the run's own, before anyone signed it."""
    if not reason.strip():
        raise Refused(422, "a reason is required to discard a run", gate="bounds")
    with hub.repository() as repo, hub._decide():
        run = _need_run(repo, run_id)
        if run["status"] not in ("loaded", "reconciled"):
            raise Refused(409, f"run {run_id} is {run['status']}: only a loaded or reconciled run is discarded")
        ids = [m["ours"] for m in repo.migration.of_run(run_id, "hr.leave")]
        moved = [rid for rid in ids if (r := repo.requests.get(rid)) is not None and (r.status != "approved" or r.revision != 1)]
        if moved:
            raise Refused(409, f"{len(moved)} migrated requests have been cancelled or cut back since the load: it cannot be discarded", gate="nothing_moved",
                          fix="reconcile and fix instead")
        refs = [f"req:{rid}:r1" for rid in ids] + sorted({m["ours"] for m in repo.migration.of_run(run_id, "hr.leave.allocation")})
        repo.migration.delete_run(run_id, ids, refs)
        row = {**run, "status": "discarded", "discarded_by": hub._who(caller), "discard_reason": reason.strip()}
        repo.migration.save_run(row, hub._now())
        return _run_view(row)


@TOF.use_case(kind="query", route="GET /migration/odoo/{run_id}", permission=MIG_READ)
def get_tof_migration_run(caller: dict, run_id: Code) -> dict:
    """One migration run: its status, the doors it was loaded with, the reconciliation's checks (Odoo's number, ours, explained) and what signing still needs."""
    with hub.repository() as repo:
        return _run_view(_need_run(repo, run_id))


@TOF.use_case(kind="query", route="GET /migration/odoo", permission=MIG_READ)
def list_tof_migration_runs(caller: dict) -> dict:
    """Every migration run, oldest first: id, Odoo database, cut-over, status."""
    with hub.repository() as repo:
        return {"runs": [{"run_id": r["run_id"], "odoo_db": r["odoo_db"], "cutover_date": r["cutover_date"].isoformat(), "status": r["status"]} for r in repo.migration.runs()]}


@TOF.use_case(kind="query", route="GET /migration-map/{db}/{model}", permission=MIG_READ)
def get_tof_migration_map(caller: dict, db: Code, model: Literal["hr.leave", "hr.leave.allocation"], ids: Annotated[str, Field(max_length=8000)]) -> list:
    """The one map route every migrating brick offers (shared.yaml conventions.odoo_migration): for a comma-separated list of Odoo ids, [{odoo_model, odoo_id,
    ours}] - our key (a request id, or the opening entry an allocation went into). An id not migrated is omitted, never null and never an error."""
    want = [i.strip() for i in ids.split(",") if i.strip()]
    if not want or not all(i.isdigit() and len(i) <= 18 for i in want):
        raise hub._bounds("ids are Odoo ids, comma-separated digits such as 12,13")
    with hub.repository() as repo:
        got = repo.migration.mapped(db, model, want)
        return [{"odoo_model": model, "odoo_id": i, "ours": got[i]["ours"]} for i in want if i in got]