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

86 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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:4821:14 hora local (02:4803: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