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
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.
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.
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).
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).
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:
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.
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
# 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])"
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
(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).
Investigar si el evento del 2026-05-29 ~21:00 (re-disparo masivo) fue corrida manual/replay y acotarlo.
(Mejora) Persistir createdAt/updatedAt de GHL en opportunities (db.py) para no depender de GET en vivo al fechar.