Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Agent
53862b2685 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) <noreply@anthropic.com>
2026-03-25 15:03:25 +00:00
Claude Agent
adf5a9d96d feat(sync): uppercase client names in SQLite for consistency with Oracle
Existing 741 rows also updated via UPPER() on customer_name,
shipping_name, billing_name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:02:23 +00:00
3 changed files with 450 additions and 1 deletions

View File

@@ -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)

View File

@@ -103,7 +103,7 @@ def _derive_customer_info(order):
customer = shipping_name or billing_name
payment_method = getattr(order, 'payment_name', None) or None
delivery_method = getattr(order, 'delivery_name', None) or None
return shipping_name, billing_name, customer, payment_method, delivery_method
return shipping_name.upper(), billing_name.upper(), customer.upper(), payment_method, delivery_method
async def _fix_stale_error_orders(existing_map: dict, run_id: str):

View File

@@ -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("<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()