From 2c3b35294c646f1af82d06159a6311d3b599f351 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Fri, 26 Jun 2026 08:13:02 +0000 Subject: [PATCH] feat(scripts): reconcile manual ROA invoices with GoMag orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool to relink orphan VANZARI invoices (ID_COMANDA NULL, sters=0) — emitted manually by the warehouse during an Oracle/sync outage — to their GoMag order, then populate the SQLite invoice cache like the app does. Matching is conservative (exact total + partner OR name; handles duplicate partner records) and only auto-links unambiguous 1:1 matches. Genuine warehouse/ walk-in invoices (no online order behind them) get SKIP_NOMATCH and are left untouched; anything unclear is reported SKIP_AMBIGUOUS for manual review. Dry-run by default; --apply / --yes / --days N. Runs on prod (uses app.config settings + prod import.db). Codifies the 2026-06-26 manual relink (12 invoices). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/relink_manual_invoices.py | 333 ++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 scripts/relink_manual_invoices.py diff --git a/scripts/relink_manual_invoices.py b/scripts/relink_manual_invoices.py new file mode 100644 index 0000000..f54a0cc --- /dev/null +++ b/scripts/relink_manual_invoices.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +Reconcile manual ROA invoices with GoMag orders left "nefacturate". + +Context +------- +When the Oracle pool / sync is down (e.g. after a power loss) the warehouse +operator emits invoices MANUALLY in ROA. Those land in `VANZARI` with +`ID_COMANDA = NULL` (vs the app's normal flow which sets `ID_COMANDA` and links +to `COMENZI.COMANDA_EXTERNA` = GoMag order no). Once the app recovers it imports +the same web orders into `COMENZI`, but the manual invoice is never linked, so +the order stays "nefacturat" in ROA and in the dashboard. + +This script finds those orphan invoices (`VANZARI.ID_COMANDA IS NULL`, `sters=0`) +and links each to its GoMag order, matching by **exact total + partner/name**, +then populates the SQLite invoice cache (`orders.factura_*`) exactly like the app. + +IMPORTANT — warehouse / walk-in invoices +----------------------------------------- +The operator ALSO emits genuine manual invoices directly from the warehouse, +with NO online order behind them (~20+/day). Those have no matching uninvoiced +GoMag order, so they get classified SKIP_NOMATCH and are LEFT UNTOUCHED. The +matching is deliberately conservative: anything not an unambiguous 1:1 match is +reported for manual review, never auto-linked. + +Run it ON the production server (it needs prod Oracle + prod import.db): + + # dry-run (default) — shows the plan, changes nothing + C:\\gomag-vending\\venv\\Scripts\\python.exe scripts\\relink_manual_invoices.py + + # apply, with confirmation + ... scripts\\relink_manual_invoices.py --apply + + # apply without confirmation (automation) + ... scripts\\relink_manual_invoices.py --apply --yes + + # widen / narrow the lookback window (default: last 3 days) + ... scripts\\relink_manual_invoices.py --days 5 + +From the dev container you can drive it over SSH: + + scp -P 22122 scripts/relink_manual_invoices.py gomag@79.119.86.134:C:/gomag-vending/scripts/ + ssh -p 22122 gomag@79.119.86.134 "cd C:\\gomag-vending\\api; \ + $env:TNS_ADMIN='C:\\roa\\instantclient_11_2_0_2'; \ + C:\\gomag-vending\\venv\\Scripts\\python.exe ..\\scripts\\relink_manual_invoices.py" +""" + +import argparse +import os +import re +import sqlite3 +import sys + +# Windows service console is cp1252; keep output robust regardless of code page. +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +# Make the app package importable + load .env-backed settings (Oracle creds, SQLite path). +_API_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "api") +sys.path.insert(0, _API_ROOT) + +import oracledb # noqa: E402 +from app.config import settings # noqa: E402 + +# Match tolerance for money comparison (lei). Totals are stored to 2 decimals. +MONEY_EPS = 0.01 + + +# ─── Oracle ────────────────────────────────────────────────────────────────── + +def oracle_connect(): + if settings.TNS_ADMIN: + os.environ.setdefault("TNS_ADMIN", settings.TNS_ADMIN) + if settings.INSTANTCLIENTPATH: + try: + oracledb.init_oracle_client(lib_dir=settings.INSTANTCLIENTPATH) + except Exception: + pass # already initialized / thin mode + return oracledb.connect( + user=settings.ORACLE_USER, + password=settings.ORACLE_PASSWORD, + dsn=settings.ORACLE_DSN, + ) + + +def fetch_orphan_invoices(cur, days): + """Manual invoices with no order link, created in the lookback window.""" + cur.execute( + """ + SELECT v.ID_VANZARE, v.ID_PART, v.SERIE_ACT, v.NUMAR_ACT, + v.TOTAL_FARA_TVA, v.TOTAL_TVA, v.TOTAL_CU_TVA, + TO_CHAR(v.DATA_ACT, 'YYYY-MM-DD') AS data_act, + TO_CHAR(v.DATAORA, 'YYYY-MM-DD HH24:MI') AS creat, + v.TIP, p.DENUMIRE + FROM VANZARI v + LEFT JOIN NOM_PARTENERI p ON p.ID_PART = v.ID_PART + WHERE v.STERS = 0 + AND v.ID_COMANDA IS NULL + AND v.DATAORA >= TRUNC(SYSDATE) - :days + ORDER BY v.DATAORA + """, + days=days, + ) + cols = [d[0].lower() for d in cur.description] + return [dict(zip(cols, r)) for r in cur.fetchall()] + + +def comanda_active(cur, id_comanda): + cur.execute("SELECT COUNT(*) FROM COMENZI WHERE ID_COMANDA = :1 AND STERS = 0", [id_comanda]) + return cur.fetchone()[0] == 1 + + +def comanda_already_invoiced(cur, id_comanda): + cur.execute( + "SELECT COUNT(*) FROM VANZARI WHERE ID_COMANDA = :1 AND STERS = 0", [id_comanda] + ) + return cur.fetchone()[0] > 0 + + +# ─── SQLite ────────────────────────────────────────────────────────────────── + +def fetch_uninvoiced_orders(db, days): + """Imported GoMag orders that have an id_comanda but no cached invoice yet.""" + cur = db.execute( + """ + SELECT order_number, id_comanda, id_partener, order_total, + customer_name, shipping_name, billing_name + FROM orders + WHERE status IN ('IMPORTED', 'ALREADY_IMPORTED') + AND id_comanda IS NOT NULL + AND (factura_numar IS NULL OR factura_numar = '') + AND order_date >= date('now', ?) + """, + (f'-{days} day',), + ) + return [dict(r) for r in cur.fetchall()] + + +# ─── Name matching (handles duplicate partner records) ──────────────────────── + +_NAME_NOISE = re.compile( + r"\b(S\.?R\.?L\.?|S\.?C\.?|S\.?A\.?|P\.?F\.?A\.?|II|SRL|SC|SA)\b", re.IGNORECASE +) + + +def _tokens(name): + if not name: + return set() + name = _NAME_NOISE.sub(" ", name.upper()) + name = re.sub(r"[^A-Z0-9 ]", " ", name) + return {t for t in name.split() if len(t) >= 3} + + +def name_match(a, b): + """Conservative name overlap — tolerant of word order and SRL/SC noise.""" + ta, tb = _tokens(a), _tokens(b) + if not ta or not tb: + return False + shared = ta & tb + return len(shared) >= 1 and len(shared) >= min(len(ta), len(tb)) * 0.5 + + +def money_eq(a, b): + return a is not None and b is not None and abs(float(a) - float(b)) <= MONEY_EPS + + +# ─── Matching ───────────────────────────────────────────────────────────────── + +def classify(inv, orders, cur): + """Decide what to do with one orphan invoice. + + Returns (action, order_or_None, note). action in: + LINK unambiguous match -> will link + SKIP_NOMATCH no uninvoiced GoMag order with this total -> warehouse/walk-in invoice + SKIP_AMBIGUOUS several plausible orders -> needs a human + SKIP_ALREADY matched comanda already has an invoice / is gone + """ + total = inv["total_cu_tva"] + cands = [o for o in orders if money_eq(o["order_total"], total)] + + if not cands: + return ("SKIP_NOMATCH", None, "fara comanda online cu acest total (factura depozit)") + + def pick(subset, why): + o = subset[0] + if not comanda_active(cur, o["id_comanda"]): + return ("SKIP_ALREADY", None, f"comanda {o['id_comanda']} nu mai e activa in ROA") + if comanda_already_invoiced(cur, o["id_comanda"]): + return ("SKIP_ALREADY", None, f"comanda {o['id_comanda']} are deja factura") + return ("LINK", o, why) + + by_partner = [o for o in cands if o["id_partener"] == inv["id_part"]] + by_name = [ + o for o in cands + if name_match(inv["denumire"], o["customer_name"]) + or name_match(inv["denumire"], o["shipping_name"]) + or name_match(inv["denumire"], o["billing_name"]) + ] + + if len(by_partner) == 1: + return pick(by_partner, "potrivire partener+total") + if len(by_partner) > 1: + return ("SKIP_AMBIGUOUS", None, + f"{len(by_partner)} comenzi acelasi partener+total: " + + ", ".join(o["order_number"] for o in by_partner)) + if len(by_name) == 1: + return pick(by_name, "potrivire nume+total (partener dublat)") + if len(by_name) > 1: + return ("SKIP_AMBIGUOUS", None, + f"{len(by_name)} comenzi nume+total: " + + ", ".join(o["order_number"] for o in by_name)) + if len(cands) == 1: + return ("SKIP_AMBIGUOUS", None, + f"total se potriveste cu {cands[0]['order_number']} dar partenerul si numele difera") + return ("SKIP_AMBIGUOUS", None, + f"{len(cands)} comenzi cu acelasi total, niciun partener/nume sigur") + + +# ─── Apply ──────────────────────────────────────────────────────────────────── + +def apply_link(ora_cur, db, inv, order): + """Link VANZARI -> COMANDA in Oracle and cache the invoice onto the SQLite order.""" + ora_cur.execute( + "UPDATE VANZARI SET ID_COMANDA = :1 " + "WHERE ID_VANZARE = :2 AND ID_COMANDA IS NULL AND STERS = 0", + [order["id_comanda"], inv["id_vanzare"]], + ) + linked = ora_cur.rowcount == 1 + if linked: + numar = int(inv["numar_act"]) if inv["numar_act"] is not None else None + db.execute( + """ + UPDATE orders SET + factura_serie = ?, factura_numar = ?, + factura_total_fara_tva = ?, factura_total_tva = ?, factura_total_cu_tva = ?, + factura_data = ?, invoice_checked_at = datetime('now'), + updated_at = datetime('now') + WHERE order_number = ? AND (factura_numar IS NULL OR factura_numar = '') + """, + (inv["serie_act"], numar, + float(inv["total_fara_tva"] or 0), float(inv["total_tva"] or 0), + float(inv["total_cu_tva"] or 0), inv["data_act"], order["order_number"]), + ) + return linked + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +def main(): + ap = argparse.ArgumentParser(description="Relink manual ROA invoices to GoMag orders.") + ap.add_argument("--apply", action="store_true", help="apply changes (default: dry-run)") + ap.add_argument("--yes", action="store_true", help="skip confirmation prompt") + ap.add_argument("--days", type=int, default=3, help="lookback window in days (default 3)") + args = ap.parse_args() + + conn = oracle_connect() + ora_cur = conn.cursor() + db = sqlite3.connect(settings.SQLITE_DB_PATH) + db.row_factory = sqlite3.Row + + invoices = fetch_orphan_invoices(ora_cur, args.days) + orders = fetch_uninvoiced_orders(db, args.days) + + print(f"Fereastra: ultimele {args.days} zile") + print(f"Facturi orfane (VANZARI ID_COMANDA NULL, sters=0): {len(invoices)}") + print(f"Comenzi GoMag nefacturate (cu id_comanda): {len(orders)}\n") + + plans = [] + for inv in invoices: + action, order, note = classify(inv, orders, ora_cur) + plans.append((inv, action, order, note)) + # an order can only back one invoice + if action == "LINK" and order is not None: + orders = [o for o in orders if o["order_number"] != order["order_number"]] + + def show(action, detailed=True): + rows = [(i, o, n) for (i, a, o, n) in plans if a == action] + if not rows: + return + print(f"-- {action} ({len(rows)}) --") + if not detailed: + print(" (facturi de depozit, fara comanda online — lasate neatinse)\n") + return + for inv, order, note in rows: + tag = f"-> {order['order_number']} (idcom {order['id_comanda']})" if order else "" + print(f" IDV={inv['id_vanzare']} {inv['serie_act']}{inv['numar_act']} " + f"tot={inv['total_cu_tva']} [{inv['denumire']}] {tag} {note}") + print() + + for a in ("LINK", "SKIP_AMBIGUOUS", "SKIP_ALREADY"): + show(a) + show("SKIP_NOMATCH", detailed=False) + + to_link = [(i, o) for (i, a, o, n) in plans if a == "LINK"] + ambiguous = sum(1 for (_, a, _, _) in plans if a == "SKIP_AMBIGUOUS") + print(f"De legat: {len(to_link)} | De verificat manual (AMBIGUOUS): {ambiguous} | " + f"Neatinse (depozit): {sum(1 for (_, a, _, _) in plans if a == 'SKIP_NOMATCH')}") + + if not args.apply: + print("\n[DRY-RUN] nimic modificat. Reruleaza cu --apply ca sa aplici.") + return + + if not to_link: + print("\nNimic de legat.") + return + + if not args.yes: + resp = input(f"\nAplici {len(to_link)} legaturi? [y/N] ").strip().lower() + if resp != "y": + print("Anulat.") + return + + linked = 0 + for inv, order in to_link: + if apply_link(ora_cur, db, inv, order): + linked += 1 + print(f" OK IDV={inv['id_vanzare']} -> idcom {order['id_comanda']} " + f"({order['order_number']})") + else: + print(f" SKIP IDV={inv['id_vanzare']} — ID_COMANDA nu mai era NULL (concurenta)") + + conn.commit() + db.commit() + print(f"\nAplicat: {linked} facturi legate + cache SQLite actualizat.") + + conn.close() + db.close() + + +if __name__ == "__main__": + main()