From 53862b26855fce48b77a04ddc8392d25ac855e4b Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 25 Mar 2026 15:03:25 +0000 Subject: [PATCH] feat: add sync_vending_to_mariusm script and CLAUDE.md docs Script syncs articles from VENDING (prod) to MARIUSM_AUTO (dev) via SSH. Supports dry-run, --apply, and --yes modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 16 ++ scripts/sync_vending_to_mariusm.py | 433 +++++++++++++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100755 scripts/sync_vending_to_mariusm.py diff --git a/CLAUDE.md b/CLAUDE.md index cbad077..4fdbcf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,22 @@ Documentatie completa: [README.md](README.md) - Coloanele `factura_*` pe `orders` (SQLite), populate lazy din Oracle (`vanzari WHERE sters=0`) - Refresh complet: verifica facturi noi + facturi sterse + comenzi sterse din ROA +## Sync articole VENDING → MARIUSM_AUTO + +```bash +# Dry-run (arată diferențele fără să modifice) +python3 scripts/sync_vending_to_mariusm.py + +# Aplică cu confirmare +python3 scripts/sync_vending_to_mariusm.py --apply + +# Fără confirmare (automatizare) +python3 scripts/sync_vending_to_mariusm.py --apply --yes +``` + +Sincronizează via SSH din VENDING (prod Windows) în MARIUSM_AUTO (dev ROA_CENTRAL): +nom_articole (noi by codmat, codmat updatat) + articole_terti (noi, modificate, soft-delete). + ## Deploy Windows Vezi [README.md](README.md#deploy-windows) diff --git a/scripts/sync_vending_to_mariusm.py b/scripts/sync_vending_to_mariusm.py new file mode 100755 index 0000000..6708e1d --- /dev/null +++ b/scripts/sync_vending_to_mariusm.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +""" +Sync nom_articole and articole_terti from VENDING (production Windows) +to MARIUSM_AUTO (development ROA_CENTRAL). + +Usage: + python3 scripts/sync_vending_to_mariusm.py # dry-run (default) + python3 scripts/sync_vending_to_mariusm.py --apply # apply changes + python3 scripts/sync_vending_to_mariusm.py --apply --yes # skip confirmation + +How it works: + 1. SSH to production Windows server, runs Python to extract VENDING data + 2. Connects locally to MARIUSM_AUTO on ROA_CENTRAL + 3. Compares and syncs: + - nom_articole: new articles (by codmat), codmat updates on existing articles + - articole_terti: new, modified, or soft-deleted mappings +""" + +import argparse +import json +import subprocess +import textwrap +from dataclasses import dataclass, field + +import oracledb + +# ─── Configuration ─────────────────────────────────────────────────────────── + +SSH_HOST = "gomag@79.119.86.134" +SSH_PORT = "22122" +VENDING_PYTHON = r"C:\gomag-vending\venv\Scripts\python.exe" +VENDING_ORACLE_LIB = "C:/app/Server/product/18.0.0/dbhomeXE/bin" +VENDING_USER = "VENDING" +VENDING_PASSWORD = "ROMFASTSOFT" +VENDING_DSN = "ROA" + +MA_USER = "MARIUSM_AUTO" +MA_PASSWORD = "ROMFASTSOFT" +MA_DSN = "10.0.20.121:1521/ROA" + +# Columns to sync for nom_articole (besides codmat which is the match key) +NOM_SYNC_COLS = ["codmat", "denumire", "um", "cont", "codbare"] + +# ─── Data classes ──────────────────────────────────────────────────────────── + +@dataclass +class SyncReport: + nom_new: list = field(default_factory=list) + nom_codmat_updated: list = field(default_factory=list) + at_new: list = field(default_factory=list) + at_updated: list = field(default_factory=list) + at_deleted: list = field(default_factory=list) + errors: list = field(default_factory=list) + + @property + def has_changes(self): + return any([self.nom_new, self.nom_codmat_updated, + self.at_new, self.at_updated, self.at_deleted]) + + def summary(self): + lines = ["=== Sync Report ==="] + lines.append(f" nom_articole new: {len(self.nom_new)}") + lines.append(f" nom_articole codmat updated: {len(self.nom_codmat_updated)}") + lines.append(f" articole_terti new: {len(self.at_new)}") + lines.append(f" articole_terti updated: {len(self.at_updated)}") + lines.append(f" articole_terti deleted: {len(self.at_deleted)}") + if self.errors: + lines.append(f" ERRORS: {len(self.errors)}") + return "\n".join(lines) + + +# ─── Remote extraction ─────────────────────────────────────────────────────── + +def ssh_run_python(script: str) -> str: + """Run a Python script on the production Windows server via SSH.""" + # Inline script as a single command argument + cmd = [ + "ssh", "-p", SSH_PORT, + "-o", "ConnectTimeout=10", + "-o", "StrictHostKeyChecking=no", + SSH_HOST, + f"{VENDING_PYTHON} -c \"{script}\"" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + raise RuntimeError(f"SSH command failed:\n{result.stderr}") + # Filter out PowerShell CLIXML noise + lines = [l for l in result.stdout.splitlines() + if not l.startswith("#< CLIXML") and not l.startswith(" tuple[list, list]: + """Extract nom_articole and articole_terti from VENDING via SSH.""" + print("Connecting to VENDING production via SSH...") + + # Extract nom_articole + nom_script = textwrap.dedent(f"""\ + import oracledb,json,sys + oracledb.init_oracle_client(lib_dir='{VENDING_ORACLE_LIB}') + conn = oracledb.connect(user='{VENDING_USER}',password='{VENDING_PASSWORD}',dsn='{VENDING_DSN}') + cur = conn.cursor() + cur.execute('SELECT id_articol,codmat,denumire,um,cont,codbare,sters,inactiv FROM nom_articole WHERE codmat IS NOT NULL') + rows = [[r[0],r[1],r[2],r[3],r[4],r[5],r[6],r[7]] for r in cur.fetchall()] + sys.stdout.write(json.dumps(rows)) + conn.close() + """).replace("\n", ";").replace(";;", ";") + + raw = ssh_run_python(nom_script) + json_line = next((l for l in raw.splitlines() if l.startswith("[")), None) + if not json_line: + raise RuntimeError(f"No JSON in nom_articole output:\n{raw[:500]}") + vending_nom = json.loads(json_line) + print(f" VENDING nom_articole: {len(vending_nom)} rows with codmat") + + # Extract articole_terti + at_script = textwrap.dedent(f"""\ + import oracledb,json,sys + oracledb.init_oracle_client(lib_dir='{VENDING_ORACLE_LIB}') + conn = oracledb.connect(user='{VENDING_USER}',password='{VENDING_PASSWORD}',dsn='{VENDING_DSN}') + cur = conn.cursor() + cur.execute('SELECT sku,codmat,cantitate_roa,activ,sters FROM articole_terti') + rows = [[r[0],r[1],float(r[2]) if r[2] else 1,r[3],r[4]] for r in cur.fetchall()] + sys.stdout.write(json.dumps(rows)) + conn.close() + """).replace("\n", ";").replace(";;", ";") + + raw = ssh_run_python(at_script) + json_line = next((l for l in raw.splitlines() if l.startswith("[")), None) + if not json_line: + raise RuntimeError(f"No JSON in articole_terti output:\n{raw[:500]}") + vending_at = json.loads(json_line) + print(f" VENDING articole_terti: {len(vending_at)} rows") + + return vending_nom, vending_at + + +# ─── Comparison ────────────────────────────────────────────────────────────── + +def compare(vending_nom: list, vending_at: list, ma_conn) -> SyncReport: + """Compare VENDING data with MARIUSM_AUTO and build sync report.""" + report = SyncReport() + cur = ma_conn.cursor() + + # ── nom_articole ── + # Get ALL MARIUSM_AUTO articles indexed by codmat and id_articol + cur.execute("SELECT id_articol, codmat, denumire, sters, inactiv FROM nom_articole") + ma_by_id = {} + ma_by_codmat = {} + for r in cur.fetchall(): + ma_by_id[r[0]] = {"codmat": r[1], "denumire": r[2], "sters": r[3], "inactiv": r[4]} + if r[1]: + ma_by_codmat[r[1]] = r[0] # codmat -> id_articol + + print(f" MARIUSM_AUTO nom_articole: {len(ma_by_id)} total, {len(ma_by_codmat)} with codmat") + + # vending_nom: [id_articol, codmat, denumire, um, cont, codbare, sters, inactiv] + for row in vending_nom: + v_id, v_codmat, v_den, v_um, v_cont, v_codbare, v_sters, v_inactiv = row + if not v_codmat or v_sters or v_inactiv: + continue + + if v_codmat not in ma_by_codmat: + # New article - codmat doesn't exist anywhere in MARIUSM_AUTO + report.nom_new.append({ + "codmat": v_codmat, + "denumire": v_den, + "um": v_um, + "cont": v_cont, + "codbare": v_codbare, + "vending_id": v_id, + }) + else: + # Article exists by codmat - check if codmat was updated on a + # previously-null article (id match from VENDING) + # This handles: same id_articol exists in MA but had NULL codmat + if v_id in ma_by_id: + ma_art = ma_by_id[v_id] + if ma_art["codmat"] != v_codmat and ma_art["codmat"] is None: + report.nom_codmat_updated.append({ + "id_articol": v_id, + "old_codmat": ma_art["codmat"], + "new_codmat": v_codmat, + "denumire": v_den, + }) + + # Also check: MARIUSM_AUTO articles that share id_articol with VENDING + # but have different codmat (updated in VENDING) + vending_by_id = {r[0]: r for r in vending_nom if not r[6] and not r[7]} + for v_id, row in vending_by_id.items(): + v_codmat = row[1] + if v_id in ma_by_id: + ma_art = ma_by_id[v_id] + if ma_art["codmat"] != v_codmat: + # Don't duplicate entries already found above + existing = [x for x in report.nom_codmat_updated if x["id_articol"] == v_id] + if not existing: + report.nom_codmat_updated.append({ + "id_articol": v_id, + "old_codmat": ma_art["codmat"], + "new_codmat": v_codmat, + "denumire": row[2], + }) + + # ── articole_terti ── + cur.execute("SELECT sku, codmat, cantitate_roa, activ, sters FROM articole_terti") + ma_at = {} + for r in cur.fetchall(): + ma_at[(r[0], r[1])] = {"cantitate_roa": float(r[2]) if r[2] else 1, "activ": r[3], "sters": r[4]} + + print(f" MARIUSM_AUTO articole_terti: {len(ma_at)} rows") + + # vending_at: [sku, codmat, cantitate_roa, activ, sters] + vending_at_keys = set() + for row in vending_at: + sku, codmat, qty, activ, sters = row + key = (sku, codmat) + vending_at_keys.add(key) + + if key not in ma_at: + report.at_new.append({ + "sku": sku, "codmat": codmat, + "cantitate_roa": qty, "activ": activ, "sters": sters, + }) + else: + existing = ma_at[key] + changes = {} + if existing["cantitate_roa"] != qty: + changes["cantitate_roa"] = (existing["cantitate_roa"], qty) + if existing["activ"] != activ: + changes["activ"] = (existing["activ"], activ) + if existing["sters"] != sters: + changes["sters"] = (existing["sters"], sters) + if changes: + report.at_updated.append({ + "sku": sku, "codmat": codmat, "changes": changes, + "new_qty": qty, "new_activ": activ, "new_sters": sters, + }) + + # Soft-delete: MA entries not in VENDING (only active ones) + for key, data in ma_at.items(): + if key not in vending_at_keys and data["activ"] == 1 and data["sters"] == 0: + report.at_deleted.append({"sku": key[0], "codmat": key[1]}) + + return report + + +# ─── Apply changes ─────────────────────────────────────────────────────────── + +def apply_changes(report: SyncReport, ma_conn) -> SyncReport: + """Apply sync changes to MARIUSM_AUTO.""" + cur = ma_conn.cursor() + + # ── nom_articole: insert new ── + for art in report.nom_new: + try: + cur.execute(""" + INSERT INTO nom_articole + (codmat, denumire, um, cont, codbare, + sters, inactiv, dep, id_subgrupa, cant_bax, + id_mod, in_stoc, in_crm, dnf) + VALUES + (:codmat, :denumire, :um, :cont, :codbare, + 0, 0, 0, 0, 1, + 0, 1, 0, 0) + """, { + "codmat": art["codmat"], + "denumire": art["denumire"], + "um": art["um"], + "cont": art["cont"], + "codbare": art["codbare"], + }) + except Exception as e: + report.errors.append(f"nom_articole INSERT {art['codmat']}: {e}") + + # ── nom_articole: update codmat ── + for upd in report.nom_codmat_updated: + try: + cur.execute(""" + UPDATE nom_articole SET codmat = :codmat + WHERE id_articol = :id_articol + """, {"codmat": upd["new_codmat"], "id_articol": upd["id_articol"]}) + except Exception as e: + report.errors.append(f"nom_articole UPDATE {upd['id_articol']}: {e}") + + # ── articole_terti: insert new ── + for at in report.at_new: + try: + cur.execute(""" + INSERT INTO articole_terti + (sku, codmat, cantitate_roa, activ, sters, + data_creare, id_util_creare) + VALUES + (:sku, :codmat, :cantitate_roa, :activ, :sters, + SYSDATE, 0) + """, at) + except Exception as e: + report.errors.append(f"articole_terti INSERT {at['sku']}->{at['codmat']}: {e}") + + # ── articole_terti: update modified ── + for at in report.at_updated: + try: + cur.execute(""" + UPDATE articole_terti + SET cantitate_roa = :new_qty, + activ = :new_activ, + sters = :new_sters, + data_modif = SYSDATE, + id_util_modif = 0 + WHERE sku = :sku AND codmat = :codmat + """, at) + except Exception as e: + report.errors.append(f"articole_terti UPDATE {at['sku']}->{at['codmat']}: {e}") + + # ── articole_terti: soft-delete removed ── + for at in report.at_deleted: + try: + cur.execute(""" + UPDATE articole_terti + SET sters = 1, activ = 0, + data_modif = SYSDATE, id_util_modif = 0 + WHERE sku = :sku AND codmat = :codmat + """, at) + except Exception as e: + report.errors.append(f"articole_terti DELETE {at['sku']}->{at['codmat']}: {e}") + + if report.errors: + print(f"\n{len(report.errors)} errors occurred, rolling back...") + ma_conn.rollback() + else: + ma_conn.commit() + print("\nCOMMIT OK") + + return report + + +# ─── Display ───────────────────────────────────────────────────────────────── + +def print_details(report: SyncReport): + """Print detailed changes.""" + if report.nom_new: + print(f"\n--- nom_articole NEW ({len(report.nom_new)}) ---") + for art in report.nom_new: + print(f" codmat={art['codmat']:20s} um={str(art.get('um','')):5s} " + f"cont={str(art.get('cont','')):5s} {art['denumire']}") + + if report.nom_codmat_updated: + print(f"\n--- nom_articole CODMAT UPDATED ({len(report.nom_codmat_updated)}) ---") + for upd in report.nom_codmat_updated: + print(f" id={upd['id_articol']} {upd['old_codmat']} -> {upd['new_codmat']} {upd['denumire']}") + + if report.at_new: + print(f"\n--- articole_terti NEW ({len(report.at_new)}) ---") + for at in report.at_new: + print(f" {at['sku']:20s} -> {at['codmat']:20s} qty={at['cantitate_roa']}") + + if report.at_updated: + print(f"\n--- articole_terti UPDATED ({len(report.at_updated)}) ---") + for at in report.at_updated: + for col, (old, new) in at["changes"].items(): + print(f" {at['sku']:20s} -> {at['codmat']:20s} {col}: {old} -> {new}") + + if report.at_deleted: + print(f"\n--- articole_terti SOFT-DELETED ({len(report.at_deleted)}) ---") + for at in report.at_deleted: + print(f" {at['sku']:20s} -> {at['codmat']:20s}") + + if report.errors: + print(f"\n--- ERRORS ({len(report.errors)}) ---") + for e in report.errors: + print(f" {e}") + + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Sync nom_articole & articole_terti from VENDING to MARIUSM_AUTO") + parser.add_argument("--apply", action="store_true", + help="Apply changes (default is dry-run)") + parser.add_argument("--yes", "-y", action="store_true", + help="Skip confirmation prompt") + args = parser.parse_args() + + # 1. Extract from VENDING + vending_nom, vending_at = extract_vending_data() + + # 2. Connect to MARIUSM_AUTO + print("Connecting to MARIUSM_AUTO...") + ma_conn = oracledb.connect(user=MA_USER, password=MA_PASSWORD, dsn=MA_DSN) + + # 3. Compare + print("Comparing...") + report = compare(vending_nom, vending_at, ma_conn) + + # 4. Display + print(report.summary()) + if not report.has_changes: + print("\nNothing to sync — already up to date.") + ma_conn.close() + return + + print_details(report) + + # 5. Apply or dry-run + if not args.apply: + print("\n[DRY-RUN] No changes applied. Use --apply to execute.") + ma_conn.close() + return + + if not args.yes: + answer = input("\nApply these changes? [y/N] ").strip().lower() + if answer != "y": + print("Aborted.") + ma_conn.close() + return + + print("\nApplying changes...") + apply_changes(report, ma_conn) + + # 6. Verify + cur = ma_conn.cursor() + cur.execute("SELECT COUNT(*) FROM nom_articole WHERE sters=0 AND inactiv=0") + print(f" nom_articole active: {cur.fetchone()[0]}") + cur.execute("SELECT COUNT(*) FROM articole_terti WHERE activ=1 AND sters=0") + print(f" articole_terti active: {cur.fetchone()[0]}") + + ma_conn.close() + print("Done.") + + +if __name__ == "__main__": + main()