dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

src/brick_fixed_assets/application/uc_migration.py

"""The Odoo slice (FA-30, FA-31): account.asset and its posted depreciation -> assets with cost and accumulated depreciation at
cut-over, reconciled per asset and per class to Odoo and to the ledger, then signed by the owner.

The load is READ-ONLY on Odoo's side (a reader port) and idempotent here: every row is keyed `odoo:<db>:<model>:<id>` and
recorded in fa_migration_map, so a second load writes nothing. A takeover posts NO journal (gl migrates the journals); this
module depreciates from the NEXT period. History is never rewritten: a SIGNED run is frozen."""
import hashlib
import json
from datetime import date
from decimal import Decimal
from typing import Optional

from erp_platform.bricks import Refused
from erp_platform.repository import upsert
from erp_platform.types import Name
from sqlalchemy import select

from brick_fixed_assets.application import services
from brick_fixed_assets.application import use_cases as hub
from brick_fixed_assets.application.inputs import Code, IsoDate, Reason
from brick_fixed_assets.domain import odoo as mapping
from brick_fixed_assets.domain.changes import Delta
from brick_fixed_assets.domain.errors import RuleBroken
from brick_fixed_assets.domain.values import ZERO, met, unmet
from brick_fixed_assets.infrastructure import odoo as odoo_port
from brick_fixed_assets.infrastructure import seams
from brick_fixed_assets.infrastructure import tables as T
from brick_fixed_assets.infrastructure.tables import EPOCH, Loaded

FA = hub.FA
DOORS = ("odoo_flavour", "asset_numbers", "draft_assets", "conventions", "opening_pools", "analytic")
DOOR_CHOICES = {"odoo_flavour": ("enterprise", "community"), "asset_numbers": ("keep", "renumber"),
                "draft_assets": ("recreate", "carry"), "conventions": ("keep_asset", "use_class"),
                "opening_pools": ("entered", "none"), "analytic": ("drop", "first")}


def _run_id(db: str, cutover: date) -> str:
    return f"odoo:{db}:{cutover.isoformat()}"


def _load_run(repo, run_id: str) -> dict:
    got = repo.load(T.MIGRATION_RUN, run_id=run_id)
    if got is None:
        raise Refused(404, f"no migration run {run_id}")
    root = got["fa_migration_run"][0]
    return {"root": root, "version": root["version"]}


def _save_run(repo, run: dict) -> int:
    return T.MIGRATION_RUN.save(repo.conn, {"fa_migration_run": [run["root"]]}, expected_version=run["version"],
                                snapshot=repo.spend(T.MIGRATION_RUN, run["root"]["run_id"]))


def _run_view(run: dict) -> dict:
    r = run["root"]
    checks = json.loads(r["checks"] or "[]")
    return {"run_id": r["run_id"], "odoo_db": r["odoo_db"], "cutover_date": r["cutover_date"].isoformat(),
            "status": r["status"], "doors": json.loads(r["doors"] or "{}"), "checks": checks,
            "summary": json.loads(r["checksums"] or "{}"), "loaded_by": r["loaded_by"],
            "signed_by_email": r["signed_by_email"], "signed_on": r["signed_on"].isoformat() if r["signed_on"] else None,
            "readiness": [g.as_dict() for g in sign_gates(checks)], "version": run["version"]}


def sign_gates(checks: list) -> list:
    unexplained = [c["check"] for c in checks if c.get("equal") is False and not c.get("explained")]
    must = [c["check"] for c in checks if c.get("equal") is False and c.get("must") == "equal"]
    return [unmet("zero_unexplained", "checks neither equal nor explained: " + "; ".join(unexplained), "explain each, or fix and reload")
            if unexplained else met("zero_unexplained"),
            unmet("must_equal", "a check that must be equal differs: " + "; ".join(must), "fix the mapping and reload")
            if must else met("must_equal")]


def _gl_map(db: str, model: str, ids: list) -> dict:
    if not ids:
        return {}
    if not seams.GL.installed:
        raise seams.Unavailable("gl is not installed, so the Odoo accounts cannot be mapped to gl account ids "
                                "(accounts_mapped): run the gl migration first", 409)
    r = seams.GL.get(f"/migration-map/{db}/{model}", {"ids": ",".join(str(i) for i in sorted(set(ids)))})
    if not r.ok:
        raise seams.Unavailable(f"gl answered {r.status or 'nothing'} ({r.reason}) for its migration map", 502)
    rows = r.data.get("body") if isinstance(r.data, dict) else r.data
    return {int(x["odoo_id"]): x["ours"] for x in rows or []}


def _same(a, b) -> bool:
    return str(a) == str(b)


def _load_all(caller: dict, odoo_db: str, cutover: date, company_code: str, doors: dict) -> dict:
    """The load, in one transaction: classes, assets, takeover values and the id map. Nothing is posted to gl."""
    reader = odoo_port.reader()
    rows = reader.assets(odoo_db)
    fy_end = seams.fiscal_year_end_month(company_code)
    fy_start = mapping.fy_start_of(cutover, fy_end)
    models = {int(r["id"]): r for r in rows if r.get("state") == "model"}
    carry_rows = []
    for r in rows:
        st = r.get("state")
        if st in ("open", "paused"):
            carry_rows.append(r)
        elif st == "close" and (mapping.iso(r.get("disposal_date")) or date.min) >= fy_start:
            carry_rows.append(r)
        elif st == "draft" and doors["draft_assets"] == "carry":
            carry_rows.append(r)
    accounts = sorted({mapping.ref_id(r.get(f)) for r in carry_rows + list(models.values())
                       for f in ("account_asset_id", "account_depreciation_id", "account_depreciation_expense_id")
                       if mapping.ref_id(r.get(f))})
    gl_accounts = _gl_map(odoo_db, "account.account", accounts)
    missing = [a for a in accounts if a not in gl_accounts]
    if missing:
        raise RuleBroken("Odoo asset, depreciation or expense accounts have no gl account_id (accounts_mapped): "
                         + ", ".join(str(m) for m in missing) + " — run the gl migration first")
    moves = reader.moves(odoo_db, [int(r["id"]) for r in carry_rows], cutover) if carry_rows else []
    by_asset: dict = {}
    for m in moves:
        by_asset.setdefault(mapping.ref_id(m.get("asset_id")), []).append(m)
    run_id = _run_id(odoo_db, cutover)
    stats = {"classes": 0, "assets": 0, "written": 0, "unchanged": 0, "takeovers": 0, "retirements": 0}
    with hub.repository() as repo:
        today = hub.today(repo, True)
        area_code = doors.get("area_code") or next((a["area_code"] for a in repo.posting_areas(today)), None)
        area = repo.area(area_code, today) if area_code else None
        if area is None:
            raise RuleBroken("no book area is defined (areas): the owner defines the depreciation area the register is "
                             "carried in (Settings: areas) before the load")
        if area["posting"] != "per_run":
            raise RuleBroken(f"area {area_code} does not post: carry the register in the book area")
        existing = repo.conn.execute(select(T.fa_migration_run.c.status).where(
            T.fa_migration_run.c.run_id == run_id)).scalar()
        if existing == "signed":
            raise RuleBroken("this run is signed (not_signed): signed history is never rewritten — start a new run for "
                             "what changed after cutover")
        # the run row first (a re-load keeps its checks until reconciled again)
        run = {"root": {"run_id": run_id, "odoo_db": odoo_db, "cutover_date": cutover, "status": "loaded",
                        "doors": json.dumps(doors), "checks": "[]", "checksums": "{}", "loaded_by": services.who(caller),
                        "signed_by_email": None, "signed_by_sub": None, "signed_on": None, "version": 0}, "version": 0}
        # parent -> children ranks
        children: dict = {}
        for r in carry_rows:
            p = mapping.ref_id(r.get("parent_id"))
            if p:
                children.setdefault(p, []).append(r)
        for p in children:
            children[p].sort(key=lambda r: (mapping.iso(r.get("acquisition_date")) or date.min, r["id"]))
        names = {int(r["id"]): r.get("name") or f"asset-{r['id']}" for r in rows}
        # classes (from the models, else from the asset's own accounts) and keys
        class_of: dict = {}
        for r in carry_rows:
            mid = mapping.ref_id(r.get("model_id"))
            model = models.get(mid) if mid else None
            src = model or r
            code = mapping.class_code(model["name"] if model else f"ODOO-{mapping.ref_id(r.get('account_asset_id'))}")
            class_of[int(r["id"])] = code
            method, rate = mapping.method_map(src.get("method"), src.get("method_progress_factor"))
            life = mapping.life_periods(src.get("method_number"), src.get("method_period"))
            conv = mapping.convention(src.get("prorata_computation_type"))
            key_code = f"ODOO-{(src.get('method') or 'linear').upper()}-{life}-{conv}".upper()[:60]
            krow = {"key_code": key_code, "valid_from": EPOCH, "method": method, "rate_pct": rate, "life_periods": life,
                    "base": "net_book_value", "acq_conv": conv, "ret_conv": conv, "stop_at": "scrap", "set_by": "migration"}
            upsert(repo.conn, T.fa_dep_key, [krow], ("key_code", "valid_from"))
            repo.config_written()
            crow = {"class_code": code, "valid_from": EPOCH, "name": (model or r).get("name"), "is_auc": False,
                    "is_low_value": False, "default_ca_pool": "main", "number_format": None, "set_by": "migration"}
            had = repo.row_on(T.fa_class, EPOCH, class_code=code)
            upsert(repo.conn, T.fa_class, [crow], ("class_code", "valid_from"))
            repo.config_written()
            upsert(repo.conn, T.fa_class_area, [{"class_code": code, "area_code": area_code, "valid_from": EPOCH,
                                                 "key_code": key_code, "life_periods": life, "set_by": "migration"}],
                   ("class_code", "area_code", "valid_from"))
            acc = {"class_code": code, "area_code": area_code, "valid_from": EPOCH, "set_by": "migration",
                   "cost_account_id": gl_accounts.get(mapping.ref_id(src.get("account_asset_id"))),
                   "accumulated_depreciation_account_id": gl_accounts.get(mapping.ref_id(src.get("account_depreciation_id"))),
                   "depreciation_expense_account_id": gl_accounts.get(mapping.ref_id(src.get("account_depreciation_expense_id")))}
            upsert(repo.conn, T.fa_account_map, [acc], ("class_code", "area_code", "valid_from"))
            repo.config_written()
            if had is None:
                stats["classes"] += 1
            repo.map_put(odoo_db, "account.asset.class", code, code, run_id)
        # the assets
        from sqlalchemy import insert
        for r in carry_rows:
            oid = int(r["id"])
            asset_id = f"odoo:{odoo_db}:account.asset:{oid}"
            parent = mapping.ref_id(r.get("parent_id"))
            if parent and parent in names:
                asset_no, sub_no = names[parent], 1 + [int(x["id"]) for x in children[parent]].index(oid)
            else:
                asset_no, sub_no = names[oid], 0
            if doors["asset_numbers"] == "renumber":
                asset_no = None
            life = mapping.life_periods(r.get("method_number"), r.get("method_period"))
            mine = by_asset.get(oid, [])
            c = mapping.carry(r, mine, fy_start, cutover, life)
            closed = r.get("state") == "close"
            acc_total = (c.cost - (mapping.num(r.get("book_value")) if closed else c.nbv)) if closed else c.acc_start + c.in_year
            have = repo.asset(asset_id)
            cc = None
            if doors["analytic"] == "first" and r.get("analytic_distribution"):
                dist = r["analytic_distribution"]
                first = sorted(dist)[0] if isinstance(dist, dict) and dist else None
                cc = str(first) if first else None
            mid = mapping.ref_id(r.get("model_id"))
            key_code = f"ODOO-{((models.get(mid) or r).get('method') or 'linear').upper()}-{life}-" \
                       f"{mapping.convention((models.get(mid) or r).get('prorata_computation_type'))}".upper()[:60]
            area_row = {"asset_id": asset_id, "area_code": area_code, "valid_from": EPOCH, "key_code": key_code,
                        "life_periods": life, "total_units": None, "scrap_value": c.scrap, "dep_start": c.first_period}
            tx_id = f"odoo:{odoo_db}:account.asset:{oid}:takeover"
            values = {"cost": c.cost, "accumulated_depreciation": acc_total, "depreciation_in_year": c.in_year}
            stats["assets"] += 1
            if have is None:
                if asset_no is None:
                    cls = class_of[oid]
                    asset_no = repo.claim_number(cls, today.year, repo.klass(cls, today)["number_format"] or
                                                 repo.setting("asset_number_format", today), asset_id)
                else:
                    cls = class_of[oid]
                    fmt = repo.klass(cls, today)["number_format"] or repo.setting("asset_number_format", today)
                    if not repo.claim_name(asset_no, "odoo", asset_id, cls, fmt):          # a name used twice in Odoo
                        asset_no = f"{asset_no}-{oid}"
                        repo.claim_name(asset_no, "odoo", asset_id, cls, fmt)
                root = {"asset_id": asset_id, "company_code": company_code, "asset_no": asset_no, "sub_no": sub_no,
                        "class_code": class_of[oid], "description": r.get("name"), "serial_no": None, "inventory_no": None,
                        "bp_id": None, "capitalised_on": mapping.iso(r.get("acquisition_date")) or cutover,
                        "status": "retired" if closed else "active", "last_counted_on": None, "quantity": Decimal("1"),
                        "request_id": asset_id, "is_auc": False, "is_car": False, "new_and_unused": False,
                        "ca_pool": "main", "deactivated_reason": None, "created_by": "migration", "version": 0}
                a = Loaded(root, [{"asset_id": asset_id, "valid_from": EPOCH, "cost_centre": cc, "location_id": None,
                                   "custodian_email": None, "set_by_email": "migration"}], [area_row], 0)
                a.version = repo.save_asset(a)
                repo.conn.execute(insert(T.fa_asset_request).values(request_id=asset_id, asset_id=asset_id,
                                                                    company_code=company_code))
                prm = {"offset": None, "source": "odoo", "odoo_id": oid}
                if not closed:
                    prm["areas"] = {area_code: {"depreciable": str(max(c.nbv - c.scrap, ZERO)), "remaining": c.remaining,
                                                "first_period": c.first_period}}
                services.new_tx(repo, caller, transaction_id=tx_id, asset=a, kind="takeover", value_date=cutover,
                                deltas={area_code: Delta(
                                    cost=c.cost, acc_dep=acc_total, depreciation_in_year=c.in_year)},
                                params=prm, status="posted", source_ref=asset_id)
                if closed:
                    dd = mapping.iso(r.get("disposal_date")) or cutover
                    services.new_tx(repo, caller, transaction_id=f"odoo:{odoo_db}:account.asset:{oid}:retirement",
                                    asset=a, kind="retirement", value_date=dd,
                                    deltas={area_code: Delta(
                                        cost=-c.cost, acc_dep=-acc_total)},
                                    params={"offset": None, "share_of_remaining": "1/1", "orig_share": "1/1", "source": "odoo"},
                                    status="posted", source_ref=asset_id)
                    stats["retirements"] += 1
                stats["takeovers"] += 1
                stats["written"] += 1
                repo.map_put(odoo_db, "account.asset", oid, asset_id, run_id)
            else:
                took = repo.conn.execute(select(T.fa_migration_map.c.run_id).where(
                    T.fa_migration_map.c.odoo_db == odoo_db, T.fa_migration_map.c.odoo_model == "account.asset",
                    T.fa_migration_map.c.odoo_id == str(oid))).scalar()
                if took and took != run_id and repo.conn.execute(select(T.fa_migration_run.c.status).where(
                        T.fa_migration_run.c.run_id == took)).scalar() == "signed":
                    raise Refused(409, {"gate": "not_signed", "met": False,
                                        "reason": f"asset {asset_id} (Odoo account.asset {oid}) was taken over by the signed run "
                                                  f"{took}: signed history is never rewritten, and this run ({run_id}) would "
                                                  "overwrite its takeover", "fix": "leave it to the signed run: what Odoo posted "
                                                  "after that cutover is depreciated here from the next period, not re-loaded"})
                tx = repo.transaction(tx_id)
                changed = tx is not None and not all(_same(tx["areas"][area_code][k], v) for k, v in values.items())
                ar = have.area_on(area_code, EPOCH)
                if ar and not (_same(ar["life_periods"], life) and _same(ar["scrap_value"], c.scrap) and
                               ar["key_code"] == key_code):
                    ar.update({"life_periods": life, "scrap_value": c.scrap, "key_code": key_code, "dep_start": c.first_period})
                    have.version = repo.save_asset(have)
                    changed = True
                if changed and tx is not None:
                    for k, v in values.items():
                        tx["areas"][area_code][k] = v
                    prm = json.loads(tx["root"]["params"] or "{}")
                    if not closed:
                        prm["areas"] = {area_code: {"depreciable": str(max(c.nbv - c.scrap, ZERO)), "remaining": c.remaining,
                                                    "first_period": c.first_period}}
                    tx["root"]["params"] = json.dumps(prm, sort_keys=True)
                    repo.save_transaction(tx["root"], tx["areas"], tx["version"])
                stats["written" if changed else "unchanged"] += 1
        # the run row last: keep a reconciled run's status only if nothing changed
        prior = repo.conn.execute(select(T.fa_migration_run).where(
            T.fa_migration_run.c.run_id == run_id)).first()
        if prior is not None:
            p = dict(prior._mapping)
            if stats["written"] == 0 and p["status"] in ("loaded", "reconciled"):
                # nothing differs: write NOTHING, not even the run row (no version bump, no new checksum)
                return _run_view(_load_run(repo, run_id)) | {"stats": stats}
            else:
                run["root"]["version"] = p["version"]
                run["version"] = p["version"]
        run["root"]["checksums"] = run["root"]["checksums"] if run["root"]["status"] == "reconciled" else json.dumps(stats)
        run["root"]["doors"] = json.dumps(doors)
        run["version"] = _save_run(repo, run)
        return _run_view(run) | {"stats": stats}


def _doors_gate(doors: dict) -> None:
    missing = [d for d in DOORS if not doors.get(d)]
    if missing:
        raise RuleBroken("a migration door is undecided (doors_decided): " + ", ".join(missing) + " — Migration: answer it")
    for d, choices in DOOR_CHOICES.items():
        if doors[d] not in choices:
            raise RuleBroken(f"door {d} is one of {', '.join(choices)}")


@FA.use_case(kind="command", route="POST /migration/odoo/load", permission=hub.MIG_LOAD, idempotent=True)
def load_assets_from_odoo(caller: dict, odoo_db: Code, cutover_date: IsoDate, company_code: Code, odoo_flavour: Code,
                          asset_numbers: Code, draft_assets: Code, conventions: Code, opening_pools: Code, analytic: Code,
                          area_code: Optional[Code] = None) -> dict:
    """Load the Odoo asset register READ-ONLY (FA-30): asset models become classes with their keys and accounts (through gl's
    map), running and paused assets (and closed ones disposed of in the cut-over fiscal year) become assets with a TAKEOVER
    transaction in the book area — cost, accumulated depreciation at the start of the fiscal year, the year's charge, the life
    left — and NO journal (gl migrates the journals). The doors (flavour, numbers, drafts, conventions, opening pools, analytic)
    must all be answered. Every row is keyed odoo:<db>:<model>:<id>: a second load changes nothing; a SIGNED run is never
    rewritten. Depreciation runs here from the next period."""
    doors = {"odoo_flavour": odoo_flavour, "asset_numbers": asset_numbers, "draft_assets": draft_assets,
             "conventions": conventions, "opening_pools": opening_pools, "analytic": analytic, "area_code": area_code}
    with hub.decide():
        _doors_gate({k: v for k, v in doors.items() if k != "area_code"})
        return _load_all(caller, odoo_db, mapping.iso(cutover_date), company_code, doors)


@FA.use_case(kind="command", route="POST /migration/odoo/{run_id}/reload", permission=hub.MIG_LOAD, idempotent=True)
def reload_assets_migration(caller: dict, run_id: Code, company_code: Code) -> dict:
    """Load again after a fix (a mapping corrected, a gl account migrated): the same doors, the same keys, only what differs is
    written. Refused once the run is signed (not_signed)."""
    with hub.repository() as repo, hub.decide():
        run = _load_run(repo, run_id)
        if run["root"]["status"] == "signed":
            raise RuleBroken("this run is signed (not_signed): signed history is never rewritten — start a new run for "
                             "what changed after cutover")
        if run["root"]["status"] == "discarded":
            raise RuleBroken("this run was discarded: start a new load")
        doors = json.loads(run["root"]["doors"] or "{}")
        db, cutover = run["root"]["odoo_db"], run["root"]["cutover_date"]
    with hub.decide():
        out = _load_all(caller, db, cutover, company_code, doors)
        if out["stats"]["written"]:
            with hub.repository() as repo:
                r = _load_run(repo, run_id)
                r["root"]["status"] = "loaded"
                r["version"] = _save_run(repo, r)
                return _run_view(r) | {"stats": out["stats"]}
        return out


def _checks(repo, run: dict, company_code: str) -> list:
    r = run["root"]
    db, cutover = r["odoo_db"], r["cutover_date"]
    reader = odoo_port.reader()
    rows = reader.assets(db)
    fy_start = mapping.fy_start_of(cutover, seams.fiscal_year_end_month(company_code))
    ours = {a["asset_id"]: a for a in repo.asset_roots(company_code) if a["asset_id"].startswith(f"odoo:{db}:account.asset:")}
    carried = [x for x in rows if f"odoo:{db}:account.asset:{x['id']}" in ours]
    moves = reader.moves(db, [int(x["id"]) for x in carried], cutover) if carried else []
    by_asset: dict = {}
    for m in moves:
        by_asset.setdefault(mapping.ref_id(m.get("asset_id")), []).append(m)
    today = repo.today()
    area = next((a["area_code"] for a in repo.posting_areas(today)), None)
    out = []

    def check(name: str, odoo, ours_v, equal: bool, must: str, explanation: str = "", differences=None) -> None:
        out.append({"check": name, "odoo": odoo, "ours": ours_v, "equal": equal, "must": must,
                    "explanation": explanation, "explained": False, "differences": differences or []})

    # 1. asset count by state
    def odoo_state(x): return "retired" if x.get("state") == "close" else "active"
    oc, uc = {}, {}
    for x in carried:
        oc[odoo_state(x)] = oc.get(odoo_state(x), 0) + 1
    for a in ours.values():
        uc[a["status"]] = uc.get(a["status"], 0) + 1
    check("asset count by state", oc, uc, oc == uc, "equal", "Odoo open|paused = active, closed = retired")
    # 2, 5, 6 per asset
    nbv_diff, life_diff = [], []
    odoo_nbv = our_nbv = ZERO
    per_class: dict = {}
    for x in carried:
        aid = f"odoo:{db}:account.asset:{x['id']}"
        v = services.area_values(repo, aid, area)
        ob = mapping.num(x.get("book_value")) if x.get("book_value") not in (None, False) else \
            mapping.num(x.get("value_residual")) + mapping.num(x.get("salvage_value"))
        odoo_nbv += ob
        our_nbv += v.nbv
        if ob != v.nbv:
            nbv_diff.append({"asset_id": aid, "odoo": str(ob), "ours": str(v.nbv)})
        life = mapping.life_periods(x.get("method_number"), x.get("method_period"))
        _, _, n = mapping.split(by_asset.get(int(x["id"]), []), fy_start, cutover)
        a = repo.asset(aid)
        tx = repo.transaction(f"odoo:{db}:account.asset:{x['id']}:takeover")
        prm = json.loads(tx["root"]["params"] or "{}") if tx else {}
        ar = (prm.get("areas") or {}).get(area)
        if ar and (life - n) != int(ar["remaining"]):
            life_diff.append({"asset_id": aid, "odoo": life - n, "ours": ar["remaining"]})
        cls = a.root["class_code"]
        slot = per_class.setdefault(cls, {"cost": ZERO, "acc": ZERO, "dep_year": ZERO, "odoo_year": ZERO})
        slot["cost"] += v.cost + (ZERO)
        slot["acc"] += v.acc_dep
        slot["dep_year"] += (tx["areas"][area]["depreciation_in_year"] or ZERO) if tx else ZERO
        slot["odoo_year"] += mapping.split(by_asset.get(int(x["id"]), []), fy_start, cutover)[1]
        slot.setdefault("accounts", (mapping.ref_id(x.get("account_asset_id")), mapping.ref_id(x.get("account_depreciation_id"))))
    check("NBV per asset at cutover", str(odoo_nbv), str(our_nbv), not nbv_diff, "equal", "cost - accumulated depreciation "
          "per asset in the book area", nbv_diff)
    check("remaining life per asset", len(carried), len(carried) - len(life_diff), not life_diff, "equal",
          "assets whose remaining life matches Odoo's method_number minus its posted depreciation moves", life_diff)
    # 3, 4 per class account against Odoo's trial balance
    cost_d, acc_d = [], []
    for cls, s in sorted(per_class.items()):
        oc_acc, od_acc = s["accounts"]
        if oc_acc:
            tb = reader.trial_balance(db, oc_acc, cutover)
            if tb != s["cost"]:
                cost_d.append({"class_code": cls, "odoo": str(tb), "ours": str(s["cost"])})
        if od_acc:
            tb = -reader.trial_balance(db, od_acc, cutover)
            if tb != s["acc"]:
                acc_d.append({"class_code": cls, "odoo": str(tb), "ours": str(s["acc"])})
    check("cost per class account at cutover", "trial balance of account_asset_id", str(sum((s["cost"] for s in per_class.values()), ZERO)),
          not cost_d, "equal_or_explained", "equal, or explained where a manual journal hit the account in Odoo", cost_d)
    check("accumulated depreciation per class account at cutover", "trial balance of account_depreciation_id",
          str(sum((s["acc"] for s in per_class.values()), ZERO)), not acc_d, "equal_or_explained",
          "equal, or explained likewise", acc_d)
    yd = [{"class_code": c, "odoo": str(s["odoo_year"]), "ours": str(s["dep_year"])} for c, s in sorted(per_class.items())
          if s["odoo_year"] != s["dep_year"]]
    check("depreciation charged in the fiscal year to cutover per class", str(sum((s["odoo_year"] for s in per_class.values()), ZERO)),
          str(sum((s["dep_year"] for s in per_class.values()), ZERO)), not yd, "equal", "SUM(depreciation_value) of posted moves "
          "dated in the year", yd)
    out.append({"check": "gl's asset accounts against the register", "odoo": "-", "ours": "gl asks GET /assets/balances as of the "
                "cutover date", "equal": None, "must": "informational", "explanation": "gl compares its trial balance with this "
                "register's balances", "explained": False, "differences": []})
    return out


@FA.use_case(kind="command", route="POST /migration/odoo/{run_id}/reconcile", permission=hub.MIG_RECONCILE, idempotent=True)
def reconcile_assets_migration(caller: dict, run_id: Code, company_code: Code) -> dict:
    """Reconcile a loaded run against Odoo (FA-31): every check computed on both sides with the difference — asset count by
    state, NBV per asset, cost and accumulated depreciation per class account against Odoo's trial balance, the year's
    charge per class, remaining life per asset. Each is {check, odoo, ours, equal, explanation}; the account checks may be
    explained where a manual journal hit the account in Odoo. Explanations already given are kept."""
    with hub.repository() as repo, hub.decide():
        run = _load_run(repo, run_id)
        r = run["root"]
        if r["status"] in ("signed", "discarded"):
            raise RuleBroken(f"this run is {r['status']}: it is not reconciled again")
        old = {c["check"]: c for c in json.loads(r["checks"] or "[]")}
        checks = _checks(repo, run, company_code)
        for c in checks:
            was = old.get(c["check"])
            if was and was.get("explained") and was.get("differences") == c["differences"] and c["equal"] is False:
                c["explained"], c["explanation"] = True, was["explanation"]
        r["checks"] = json.dumps(checks)
        r["status"] = "reconciled"
        r["checksums"] = json.dumps({"checks": hashlib.sha256(json.dumps(
            [(c["check"], c["odoo"], c["ours"], c["equal"]) for c in checks], sort_keys=True, default=str).encode()).hexdigest()})
        run["version"] = _save_run(repo, run)
        return _run_view(run)


@FA.use_case(kind="command", route="POST /migration/odoo/{run_id}/explain", permission=hub.MIG_READ)
def explain_assets_difference(caller: dict, run_id: Code, check: Name, reason: Reason) -> dict:
    """Explain a difference that may be explained (the cost and accumulated depreciation per class account, where a manual journal
    hit the account in Odoo), with a reason. A check that must be equal cannot be explained away: fix the mapping and reload."""
    with hub.repository() as repo, hub.decide():
        run = _load_run(repo, run_id)
        r = run["root"]
        if r["status"] != "reconciled":
            raise RuleBroken(f"this run is {r['status']}: reconcile it first")
        checks = json.loads(r["checks"] or "[]")
        c = next((x for x in checks if x["check"] == check), None)
        if c is None:
            raise Refused(404, f"no check {check!r}")
        if c.get("equal") is not False:
            raise RuleBroken("this check is equal: there is nothing to explain")
        if c.get("must") != "equal_or_explained":
            raise RuleBroken("this check must be equal (must_equal): fix the mapping and reload")
        c["explained"], c["explanation"] = True, reason
        c["explained_by"] = services.who(caller)
        r["checks"] = json.dumps(checks)
        run["version"] = _save_run(repo, run)
        return _run_view(run)


@FA.use_case(kind="command", route="POST /migration/odoo/{run_id}/sign", permission=hub.MIG_SIGN, idempotent=True)
def sign_assets_migration(caller: dict, run_id: Code) -> dict:
    """The owner signs the reconciliation (FA-31): refused while a check that must be equal differs (must_equal) or any check is
    neither equal nor explained (zero_unexplained). The signature records who (email and sign-in), when and the run's checksums;
    a signed run is frozen and depreciation runs here from the next period."""
    with hub.repository() as repo, hub.decide():
        run = _load_run(repo, run_id)
        r = run["root"]
        if r["status"] == "signed":
            return _run_view(run)
        if r["status"] != "reconciled":
            raise RuleBroken(f"this run is {r['status']}: reconcile it before it is signed")
        checks = json.loads(r["checks"] or "[]")
        bad = [g for g in sign_gates(checks) if not g.met]
        if bad:
            raise RuleBroken("; ".join(f"{g.gate}: {g.reason} — {g.fix}" for g in bad))
        r["status"] = "signed"
        r["signed_by_email"], r["signed_by_sub"] = services.email_of(caller), services.sub_of(caller)
        r["signed_on"] = hub.today(repo, True)
        run["version"] = _save_run(repo, run)
        return _run_view(run)


@FA.use_case(kind="command", route="POST /migration/odoo/{run_id}/discard", permission=hub.MIG_SIGN, destructive=True)
def discard_assets_migration(caller: dict, run_id: Code, reason: Reason) -> dict:
    """Discard a run that is not signed, with a reason. What it wrote stays keyed by its run for a later load; nothing is deleted."""
    with hub.repository() as repo, hub.decide():
        run = _load_run(repo, run_id)
        if run["root"]["status"] in ("signed", "discarded"):
            raise RuleBroken(f"this run is {run['root']['status']}")
        run["root"]["status"] = "discarded"
        run["root"]["checksums"] = json.dumps({"discarded": reason})
        run["version"] = _save_run(repo, run)
        return _run_view(run)


@FA.use_case(kind="query", route="GET /migration/odoo/{run_id}", permission=hub.MIG_READ)
def show_assets_migration(caller: dict, run_id: Code) -> dict:
    """A migration run: its doors, every check (Odoo, ours, equal, explanation), whether the owner may sign (readiness), who
    signed and when."""
    with hub.repository() as repo:
        return _run_view(_load_run(repo, run_id))


@FA.use_case(kind="query", route="GET /migration-map/{db}/{model}", permission=hub.MIG_READ)
def get_assets_migration_map(caller: dict, db: Code, model: Code, ids: Code) -> list:
    """The one map route every migrating brick offers (shared.yaml conventions.odoo_migration.map_route): for a database and
    model, the Odoo ids (comma-separated) this register has migrated, as [{odoo_model, odoo_id, ours}] where ours is the natural key
    (the asset_id). An id not migrated is omitted — never null, never an error."""
    with hub.repository() as repo:
        out = []
        for raw in ids.split(","):
            raw = raw.strip()
            if not raw:
                continue
            ours = repo.map_get(db, model, raw)
            if ours is not None:
                out.append({"odoo_model": model, "odoo_id": raw, "ours": ours})
        return out