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) <noreply@anthropic.com>
434 lines
17 KiB
Python
Executable File
434 lines
17 KiB
Python
Executable File
#!/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("<Obj")]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def extract_vending_data() -> 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()
|