Files
MP-Manager/docs/casos/2026-05-30-descuadre-opp-deadlink.md
T
2026-05-30 14:31:19 -06:00

9.2 KiB
Raw Blame History

id, fecha, categoria, location_ids, run_ids, snapshots, status, memorias, playbooks
id fecha categoria location_ids run_ids snapshots status memorias playbooks
CASE-2026-05-30-descuadre-opp-deadlink 2026-05-30 descuadre
GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)
jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)
2eJPAdEGjC7iPhDDAeoy (85977 - MP - Interlomas)
99365455-dee6-4f1f-b52a-9076683e02bb
generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json
resuelto
positive_opp_descuadre_double_replica
positive_descuadre_stale_cache
ghl_opportunity_search_quirks
duplicate_resolution_rules
docs/PLAYBOOK_DESCUADRE.md
docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md

TRIGGERS

  • DIFERENCIA OPORTUNIDADES +2 / descuadre positivo de opps Marca > Sucursales
  • audit con todos los buckets de opps en 0 (opportunities_in_brand_duplicate_link: 0, opportunities_in_branch_not_in_brand: 0, opportunities_missing_id_field: 0) pero diff.opportunities ≠ 0
  • opp de Marca con "ID Oportunidad Sucursal" poblado pero GET /opportunities/{link} → HTTP 400 "Opportunity doesn't exist or is deleted."
  • "link muerto" / dead-link / réplica obsoleta que el n8n no borró al rotar el id de la opp de sucursal
  • contacto con 2 opps en Marca (una link válido, otra link muerto) y 1 sola opp viva en sucursal

SÍNTOMA

Dashboard Comparativa: Marca 1,341 opps / Sucursales (suma) 1,339 → +2 descuadre detectado. Contactos cuadrados (0).

DIAGNÓSTICO

  1. Frescura de caché (sospechoso #1 de positivo): sync_logs → "Sincronizar Todo" recién corrido 2026-05-30 10:09; Marca synced 10:09:13 con 1341 opps. Caché FRESCO → +2 real, no stale. (Los 3 "running syncs" del metrics eran zombies del 2026-05-20, ignorar.)
  2. Audit completo (scripts/audit_brand_vs_branches_totals.py --json): diff opps +2 pero TODOS los buckets de opps = 0. Ningún bucket lo explica → caso fuera de la cobertura del audit.
  3. Matching 1:1 robusto manual: agrupar opps de Marca por su link (extract_opp_link_value, field j029pu3OU02ATNccJR6l) vs set de ids nativos de las 47 sucursales (excl. demos). Resultado: 4 opps de Marca con link MUERTO (apunta a id que no está en ninguna sucursal); 0 sin link; 0 link duplicado; 1337 con link válido.
  4. Triage de las 4 (por contacto): 2 contactos (Ernesto, Gerardo) tienen una 2ª opp en Marca con link VÁLIDO a la opp viva → la de link muerto sobra (+2). Los otros 2 (Patricia, Lizeth) tienen 1 sola opp mal enlazada → 1:1, no inflan conteo (el audit las empareja vía contacto, por eso reportó 0 faltantes).
  5. Verificación EN VIVO: GET /opportunities/{deadlink} → HTTP 400 "doesn't exist or is deleted" en los 4. GET /opportunities/search?contact_id= → cada contacto de sucursal tiene exactamente 1 opp viva (coincide con caché).

Callejón descartado: los 2 scripts de cleanup NO sirven aquí. cleanup_brand_duplicate_replica_opps.py agrupa por link COMPARTIDO (aquí cada link muerto es único). cleanup_brand_orphan_opportunities.py empareja por NOMBRE → ve la réplica obsoleta como "sincronizada" (mismo nombre que la opp viva) y no la toca.

CAUSA RAÍZ

Réplicas obsoletas en Marca que el workflow n8n de sync de opps (Cfgwp0bOtDW8zuKW) dejó atrás cuando el id nativo de la opp de sucursal rotó (la opp original se borró/recreó en la sucursal). La réplica vieja quedó con el ID Oportunidad Sucursal apuntando a un id ya inexistente; el n8n creó una nueva réplica con el id nuevo en vez de actualizar la vieja → doble réplica. Patrón idéntico a positive_opp_descuadre_double_replica.

ACCIÓN

Confirmación explícita del usuario (borrar 2 + re-enlazar 2). Snapshot live previo en generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json. Todo bajo run_id=99365455-dee6-4f1f-b52a-9076683e02bb en script_audit (reversible desde dashboard).

  • DELETE (réplicas obsoletas en Marca): UNtCRNQHqLf4Vv4vdY39 (Ernesto Chavez, open $10k) + Bj2bINlklDAoLmTsyg3r (Gerardo Padilla, open $50k). gc.delete_opportunity.
  • RELINK (ID Oportunidad Sucursal → id vivo, gc.update_opportunity customFields): OGQtfmjFk31M5eXKDBpO (Patricia) RNwRPgWEpi0nWIBYKbeZzzBzWC4adBrdTA8WhQph; j0iKZoeeYb1wNOaWHwNN (Lizeth) wYLZJd4Xpj0K9HYyikWXLGSPKoeeEQWEq39HpPLi.

VERIFICACIÓN

  • Borradas: GET → HTTP 400 ambas. Re-enlaces: GET confirma el link nuevo en ambas.
  • Re-sync Marca: sync_account → 1339 opps (antes 1341).
  • Re-audit: diff opps = 0 (1339 = 1339), contactos = 0. Descuadre resuelto.

EDGE-CASES / TRAMPAS

  • GHL devuelve 400 (no 404) para opp borrada. Tratar 400 como "no existe".
  • El campo link sale bajo clave fieldValue en GET /opportunities/{id} (no value). Ver ghl_opportunity_search_quirks.
  • No confundir este caso con stale cache: aquí el caché estaba fresco. Verificar SIEMPRE frescura primero igual.
  • Patricia/Lizeth NO se borran (son la única réplica 1:1); borrarlas habría creado un faltante 2. Distinguir "sobra" (contacto tiene otra opp con link válido) de "mal enlazada" (única opp).

REUTILIZABLE

Snippet de detección de dead-link replicas (no lo cubre ningún bucket del audit):

import sqlite3, json, paths
BRAND='GbKkBpCmKu2QmloKFHy3'; DEMOS={'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'}
c=sqlite3.connect(str(paths.DB_PATH)); c.row_factory=sqlite3.Row
LINK='j029pu3OU02ATNccJR6l'  # field_id opportunity.id_oportunidad_sucursal en Marca
def extract(cf,fid):
    if not cf: return None
    for f in (json.loads(cf) or []):
        if isinstance(f,dict) and f.get('id')==fid: return f.get('value') or f.get('fieldValue')
branch_ids={r['id'] for r in c.execute("SELECT id FROM opportunities WHERE location_id NOT IN (?,?,?)",(BRAND,*DEMOS))}
for o in c.execute("SELECT id,name,contact_id,custom_fields_json FROM opportunities WHERE location_id=?",(BRAND,)):
    lv=(extract(o['custom_fields_json'],LINK) or '').strip()
    if lv and lv not in branch_ids: print('DEAD-LINK', o['id'], o['name'], '->', lv)

Verificación live de un dead-link: GET https://services.leadconnectorhq.com/opportunities/{id} con header Version: 2021-07-28 → 400 = borrada.

CAUSA DE FONDO (atacada 2026-05-30)

Investigación del workflow Cfgwp0bOtDW8zuKW (vía API n8n) reveló que NO se puede arreglar en el workflow: el trigger es un Webhook de creación/actualización de opp (payload con datos de vehículo/fuente); GHL NO dispara evento de borrado de opp. Por tanto una cascada de borrado en tiempo real (borrar la réplica de Marca cuando la opp de sucursal se borra) es inviable — no hay disparador. Además Decidir Match ya está correcto para opps vivas (Baserow global tabla 754 → fallback contacto → CREATE). Gap secundario detectado: Crear Oportunidad - MARCA y Actualizar Oportunidad - MARCA (v2) no tienen salida → no hacen upsert a Baserow en tiempo real (frescura solo por backfill cada 30 min); no es la causa del dead-link.

Solución desplegada = reconciliador determinista periódico (backstop que converge dead-links a 0 sin importar cómo surjan):

  • scripts/reconcile_brand_deadlink_opps.py: detección cache (link no en set de ids de sucursal) → verificación EN VIVO (GET 400) → classify() puro (DELETE réplica obsoleta / RELINK id rotado / SKIP ambiguo o cache stale) → snapshot + script_audit. Dry-run default. Registrado en SCRIPTS_METADATA (dashboard, mutator).
  • scripts/scheduled_deadlink_check.py + run_deadlink_check.bat + Tarea Programada Windows "MP Deadlink Check" (diaria 7:15am): corre dry-run con --resync-first y deja generated/runtime/deadlink_check_alert.json si hay accionables; el owner aplica desde el dashboard (protocolo dry-run).
  • Validación 100%: self-test 8/8 (--self-test, lógica de decisión); E2E RELINK en vivo (corromper link→fake, detect→GET400→relink al id vivo, auto-reversible); E2E DELETE en vivo (opp desechable creada+borrada); dry-run real = 0; descuadre global = 0.

GAP SECUNDARIO CERRADO (2026-05-30)

Cableado el upsert a Baserow en tiempo real tras CREATE/UPDATE en Cfgwp0bOtDW8zuKW (sus salidas estaban vacías). n8n/_add_baserow_opp_upsert.py --apply agregó 2 nodos (Preparar Upsert Mapeo + Crear Mapeo - Baserow), diseño create-only condicional con onError=continueRegularOutput (no puede romper la replicación). Cierra la ventana de 30 min del backfill: una opp recién replicada queda mapeada al instante → una re-ejecución hace UPDATE, no CREATE duplicado. Validado E2E live replicando el webhook real (exec 52872 crea la fila; exec 52873 re-dispara → match Baserow → no duplica). Ver n8n_opp_idempotency_baserow_mapping.

PENDIENTES

  • Considerar agregar al audit un bucket "dead-link" (link de Marca no presente en ningún id nativo de sucursal) para que la Comparativa lo reporte sin correr el reconciliador.

ENLACES