Primer commit
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Reconcilia REPLICAS HUERFANAS de opps en Marca con link MUERTO (dead-link).
|
||||
|
||||
CAUSA RAIZ que ataca (ver docs/casos/2026-05-30-descuadre-opp-deadlink.md y
|
||||
memoria positive_opp_descuadre_double_replica, variante DEAD-LINK UNICO):
|
||||
|
||||
El workflow n8n de sync de opps (Cfgwp0bOtDW8zuKW) crea una replica en Marca por
|
||||
cada opp de sucursal y guarda el id nativo de la sucursal en el CF
|
||||
"ID Oportunidad Sucursal". Cuando una opp de sucursal se BORRA (su id deja de
|
||||
existir), GHL NO dispara webhook de borrado -> la replica de Marca queda
|
||||
HUERFANA con un link muerto y NADIE la limpia. Es la causa #1 del descuadre
|
||||
positivo (Marca > sucursales). Ningun bucket del audit ni los otros cleanup la
|
||||
atrapan (link unico no-compartido + campo poblado + opp viva emparejada por
|
||||
contacto).
|
||||
|
||||
Este reconciliador es el backstop DETERMINISTA: converge los dead-links a 0 sin
|
||||
importar como surgieron. Replica el procedimiento manual ya validado:
|
||||
|
||||
1. DETECTA (cache): opps de Marca cuyo link NO esta en el set de ids nativos de
|
||||
ninguna sucursal (excluye demos).
|
||||
2. VERIFICA EN VIVO: GET /opportunities/{link} con el token de la sucursal del
|
||||
contacto. GHL devuelve 400 "doesn't exist or is deleted" si esta borrada
|
||||
(NO 404). Si responde 200 -> el link esta VIVO (cache stale) -> SKIP.
|
||||
3. CLASIFICA (deterministico, funcion pura `classify`):
|
||||
- sibling con link VALIDO -> DELETE (replica obsoleta duplicada)
|
||||
- 0 opps vivas en sucursal -> DELETE (huerfana real)
|
||||
- 1 opp viva no enlazada -> RELINK (id rotado; re-apuntar al id vivo)
|
||||
- ambiguo (multi-opp / sin resolver branch) -> SKIP (revision humana)
|
||||
4. APLICA (--apply): snapshot live + script_audit run_id (reversible). DELETE
|
||||
irreversible en GHL -> el snapshot permite recrear.
|
||||
|
||||
Uso:
|
||||
python scripts/reconcile_brand_deadlink_opps.py # dry-run
|
||||
python scripts/reconcile_brand_deadlink_opps.py --json
|
||||
python scripts/reconcile_brand_deadlink_opps.py --apply --run-id <uuid>
|
||||
python scripts/reconcile_brand_deadlink_opps.py --only-opp <marca_opp_id>
|
||||
python scripts/reconcile_brand_deadlink_opps.py --resync-first # re-sync Marca antes de detectar
|
||||
python scripts/reconcile_brand_deadlink_opps.py --self-test # valida classify() con fixtures (sin API)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!")
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS_DIR)
|
||||
|
||||
import requests # noqa: E402
|
||||
import sync_engine # noqa: E402
|
||||
import script_audit # noqa: E402
|
||||
from paths import DB_PATH, MIGRATIONS_DIR # noqa: E402
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
DEMO_LOCATION_IDS = {"Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"}
|
||||
OPP_LINK_FIELD_KEY = "opportunity.id_oportunidad_sucursal"
|
||||
CONTACT_LINK_FIELD_KEY = "contact.id_contacto_sucursal"
|
||||
GHL_BASE = "https://services.leadconnectorhq.com"
|
||||
|
||||
gc = sync_engine.ghl_client
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# utils
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def safe_print(*args, **kwargs):
|
||||
text = " ".join(str(a) for a in args)
|
||||
try:
|
||||
sys.stdout.write(text + "\n")
|
||||
sys.stdout.flush()
|
||||
except UnicodeEncodeError:
|
||||
enc = sys.stdout.encoding or "utf-8"
|
||||
sys.stdout.write(text.encode(enc, errors="replace").decode(enc) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def extract_cf(cf_json, field_id):
|
||||
"""Valor de un custom field por id. GHL guarda bajo fieldValueString (gotcha)."""
|
||||
if not cf_json or not field_id:
|
||||
return None
|
||||
try:
|
||||
arr = json.loads(cf_json)
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(arr, dict):
|
||||
arr = arr.get("customFields") or arr.get("custom_fields") or []
|
||||
for f in arr or []:
|
||||
if isinstance(f, dict) and (f.get("id") == field_id or f.get("fieldId") == field_id):
|
||||
return f.get("fieldValueString") or f.get("fieldValue") or f.get("value")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_field_id(conn, location_id, object_key, field_key):
|
||||
row = conn.execute(
|
||||
"SELECT field_id FROM object_schemas "
|
||||
"WHERE location_id=? AND object_key=? AND field_key=?",
|
||||
(location_id, object_key, field_key),
|
||||
).fetchone()
|
||||
return row["field_id"] if row else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# nucleo de decision (PURO, sin side-effects -> unit-testable via --self-test)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def classify(*, dead_link_confirmed, sibling_valid_links, branch_live_opp_ids,
|
||||
branch_resolved, branch_opp_already_linked):
|
||||
"""Decide la accion para una replica de Marca con link presuntamente muerto.
|
||||
|
||||
Args:
|
||||
dead_link_confirmed: bool | None — GET live del link dio 400 (True), 200 (False),
|
||||
o no se pudo verificar (None).
|
||||
sibling_valid_links: int — # de OTRAS opps de Marca del mismo contacto cuyo link
|
||||
apunta a una opp de sucursal VIVA.
|
||||
branch_live_opp_ids: list[str] — ids de opps VIVAS del contacto en su sucursal.
|
||||
branch_resolved: bool — se pudo resolver la sucursal del contacto.
|
||||
branch_opp_already_linked: bool — la(s) opp(s) viva(s) de sucursal YA estan
|
||||
enlazadas por otra opp de Marca (no disponibles para relink).
|
||||
|
||||
Returns: dict {action: DELETE|RELINK|SKIP, reason: str, relink_to: str|None}
|
||||
"""
|
||||
if dead_link_confirmed is False:
|
||||
return {"action": "SKIP", "reason": "link VIVO en GHL (cache stale); re-sync y re-evaluar", "relink_to": None}
|
||||
if dead_link_confirmed is None:
|
||||
return {"action": "SKIP", "reason": "no se pudo verificar el link en vivo (sucursal no resuelta)", "relink_to": None}
|
||||
|
||||
# link confirmado MUERTO
|
||||
if sibling_valid_links >= 1:
|
||||
return {"action": "DELETE", "reason": "replica obsoleta: el contacto ya tiene otra opp de Marca con link valido", "relink_to": None}
|
||||
|
||||
if branch_resolved and len(branch_live_opp_ids) == 0:
|
||||
return {"action": "DELETE", "reason": "huerfana real: el contacto no tiene opps vivas en su sucursal", "relink_to": None}
|
||||
|
||||
if branch_resolved and len(branch_live_opp_ids) == 1 and not branch_opp_already_linked:
|
||||
return {"action": "RELINK", "reason": "id rotado: re-apuntar al unico id de opp vivo de la sucursal", "relink_to": branch_live_opp_ids[0]}
|
||||
|
||||
return {"action": "SKIP", "reason": "ambiguo (multi-opp en sucursal o ya enlazadas); revision humana", "relink_to": None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# verificacion en vivo
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _headers(token):
|
||||
return {"Authorization": f"Bearer {token}", "Version": "2021-07-28", "Accept": "application/json"}
|
||||
|
||||
|
||||
def opp_alive_live(opp_id, token):
|
||||
"""True si la opp existe en vivo, False si 400/404 (borrada), None si error de red."""
|
||||
try:
|
||||
r = requests.get(f"{GHL_BASE}/opportunities/{opp_id}", headers=_headers(token), timeout=30)
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
if r.status_code in (400, 404):
|
||||
return False
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def branch_live_opps_for_contact(branch_contact_id, location_id, token):
|
||||
"""Ids de opps vivas del contacto en su sucursal (search by contact)."""
|
||||
try:
|
||||
r = requests.get(f"{GHL_BASE}/opportunities/search",
|
||||
headers=_headers(token),
|
||||
params={"location_id": location_id, "contact_id": branch_contact_id},
|
||||
timeout=30)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return [o["id"] for o in r.json().get("opportunities", [])]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# carga de datos
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def load_state(conn):
|
||||
brand_opp_link = resolve_field_id(conn, BRAND_LOCATION_ID, "opportunity", OPP_LINK_FIELD_KEY)
|
||||
brand_contact_link = resolve_field_id(conn, BRAND_LOCATION_ID, "contact", CONTACT_LINK_FIELD_KEY)
|
||||
|
||||
branch_opp_ids = {
|
||||
r["id"] for r in conn.execute(
|
||||
"SELECT id FROM opportunities WHERE location_id NOT IN (?,?,?)",
|
||||
(BRAND_LOCATION_ID, *DEMO_LOCATION_IDS),
|
||||
)
|
||||
}
|
||||
# branch contact id -> location_id (para resolver la sucursal del contacto de Marca)
|
||||
branch_contact_loc = {}
|
||||
for r in conn.execute(
|
||||
"SELECT id, location_id FROM contacts WHERE location_id NOT IN (?,?,?)",
|
||||
(BRAND_LOCATION_ID, *DEMO_LOCATION_IDS),
|
||||
):
|
||||
branch_contact_loc[r["id"]] = r["location_id"]
|
||||
|
||||
brand_opps = [dict(r) for r in conn.execute(
|
||||
"SELECT id, name, status, monetary_value, contact_id, custom_fields_json "
|
||||
"FROM opportunities WHERE location_id=?", (BRAND_LOCATION_ID,))]
|
||||
brand_contacts = {r["id"]: dict(r) for r in conn.execute(
|
||||
"SELECT id, custom_fields_json FROM contacts WHERE location_id=?", (BRAND_LOCATION_ID,))}
|
||||
|
||||
return {
|
||||
"brand_opp_link": brand_opp_link,
|
||||
"brand_contact_link": brand_contact_link,
|
||||
"branch_opp_ids": branch_opp_ids,
|
||||
"branch_contact_loc": branch_contact_loc,
|
||||
"brand_opps": brand_opps,
|
||||
"brand_contacts": brand_contacts,
|
||||
}
|
||||
|
||||
|
||||
def build_plans(conn, tokens, *, only_opp=None, log=safe_print):
|
||||
st = load_state(conn)
|
||||
bf = st["brand_opp_link"]
|
||||
cf = st["brand_contact_link"]
|
||||
if not bf:
|
||||
raise RuntimeError("No se resolvio el field_id de 'ID Oportunidad Sucursal' en Marca (corre sync de metadata).")
|
||||
|
||||
# opps de Marca agrupadas por contacto + validez de su link (para sibling check)
|
||||
opps_by_contact = {}
|
||||
for o in st["brand_opps"]:
|
||||
lv = (extract_cf(o["custom_fields_json"], bf) or "").strip()
|
||||
o["_link"] = lv
|
||||
o["_link_valid"] = bool(lv) and lv in st["branch_opp_ids"]
|
||||
opps_by_contact.setdefault(o["contact_id"], []).append(o)
|
||||
|
||||
# candidatos = link no vacio y NO en el set de ids de sucursal
|
||||
candidates = [o for o in st["brand_opps"]
|
||||
if o["_link"] and not o["_link_valid"]]
|
||||
if only_opp:
|
||||
candidates = [o for o in candidates if o["id"] == only_opp]
|
||||
|
||||
log(f"Candidatos dead-link (cache): {len(candidates)}")
|
||||
plans = []
|
||||
for o in candidates:
|
||||
cid = o["contact_id"]
|
||||
siblings = [s for s in opps_by_contact.get(cid, []) if s["id"] != o["id"]]
|
||||
sibling_valid = sum(1 for s in siblings if s["_link_valid"])
|
||||
|
||||
# resolver sucursal del contacto via id_contacto_sucursal
|
||||
bc = st["brand_contacts"].get(cid, {})
|
||||
branch_contact_id = (extract_cf(bc.get("custom_fields_json"), cf) or "").strip() if cf else ""
|
||||
branch_loc = st["branch_contact_loc"].get(branch_contact_id)
|
||||
branch_resolved = bool(branch_loc and branch_loc in tokens)
|
||||
|
||||
dead_confirmed = None
|
||||
branch_live = []
|
||||
already_linked = False
|
||||
if branch_resolved:
|
||||
tok = tokens[branch_loc]
|
||||
alive = opp_alive_live(o["_link"], tok)
|
||||
dead_confirmed = (alive is False) if alive is not None else None
|
||||
live = branch_live_opps_for_contact(branch_contact_id, branch_loc, tok)
|
||||
branch_live = live or []
|
||||
# ¿alguna opp viva de la sucursal ya esta enlazada por otra opp de Marca?
|
||||
linked_ids = {s["_link"] for s in opps_by_contact.get(cid, []) if s["_link_valid"]}
|
||||
already_linked = any(bid in linked_ids for bid in branch_live)
|
||||
|
||||
decision = classify(
|
||||
dead_link_confirmed=dead_confirmed,
|
||||
sibling_valid_links=sibling_valid,
|
||||
branch_live_opp_ids=branch_live,
|
||||
branch_resolved=branch_resolved,
|
||||
branch_opp_already_linked=already_linked,
|
||||
)
|
||||
plans.append({
|
||||
"marca_opp_id": o["id"],
|
||||
"name": o["name"],
|
||||
"status": o["status"],
|
||||
"monetary_value": o["monetary_value"],
|
||||
"contact_id": cid,
|
||||
"dead_link": o["_link"],
|
||||
"branch_contact_id": branch_contact_id,
|
||||
"branch_location_id": branch_loc,
|
||||
"branch_resolved": branch_resolved,
|
||||
"dead_link_confirmed": dead_confirmed,
|
||||
"sibling_valid_links": sibling_valid,
|
||||
"branch_live_opp_ids": branch_live,
|
||||
"branch_opp_already_linked": already_linked,
|
||||
**decision,
|
||||
})
|
||||
return plans
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# aplicacion
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def snapshot(plans, run_id, dry_run):
|
||||
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(MIGRATIONS_DIR, f"reconcile_brand_deadlink_opps_{ts}.json")
|
||||
json.dump({"run_id": run_id, "dry_run": dry_run, "plans": plans},
|
||||
open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2, default=str)
|
||||
return path
|
||||
|
||||
|
||||
def apply_plans(plans, tokens, brand_link_field_id, *, run_id, log=safe_print):
|
||||
btok = tokens[BRAND_LOCATION_ID]
|
||||
stats = {"deleted": 0, "relinked": 0, "skipped": 0, "errors": 0}
|
||||
for p in plans:
|
||||
action = p["action"]
|
||||
oid = p["marca_opp_id"]
|
||||
if action == "SKIP":
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if action == "DELETE":
|
||||
snap = None
|
||||
try:
|
||||
snap = (gc.get_opportunity(btok, oid) or {}).get("opportunity")
|
||||
except Exception:
|
||||
pass
|
||||
change_id = script_audit.record_change(
|
||||
run_id, BRAND_LOCATION_ID, "opportunity", oid, "_delete",
|
||||
"reconcile_deadlink_delete",
|
||||
{"opportunity": snap, "dead_link": p["dead_link"]}, {"deleted": True}) if run_id else None
|
||||
try:
|
||||
gc.delete_opportunity(btok, oid, BRAND_LOCATION_ID)
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "applied")
|
||||
stats["deleted"] += 1
|
||||
log(f" DELETE ok {oid} ({p['name']})")
|
||||
except Exception as e:
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "failed", str(e))
|
||||
stats["errors"] += 1
|
||||
log(f" DELETE ERROR {oid}: {e}")
|
||||
elif action == "RELINK":
|
||||
new = p["relink_to"]
|
||||
change_id = script_audit.record_change(
|
||||
run_id, BRAND_LOCATION_ID, "opportunity", oid, brand_link_field_id,
|
||||
"reconcile_deadlink_relink",
|
||||
{"value": p["dead_link"]}, {"value": new}) if run_id else None
|
||||
try:
|
||||
gc.update_opportunity(btok, oid, {"customFields": [
|
||||
{"id": brand_link_field_id, "key": OPP_LINK_FIELD_KEY, "field_value": new}]})
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "applied")
|
||||
stats["relinked"] += 1
|
||||
log(f" RELINK ok {oid}: {p['dead_link']} -> {new}")
|
||||
except Exception as e:
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "failed", str(e))
|
||||
stats["errors"] += 1
|
||||
log(f" RELINK ERROR {oid}: {e}")
|
||||
return stats
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# run
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def run(*, apply=False, run_id=None, only_opp=None, resync_first=False, log=safe_print):
|
||||
accounts = sync_engine.parse_accounts_csv()
|
||||
tokens = {a["location_id"]: a["token"] for a in accounts}
|
||||
|
||||
if resync_first:
|
||||
log("Re-sync de Marca antes de detectar...")
|
||||
sync_engine.sync_account(BRAND_LOCATION_ID, tokens[BRAND_LOCATION_ID])
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
brand_link_field_id = resolve_field_id(conn, BRAND_LOCATION_ID, "opportunity", OPP_LINK_FIELD_KEY)
|
||||
plans = build_plans(conn, tokens, only_opp=only_opp, log=log)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
by_action = {"DELETE": [], "RELINK": [], "SKIP": []}
|
||||
for p in plans:
|
||||
by_action[p["action"]].append(p)
|
||||
|
||||
log(f"\nPlan: DELETE={len(by_action['DELETE'])} RELINK={len(by_action['RELINK'])} SKIP={len(by_action['SKIP'])}")
|
||||
for p in plans:
|
||||
log(f" [{p['action']}] {p['marca_opp_id']} '{p['name']}' (${p['monetary_value']}) "
|
||||
f"dead={p['dead_link']} -> {p['reason']}"
|
||||
+ (f" relink_to={p['relink_to']}" if p.get('relink_to') else ""))
|
||||
|
||||
snap = snapshot(plans, run_id, dry_run=not apply)
|
||||
log(f"Snapshot: {snap}")
|
||||
|
||||
summary = {"candidates": len(plans),
|
||||
"delete": len(by_action["DELETE"]),
|
||||
"relink": len(by_action["RELINK"]),
|
||||
"skip": len(by_action["SKIP"]),
|
||||
"deleted": 0, "relinked": 0, "errors": 0}
|
||||
|
||||
if not apply:
|
||||
log("\nDRY-RUN. Para aplicar: --apply --run-id <uuid>")
|
||||
return {"summary": summary, "plans": plans, "snapshot": snap}
|
||||
|
||||
actionable = by_action["DELETE"] + by_action["RELINK"]
|
||||
if not actionable:
|
||||
log("\nNada accionable. Dead-links = 0 (o todo SKIP).")
|
||||
return {"summary": summary, "plans": plans, "snapshot": snap}
|
||||
|
||||
if run_id:
|
||||
script_audit.create_run(run_id, "reconcile_brand_deadlink_opps.py",
|
||||
arguments=f"delete:{summary['delete']} relink:{summary['relink']}",
|
||||
locations=[BRAND_LOCATION_ID])
|
||||
log("\nAplicando...")
|
||||
stats = apply_plans(plans, tokens, brand_link_field_id, run_id=run_id, log=log)
|
||||
summary.update(deleted=stats["deleted"], relinked=stats["relinked"], errors=stats["errors"])
|
||||
if run_id:
|
||||
script_audit.update_run_status(run_id, "completed" if stats["errors"] == 0 else "failed",
|
||||
f"errors={stats['errors']}" if stats["errors"] else None)
|
||||
log(f"\nResumen: deleted={summary['deleted']} relinked={summary['relinked']} errors={summary['errors']}")
|
||||
return {"summary": summary, "plans": plans, "snapshot": snap}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# self-test (valida classify() sin tocar API/DB) — repetible, deterministico
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def self_test():
|
||||
cases = [
|
||||
# (descripcion, kwargs, accion_esperada)
|
||||
("sibling valido -> DELETE (Ernesto/Gerardo)",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=1, branch_live_opp_ids=["B1"],
|
||||
branch_resolved=True, branch_opp_already_linked=True), "DELETE"),
|
||||
("0 opps vivas -> DELETE (huerfana real)",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=0, branch_live_opp_ids=[],
|
||||
branch_resolved=True, branch_opp_already_linked=False), "DELETE"),
|
||||
("1 opp viva no enlazada -> RELINK (Patricia/Lizeth)",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=0, branch_live_opp_ids=["B9"],
|
||||
branch_resolved=True, branch_opp_already_linked=False), "RELINK"),
|
||||
("multi-opp viva -> SKIP (ambiguo)",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=0, branch_live_opp_ids=["B1", "B2"],
|
||||
branch_resolved=True, branch_opp_already_linked=False), "SKIP"),
|
||||
("link VIVO en GHL -> SKIP (cache stale)",
|
||||
dict(dead_link_confirmed=False, sibling_valid_links=0, branch_live_opp_ids=["B1"],
|
||||
branch_resolved=True, branch_opp_already_linked=False), "SKIP"),
|
||||
("no verificable -> SKIP",
|
||||
dict(dead_link_confirmed=None, sibling_valid_links=0, branch_live_opp_ids=[],
|
||||
branch_resolved=False, branch_opp_already_linked=False), "SKIP"),
|
||||
("1 opp viva PERO ya enlazada por otra -> SKIP",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=0, branch_live_opp_ids=["B9"],
|
||||
branch_resolved=True, branch_opp_already_linked=True), "SKIP"),
|
||||
("sibling valido gana aunque haya 1 viva libre -> DELETE",
|
||||
dict(dead_link_confirmed=True, sibling_valid_links=2, branch_live_opp_ids=["B9"],
|
||||
branch_resolved=True, branch_opp_already_linked=False), "DELETE"),
|
||||
]
|
||||
ok = 0
|
||||
for desc, kw, expected in cases:
|
||||
got = classify(**kw)["action"]
|
||||
status = "OK " if got == expected else "FAIL"
|
||||
if got == expected:
|
||||
ok += 1
|
||||
safe_print(f" [{status}] {desc}: esperado={expected} got={got}")
|
||||
safe_print(f"\nself-test: {ok}/{len(cases)} pasaron")
|
||||
return ok == len(cases)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Reconcilia replicas huerfanas de opps en Marca con link muerto.")
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
ap.add_argument("--run-id")
|
||||
ap.add_argument("--only-opp", help="Filtra a una sola opp de Marca por id.")
|
||||
ap.add_argument("--resync-first", action="store_true", help="Re-sync de Marca antes de detectar.")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--self-test", action="store_true", help="Valida classify() con fixtures (sin API).")
|
||||
args = ap.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
if args.self_test:
|
||||
sys.exit(0 if self_test() else 1)
|
||||
|
||||
run_id = args.run_id
|
||||
if args.apply and not run_id:
|
||||
run_id = str(uuid.uuid4())
|
||||
safe_print(f"[info] run_id autogenerado: {run_id}")
|
||||
|
||||
result = run(apply=args.apply, run_id=run_id, only_opp=args.only_opp,
|
||||
resync_first=args.resync_first,
|
||||
log=(lambda *a: None) if args.json else safe_print)
|
||||
if args.json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user