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