---
id: CASE-2026-05-30-descuadre-opp-replica-duplicada-marca
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca - Monte Providencia)"]
run_ids: ["cdo-pilot-180d299cdd (piloto ANSELMO, 1 borrado)", "cdo-batch-13ce0ae8c0 (lote, 7 borrados)"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092546.json (piloto)", "generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092637.json (lote)"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[n8n_opp_sync_match]]", "[[matching_rules]]", "[[form_submissions_source_of_truth]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---

## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +10` / descuadre positivo Marca > Sucursales
- `réplicas duplicadas en Marca` / `mismo ID Oportunidad Sucursal` / dos opps de Marca apuntan a la misma opp de sucursal
- bucket `opportunities_in_brand_duplicate_link`
- workflow n8n `Cfgwp0bOtDW8zuKW` ("Sincronizar Oportunidad - Nodos Nuevos (Create/Update)") hace CREATE en vez de UPDATE
- nodo `Decidir Match (Create vs Update)`, `Buscar Oportunidades del Contacto - MARCA`
- `cleanup_brand_orphan_opportunities.py` reporta CERO huérfanas pero el descuadre sigue positivo
- caso antelmo↔anselmo / mismo teléfono distinto contacto

## SÍNTOMA
Dashboard 2026-05-30: OPORTUNIDADES MARCA 1349 vs SUCURSALES (suma) 1339 → **DIFERENCIA +10** (descuadre positivo). Contactos cuadrados (0).

## DIAGNÓSTICO
1. `python scripts/cleanup_brand_orphan_opportunities.py` → **0 huérfanas**; 1345/1349 opps protegidas por el link "ID Oportunidad Sucursal". Si todas tienen contraparte y aun así Marca>Sucursales, la única explicación es **varias opps de Marca apuntando al MISMO id de sucursal**.
2. Query de unicidad del link (extract_opp_link_value + OPP_ID_PATTERN sobre `opportunities` location Marca, agrupando por valor): **8 valores de link con 2 opps de Marca cada uno → 8 opps sobrantes**. Confirmado.
3. `createdAt` en vivo (el cache SQLite NO guarda fecha — `opportunities.date_added` es NULL): cada par = 1 original (creado 2026-04-24..05-22) + 1 duplicado **creado en ráfaga 2026-05-29 ~20:48–21:14 hora local (02:48–03:14 UTC del 05-30)**, con `updated==created` (intactos).
4. n8n `Cfgwp0bOtDW8zuKW`: el nodo `Decidir Match (Create vs Update)` busca opps **acotado al contacto de Marca resuelto** (`/opportunities/search?contact_id=<Set Contact ID Resuelto>`) y compara el CF "ID Oportunidad Sucursal" (`fieldValueString ?? fieldValue`) contra el id de la opp de sucursal. Sin match → CREATE (sin fallback por nombre, intencional).
5. **Evidencia decisiva**: para 2 pares, `keep.contactId != delete.contactId`. La búsqueda del nodo por el contacto del KEEP SÍ devuelve el CF (bajo `fieldValueString`) → el código de match está bien. El fallo es que original y duplicado cuelgan de **contactos de Marca distintos**:
   - ANSELMO: `lGfjbkKEB25jittwcKLd` "ANSELMO SANCHEZ" (tel +525523396616, temo6715@gmail.com, 05-20) vs `KEo4p3e5OvWAvosnYrtT` "Antelmo López Rodríguez" (**mismo tel**, cuauhplayer@gmail.com, 05-27).
   - MIGUEL: `RwxMQr0Ywvydjr3veCYo` "Miguel Angel" (tel +527775114949, ene-08) vs `hE9U9Q62Xgd0wPeq6L80` "MIGUEL ANGEL" (**tel distinto, sin email**, 05-28).

## CAUSA RAÍZ
Una **misma opp de sucursal** se replicó a Marca **dos veces, cada vez sobre un contacto de Marca diferente**, porque la identidad del contacto en Marca es ambigua (mismo teléfono con nombre/email variante; o teléfono distinto sin email). El nodo `Decidir Match` evalúa la llave de idempotencia (id de opp de sucursal) **solo dentro del contacto resuelto ese run**, no de forma global en Marca. Al resolver un contacto distinto al que tiene la opp original, no la encuentra y hace CREATE → réplica duplicada. (No es un bug del lector de CF; es scope del match + duplicidad/ambigüedad de contacto.)

## ACCIÓN
- **Detección (read-only, aplicada):** nuevo bucket `opportunities_in_brand_duplicate_link` en `scripts/audit_brand_vs_branches_totals.py` (agrupa opps de Marca por valor de link; >1 = duplicado; recomienda keep/delete por jerarquía). Expuesto en dashboard (tarjeta + export CSV `bucket=brand_duplicate_link_opps`).
- **Limpieza (mutador, dry-run validado, PENDIENTE de aplicar):** `scripts/cleanup_brand_duplicate_replica_opps.py`. Detecta clusters vía audit, trae cada opp en vivo, conserva la canónica (jerarquía: valor → status → createdAt más antiguo) y borra las sobrantes. Snapshot en `generated/migrations/` + `script_audit` (reversible por run_id). Endpoint `POST /api/comparativa/cleanup-duplicate-opps` + botón "Limpiar duplicados" + registrado en `script_runner.py`.
  - Dry-run: 8 clusters / 8 a borrar; en los 8 conserva el original antiguo y borra el duplicado del 2026-05-30. Comando: `python scripts/cleanup_brand_duplicate_replica_opps.py` (dry-run) → `--apply --run-id <uuid>`.
- **Raíz n8n (PENDIENTE de confirmación del owner):** endurecer `Cfgwp0bOtDW8zuKW` para que la idempotencia no dependa del contacto resuelto. Ver PENDIENTES.

## VERIFICACIÓN
- Antes: bucket `opportunities_in_brand_duplicate_link` = 16 items / 8 grupos / 8 sobrantes; `diff.opportunities` = +10; brand opps = 1349.
- **Después (CONFIRMADO 2026-05-30):** piloto borró 1 (ANSELMO `prRKgLINCgclX9V3O6R0`, verificado: GET da 400 "deleted", la canónica `yjiU8pjCkohiPpJGZlH6` permanece). Lote borró 7 (CESAR, JOSE LUIS ARQ, SANTIAGO FLORES, "d", Alfonso Mendoza, MARIA DE LOS ANGELES, MIGUEL ANGEL). Re-sync de Marca → **brand opps 1349 → 1341 (−8)**, bucket duplicados = **0**, `diff.opportunities` = **+10 → +2**.
- El **+2 residual** es estructural: opps de Marca cuyo origen vive en cuenta demo/excluida de la suma filtrada (1339), NO duplicados (bucket=0 y orphan-check=0). Benigno; documentar si se desea cero absoluto.
- Comando de verificación: `python scripts/audit_brand_vs_branches_totals.py --json` y leer `missing.opportunities_in_brand_duplicate_link`.
- GHL responde **400** (no 404) al GET de una opp borrada → `fetch_opp_live` trata 400/404/"deleted"/"doesn't exist" como inexistente (fix tras el 1er intento de lote que crasheó en fase de planificación, sin borrar nada).

## EDGE-CASES / TRAMPAS
- `cleanup_brand_orphan_opportunities.py` NO ve este problema: trata el link como salvaguarda y nunca verifica unicidad. No concluir "todo limpio" con ese script en un descuadre positivo.
- `opportunities.date_added` es NULL en el cache → para fechar "cuándo surgió" hay que ir en vivo (`GET /opportunities/{id}` → `createdAt`). El limpiador desempata por createdAt en vivo, no por el cache.
- Al borrar la opp duplicada queda un **contacto de Marca posiblemente huérfano de opp** (p.ej. "Antelmo López"). Es un problema de contacto aparte (ver `delete_intra_brand_duplicates.py` + [[matching_rules]]); el limpiador de opps NO lo toca.
- Phone solo nunca es match ([[matching_rules]]): ANSELMO/Antelmo comparten tel pero son contactos distintos en Marca; no fusionar a ciegas.

## REUTILIZABLE
```bash
# Detectar opps de Marca con link duplicado (read-only):
python scripts/audit_brand_vs_branches_totals.py --json   # -> missing.opportunities_in_brand_duplicate_link
# Limpiar (dry-run -> piloto -> lote):
python scripts/cleanup_brand_duplicate_replica_opps.py
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link <id_opp_sucursal> --apply --run-id <uuid>
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --run-id <uuid>
# Inspeccionar workflow n8n de sync de opps:
python -c "import sys;sys.path.insert(0,'scripts');import n8n_workflow_lib as l;c=l.N8NClient(*l.load_credentials());import json;print(json.dumps(c.get_workflow('Cfgwp0bOtDW8zuKW')['nodes'],ensure_ascii=False)[:2000])"
```

## FIX PREVENTIVO n8n (APLICADO 2026-05-30) — opción (c) mapeo Baserow
Idempotencia GLOBAL por id de opp de sucursal, independiente del contacto.
- **Tabla Baserow creada por API**: DB 63, **table_id=754** "Mapeo Opp Sucursal-Marca": `id_opp_sucursal` (primario, field 7280), `id_opp_marca` (7283), `location_id_sucursal`, `updated_at`. Se extendió `scripts/baserow_client.py` con `create_table/create_field/update_field/delete_field`.
- **Backfill**: `scripts/backfill_baserow_opp_mapping.py --table-id 754 --apply` → 1341 mapeos (0 duplicados). Gotcha: el JWT de Baserow EXPIRA a mitad (~1004 creates → 401 ERROR_INVALID_ACCESS_TOKEN); re-ejecutar (idempotente, `ya_ok`) completa el resto.
- **Rewire `Cfgwp0bOtDW8zuKW`** (`n8n/_add_baserow_opp_idempotency.py --apply`; backup `n8n/backup_pre_baserow_opp_idempotency_*.json`; versionId nuevo `9caa764a-...`): nodo Baserow `Buscar Mapeo Opp - Baserow` (getAll tabla 754, filtro field 7280 == opp_id sucursal, `alwaysOutputData`+`onError=continueRegularOutput`) insertado `Set Contact ID Resuelto → [lookup] → Buscar Oportunidades del Contacto - MARCA → Decidir Match`. `Decidir Match` reescrito: si mapeo Baserow → UPDATE global; si no, fallback CF por contacto; si nada → CREATE. Degrada a la lógica previa si Baserow falla (try/catch).
- **Validación (data-layer, segura)**: lookup por link mapeado devuelve 1 fila con `id_opp_marca` correcto (ANSELMO/MIGUEL); link inexistente → 0 filas. NO se disparó webhook real (evita mutar producción); la próxima sync real ejercita el path.
- **Frescura**: Tarea Programada Windows **"MP Baserow Opp Mapping"** (cada 30 min, `run_baserow_opp_mapping.bat`) corre el backfill idempotente para mapear opps nuevas.

## PENDIENTES
1. (Opcional) Confirmar con webhook real / próxima ejecución de producción que el path UPDATE-vía-Baserow funciona E2E (riesgo bajo: degrada a lógica previa si algo falla).
2. Investigar si el evento del 2026-05-29 ~21:00 (re-disparo masivo) fue corrida manual/replay y acotarlo.
3. (Mejora) Persistir `createdAt`/`updatedAt` de GHL en `opportunities` (`db.py`) para no depender de GET en vivo al fechar.

## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[n8n_opp_sync_match]], [[matching_rules]], [[form_submissions_source_of_truth]], [[duplicate_resolution_rules]], [[name_account_with_location_id]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/cleanup_brand_duplicate_replica_opps.py, scripts/audit_brand_vs_branches_totals.py
- Workflow: n8n Cfgwp0bOtDW8zuKW
