Merge feat/relink-manual-invoices: tool to reconcile manual ROA invoices with GoMag orders
This commit is contained in:
38
scripts/relink_manual_invoices.bat
Normal file
38
scripts/relink_manual_invoices.bat
Normal file
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Reconciliere facturi manuale ROA <-> comenzi GoMag (relink VANZARI.ID_COMANDA)
|
||||
REM Ruleaza pe serverul de productie VENDING. Seteaza mediul Oracle (thick mode)
|
||||
REM exact ca start.bat, apoi apeleaza scriptul Python.
|
||||
REM
|
||||
REM Utilizare (dublu-click = dry-run, sau din cmd):
|
||||
REM relink_manual_invoices.bat -> dry-run (ultimele 3 zile)
|
||||
REM relink_manual_invoices.bat --apply -> aplica (cu confirmare)
|
||||
REM relink_manual_invoices.bat --apply --yes -> aplica fara confirmare
|
||||
REM relink_manual_invoices.bat --days 7 -> alta fereastra
|
||||
REM relink_manual_invoices.bat --apply --days 7
|
||||
REM ============================================================================
|
||||
setlocal
|
||||
|
||||
REM --- Mediu Oracle (vezi start.bat) ---
|
||||
set "TNS_ADMIN=C:\roa\instantclient_11_2_0_2"
|
||||
set "PATH=C:\app\Server\product\18.0.0\dbhomeXE\bin;%PATH%"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
|
||||
REM --- Cai relative la acest .bat (scripts\) ---
|
||||
set "PYEXE=%~dp0..\venv\Scripts\python.exe"
|
||||
set "PYSCRIPT=%~dp0relink_manual_invoices.py"
|
||||
|
||||
if not exist "%PYEXE%" (
|
||||
echo [EROARE] Nu gasesc venv-ul: "%PYEXE%"
|
||||
echo Ruleaza din C:\gomag-vending\scripts pe serverul VENDING.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%PYEXE%" "%PYSCRIPT%" %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
echo.
|
||||
echo (cod iesire: %RC%)
|
||||
pause
|
||||
endlocal & exit /b %RC%
|
||||
333
scripts/relink_manual_invoices.py
Normal file
333
scripts/relink_manual_invoices.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user