---
id: CASE-2026-05-30-descuadre-opp-deadlink
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "2eJPAdEGjC7iPhDDAeoy (85977 - MP - Interlomas)"]
run_ids: ["99365455-dee6-4f1f-b52a-9076683e02bb"]
snapshots: ["generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[positive_descuadre_stale_cache]]", "[[ghl_opportunity_search_quirks]]", "[[duplicate_resolution_rules]]"]
playbooks: ["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) `RNwRPgWEpi0nWIBYKbeZ`→`zzBzWC4adBrdTA8WhQph`; `j0iKZoeeYb1wNOaWHwNN` (Lizeth) `wYLZJd4Xpj0K9HYyikWX`→`LGSPKoeeEQWEq39HpPLi`.

## 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):
```python
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
- Memorias: [[positive_opp_descuadre_double_replica]], [[positive_descuadre_stale_cache]], [[ghl_opportunity_search_quirks]], [[duplicate_resolution_rules]], [[n8n_opp_sync_match]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/backfill_opp_sucursal_link.py (patrón PUT relink)
- Snapshot: generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json
