#!/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 python scripts/reconcile_brand_deadlink_opps.py --only-opp 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 ") 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()