dodil/erp-brick-sales-order
DODIL ERP brick: sales order
src/brick_sales_order/application/odoo_migration.py
"""The one-off Odoo migration of sales orders (SO-24, milestone M8): read the customer's Odoo, map it through the earlier
bricks' id maps, write open orders live and the rest as read-only history, and reconcile.
The decisions it carries (signed): open Odoo quotations (draft, sent) go to brick-quote, not here; the history is every
open order plus the orders of the open fiscal years (`history_since`); Odoo's order numbers are kept and the series
continues after them; a migrated order is never re-priced, re-taxed or sent to invoicing; the run is idempotent on
sales_order_id = odoo:<company_id>:<sale.order name>, so a second run changes nothing.
Planning reads and maps and writes NOTHING; writing is one unit of work per order (an interrupted run is simply re-run).
"""
import re
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from decimal import Decimal
from zoneinfo import ZoneInfo
from brick_sales_order.domain.sales_order import LineSpec, SalesOrder
from brick_sales_order.domain.values import ZERO, money, num, rate4
from brick_sales_order.infrastructure.seams import Missing, ports
D = Decimal
STATES = ("sale", "done", "cancel")
@dataclass
class Params:
cut_off: date # Odoo sales are frozen at it; opening deliveries are dated on it
history_since: date | None # start of the earliest open fiscal year; None: every order
migration_owner: str # owns orders whose salesperson no longer works here
user_map: dict # str(odoo user id) -> email
@dataclass
class Plan:
db: str
orders: list = field(default_factory=list) # [(odoo_order, SalesOrder, [map rows])] in scope, in Odoo id order
outside_window: int = 0
quotations: int = 0 # draft and sent: brick-quote's
not_carried: dict = field(default_factory=dict) # reason -> count
unmapped: dict = field(default_factory=dict) # what -> sorted odoo ids (open orders only)
placeholders: int = 0 # history lines whose product/unit/tax is not mapped (carried as text)
def _when(raw: str, tz: ZoneInfo) -> tuple:
"""(naive UTC instant, business date) of an Odoo datetime string."""
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00").replace(" ", "T"))
dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
return dt.astimezone(timezone.utc).replace(tzinfo=None), dt.astimezone(tz).date()
def _order_key(o: dict) -> str:
return f"odoo:{o['company_id']}:{o['name']}"
def _reader():
from brick_sales_order.infrastructure.seams import OdooJsonRpc
return ports.odoo or OdooJsonRpc.from_env()
def lifecycle_of(o: dict, carried: list) -> str:
"""sale and not locked with anything left to deliver or invoice -> confirmed (open); sale fully delivered and invoiced,
done, or locked -> done; cancel -> cancelled."""
if o["state"] == "cancel":
return "cancelled"
if o["state"] == "done" or o.get("locked"):
return "done"
left = any(D(str(x["product_uom_qty"])) - D(str(x["qty_delivered"])) > 0 or D(str(x["product_uom_qty"])) - D(str(x["qty_invoiced"])) > 0
for x in carried)
return "confirmed" if left else "done"
def split_lines(lines: list) -> tuple:
"""(carried, not carried [(line, reason)]) — sections and notes are layout, a down payment is a deposit request."""
carried, skipped = [], []
for ln in sorted(lines, key=lambda x: (x.get("sequence") or 0, x["id"])):
if ln.get("display_type"):
skipped.append((ln, ln["display_type"]))
elif ln.get("is_downpayment"):
skipped.append((ln, "downpayment"))
else:
carried.append(ln)
return carried, skipped
def read_scope(history_since: date | None, tz: ZoneInfo) -> tuple:
"""(reader, in-scope orders with their lines, counts) — the same classification import and reconcile both use."""
reader = _reader()
orders = reader.orders()
by = {}
for ln in reader.lines([o["id"] for o in orders]):
by.setdefault(ln["order_id"], []).append(ln)
scope, counts = [], {"quotations": 0, "outside_window": 0}
for o in sorted(orders, key=lambda x: x["id"]):
if o["state"] not in STATES:
counts["quotations"] += 1
continue
carried, skipped = split_lines(by.get(o["id"], []))
life = lifecycle_of(o, carried)
if life != "confirmed" and history_since and _when(o["date_order"], tz)[1] < history_since:
counts["outside_window"] += 1
continue
scope.append({"order": o, "carried": carried, "skipped": skipped, "lifecycle": life})
return reader, scope, counts
def plan(repo, p: Params) -> Plan:
"""Read, map and build every order that would be written — and every one that already exists is left out."""
from brick_sales_order.infrastructure import business_time
tz = ZoneInfo(business_time.timezone_name(repo.settings))
reader, scope, counts = read_scope(p.history_since, tz)
maps = ports.maps
maps.db = reader.db
out = Plan(db=reader.db, outside_window=counts["outside_window"], quotations=counts["quotations"])
bad = {}
def need(what: str, odoo_id, found, open_: bool):
if found is None and odoo_id is not None:
if open_:
bad.setdefault(what, set()).add(odoo_id)
return None
return found
try:
for item in scope:
o, carried, life = item["order"], item["carried"], item["lifecycle"]
key, open_ = _order_key(o), item["lifecycle"] == "confirmed"
ordered_at, ordered_on = _when(o["date_order"], tz)
bp = need("partner", o["partner_id"], maps.partner(o["partner_id"]), open_)
terms = maps.payment_term(o["payment_term_id"]) if o.get("payment_term_id") else ""
need("payment term", o.get("payment_term_id"), terms, open_)
owner = p.user_map.get(str(o["user_id"])) if o.get("user_id") else None
if o.get("user_id") and owner is None:
owner = p.migration_owner
specs, openings = [], {}
for n, ln in enumerate(carried, 1):
prod = need("product", ln["product_id"], maps.product(ln["product_id"]), open_) if ln.get("product_id") else None
unit = need("unit", ln["product_uom"], maps.unit(ln["product_uom"], prod["product_id"]) if prod else None, open_) if ln.get("product_uom") else None
tax = None
if ln.get("tax_ids"):
tax = need("tax", ln["tax_ids"][0], maps.tax(ln["tax_ids"][0]), open_)
if not open_ and (prod is None or unit is None):
out.placeholders += 1
price, disc = D(str(ln["price_unit"])), D(str(ln.get("discount") or 0))
specs.append(LineSpec(
prod["product_id"] if prod else f"odoo-product:{ln.get('product_id')}", ln["name"] or "",
unit["unit_code"] if unit else "", D(str(ln["product_uom_qty"])), int(unit["numerator"]) if unit else 1,
int(unit["denominator"]) if unit else 1, price, rate4(price * (1 - disc / 100)), disc, ordered_on,
f"odoo:{o['pricelist_name']}", "", (prod or {}).get("invoicing_policy", "ordered"), "", "",
odoo_line_id=str(ln["id"]), tax_code=tax or "", net_amount=money(D(str(ln["price_subtotal"]))),
tax_amount=money(D(str(ln["price_tax"])))))
if D(str(ln["qty_delivered"])) > 0:
openings[n] = (D(str(ln["qty_delivered"])), f"odoo:{ln['id']}")
deposits = []
for ln, why in item["skipped"]:
if why == "downpayment" and open_ and D(str(ln.get("invoiced_amount") or 0)) > 0:
moves = ln.get("invoice_move_ids") or []
left = money(D(str(ln["invoiced_amount"])) - D(str(ln.get("deducted_amount") or 0)))
deposits.append({"amount": left, "invoice_id": f"odoo:{reader.db}:{moves[0]}" if moves else "",
"detail": {"odoo_line_id": ln["id"], "invoiced": num(D(str(ln["invoiced_amount"])), 2),
"deducted": num(D(str(ln.get("deducted_amount") or 0)), 2)}})
order = SalesOrder.migrated(
key, order_no=o["name"], owner=owner or p.migration_owner, bp_id=bp or 0, currency=o["currency"], price_list_id=o["pricelist_name"],
lifecycle=life, customer_po_ref=o.get("client_order_ref") or "", ordered_on=ordered_on, confirmed_at=ordered_at,
delivery_terms=o.get("incoterm") or "", payment_terms_id=terms or "", specs=specs, openings=openings, cut_off=p.cut_off,
deposits=deposits)
rows = [("sale.order", o["id"], key, None, "", None, None)]
rows += [("sale.order.line", ln["id"], key, n, "", None, None) for n, ln in enumerate(carried, 1)]
rows += [("sale.order.line", ln["id"], key, None, f"not_carried:{why}", money(D(str(ln.get("price_subtotal") or 0))),
money(D(str(ln.get("price_tax") or 0)))) for ln, why in item["skipped"]]
out.orders.append((o, order, rows))
for _, why in item["skipped"]:
out.not_carried[why] = out.not_carried.get(why, 0) + 1
except Missing as m:
raise m
out.unmapped = {k: sorted(v) for k, v in bad.items()}
return out
def series_mark(plan_: Plan, today: date) -> int:
"""The highest number among the migrated orders of the current year: the series continues after it."""
best = 0
for _o, order, _ in plan_.orders:
if order.ordered_on and order.ordered_on.year == today.year:
m = re.search(r"(\d+)\s*$", order.order_no)
if m:
best = max(best, int(m.group(1)))
return best
# ── the reconciliation ─────────────────────────────────────────────────────────────────────────
def _row(check: str, odoo, ours, equal: bool, explanation: str = "", details=None) -> dict:
out = {"check": check, "odoo": odoo, "ours": ours, "equal": bool(equal), "explanation": explanation}
if details:
out["details"] = details
return out
def reconcile(repo, history_since: date | None, db_issued) -> dict:
"""The checks of migration.odoo.reconcile, each {check, odoo, ours, equal, explanation}. `db_issued(order)` reads
invoicing's billed quantities (None: invoicing is not installed). Odoo is read again; ours comes from our tables."""
from brick_sales_order.infrastructure import business_time
tz = ZoneInfo(business_time.timezone_name(repo.settings))
reader, scope, counts = read_scope(history_since, tz)
ours = {o.sales_order_id: o for o in repo.sales_orders.all_from("odoo")}
maprows = repo.migration_map.all()
dbn = reader.db
out = []
# 1. orders per state
want = {"open": 0, "finished": 0, "cancelled": 0}
for it in scope:
want[{"confirmed": "open", "done": "finished", "cancelled": "cancelled"}[it["lifecycle"]]] += 1
have = {"open": sum(o.lifecycle == "confirmed" for o in ours.values()), "finished": sum(o.lifecycle == "done" for o in ours.values()),
"cancelled": sum(o.lifecycle == "cancelled" for o in ours.values())}
out.append(_row("orders per state", want, have, want == have,
f"{counts['outside_window']} finished or cancelled order(s) dated before {history_since} are outside the history window and counted on neither side"
if counts["outside_window"] else ""))
# 2. quotations
out.append(_row("draft and sent orders accounted for", counts["quotations"], 0, counts["quotations"] == 0,
f"{counts['quotations']} open quotation(s) are migrated as open quotes by brick-quote (decided 2026-10-22), not as orders here"
if counts["quotations"] else ""))
# 3. totals per currency (an Odoo total includes its down-payment lines; open ones are our deposit requests, history ones are listed)
odoo_t, our_t = {}, {}
for it in scope:
o = it["order"]
t = odoo_t.setdefault(o["currency"], [ZERO, ZERO, ZERO])
t[0] += D(str(o["amount_untaxed"])); t[1] += D(str(o["amount_tax"])); t[2] += D(str(o["amount_total"])) # noqa: E702
dp_by_order = {}
for r in maprows:
if (r["delivery_ref"] or "") == "not_carried:downpayment":
c = dp_by_order.setdefault(r["sales_order_id"], [ZERO, ZERO])
c[0] += D(str(r["amount"] or 0)); c[1] += D(str(r["tax_amount"] or 0)) # noqa: E702
dp_total = ZERO
for key, order in ours.items():
t = our_t.setdefault(order.currency, [ZERO, ZERO, ZERO])
dp = dp_by_order.get(key, [ZERO, ZERO])
dp_total += dp[0]
t[0] += order.subtotal + dp[0]; t[1] += order.tax_total + dp[1]; t[2] += order.total + dp[0] + dp[1] # noqa: E702
fmt = lambda d: {c: {"untaxed": num(v[0], 2), "tax": num(v[1], 2), "total": num(v[2], 2)} for c, v in sorted(d.items())} # noqa: E731
out.append(_row("totals per currency, open and history", fmt(odoo_t), fmt(our_t), fmt(odoo_t) == fmt(our_t),
f"our side includes {num(dp_total, 2)} of down-payment lines Odoo counts in its totals (open orders: deposit requests; history: not carried)" if dp_total else ""))
# 4/5. lines
carried_n = sum(len(it["carried"]) for it in scope)
ours_n = sum(len(o.lines) for o in ours.values())
out.append(_row("lines carried", carried_n, ours_n, carried_n == ours_n))
reasons = {}
for it in scope:
for _, why in it["skipped"]:
reasons[why] = reasons.get(why, 0) + 1
logged = {}
for r in maprows:
ref = r["delivery_ref"] or ""
if ref.startswith("not_carried:"):
logged[ref[12:]] = logged.get(ref[12:], 0) + 1
out.append(_row("lines not carried", reasons, logged, reasons == logged,
"section and note lines are layout, not commitments; down payments are deposit requests on open orders (never lines); by reason: "
+ ", ".join(f"{k} {v}" for k, v in sorted(reasons.items())) if reasons else ""))
# 6. prices unchanged
odoo_lines = {str(ln["id"]): ln for it in scope for ln in it["carried"]}
our_lines = {ln.odoo_line_id: ln for o in ours.values() for ln in o.lines}
diff = [k for k, ln in odoo_lines.items() if k in our_lines and (D(str(ln["price_unit"])) != our_lines[k].list_price
or D(str(ln.get("discount") or 0)) != our_lines[k].discount_pct)]
out.append(_row("prices unchanged", len(odoo_lines), len(odoo_lines) - len(diff) - len(set(odoo_lines) - set(our_lines)),
not diff and set(odoo_lines) == set(our_lines), "", [f"odoo line {k}" for k in diff]))
# 7. to deliver per open line
open_items = [it for it in scope if it["lifecycle"] == "confirmed"]
odoo_deliver = sum(D(str(ln["product_uom_qty"])) - D(str(ln["qty_delivered"])) for it in open_items for ln in it["carried"])
our_deliver = sum((ln.quantity - o.delivered(ln.line_no) for o in ours.values() if o.lifecycle == "confirmed" for ln in o.lines), ZERO)
out.append(_row("to deliver per open line", num(odoo_deliver), num(our_deliver), odoo_deliver == our_deliver))
# 8/9. invoiced and to invoice (read back from invoicing)
issued = {k: db_issued(o) for k, o in ours.items() if o.lifecycle in ("confirmed", "done")} if db_issued else None
if issued is None:
why = "invoicing is not part of this installation: the billed quantities cannot be read back, so this check cannot be signed yet"
out.append(_row("to invoice per open line", "n/a", None, False, "", [why]))
out.append(_row("invoiced per line", "n/a", None, False, "", [why]))
else:
o_inv = {ln["id"]: D(str(ln["qty_invoiced"])) for it in scope for ln in it["carried"]}
bad_inv = []
our_inv_total = ZERO
for key, order in ours.items():
for ln in order.lines:
q = D(issued.get(key, {}).get(ln.line_no, 0)) if key in issued else ZERO
our_inv_total += q
oid = int(ln.odoo_line_id)
if oid in o_inv and o_inv[oid] != q:
bad_inv.append(f"{key} line {ln.line_no}: odoo {num(o_inv[oid])}, ours {num(q)}")
out.append(_row("invoiced per line", num(sum(o_inv.values(), ZERO)), num(our_inv_total), not bad_inv,
"", bad_inv))
o_to, our_to, bad_to = ZERO, ZERO, []
for it in open_items:
key = _order_key(it["order"])
order = ours.get(key)
for ln in it["carried"]:
o_to += D(str(ln["qty_to_invoice"]))
if order:
for n, ln in enumerate(it["carried"], 1):
mine = order.due_qty(n, {k: D(v) for k, v in issued.get(key, {}).items()})
our_to += mine
if mine != D(str(ln["qty_to_invoice"])):
bad_to.append(f"{key} line {n}: odoo {num(D(str(ln['qty_to_invoice'])))}, ours {num(mine)}")
out.append(_row("to invoice per open line", num(o_to), num(our_to), not bad_to,
"", bad_to))
# 10. unused deposits per open order
o_dep, our_dep, bad_dep = ZERO, ZERO, []
for it in open_items:
key = _order_key(it["order"])
left = sum((D(str(ln.get("invoiced_amount") or 0)) - D(str(ln.get("deducted_amount") or 0)) for ln, why in it["skipped"] if why == "downpayment"), ZERO)
mine = ours[key].unused_deposit() if key in ours else ZERO
o_dep += left
our_dep += mine
if left != mine:
bad_dep.append(f"{key}: odoo {num(left, 2)}, ours {num(mine, 2)}")
out.append(_row("unused deposits per open order", num(o_dep, 2), num(our_dep, 2), not bad_dep, "", bad_dep))
unexplained = [c["check"] for c in out if not c["equal"] and not c["explanation"]]
return {"db": dbn, "checks": out, "unexplained": unexplained, "signable": not unexplained}