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}