#!/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()