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]