11 KiB
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-29-descuadre-opp-multiempeno | 2026-05-29 | descuadre, config_location, duplicado |
|
|
|
resuelto (5 mislinks pendientes de re-enlace) |
|
|
TRIGGERS
descuadre +1 opp/diferencia oportunidades/ dashboard "Marca > sucursales" en opps400 "Can not create duplicate opportunity for the contact"al crear/replicar una oppallowDuplicateOpportunity(flag de settings de location)- multi-empeño "no se replica a Marca" / contacto con 2 opps en sucursal y 1 en Marca
link muerto/ID Oportunidad Sucursalapunta a opp borrada (GET da400 "Opportunity doesn't exist or is deleted")- réplica obsoleta que el n8n no borró tras rotar el id nativo de una opp
- token de agencia /
GHL_AGENCY_TOKEN/ 401 enPUT /locations
SÍNTOMA
Dashboard Comparativa: +1 en oportunidades (Marca 1340 vs suma sucursales 1339). Contactos también +1 (benigno, aparte). Signo positivo ⇒ sospechar cache viejo, huérfanos en Marca o doble réplica (ver positive_descuadre_stale_cache).
DIAGNÓSTICO
1. Audit base (read-only, vuelca a archivo):
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
Reveló: diff.opportunities=1, opportunities_in_branch_not_in_brand=0 (¡nada falta según el audit!), intra_brand_duplicates=0. → El +1 es una opp de Marca "de más" que el audit no marca, porque su matching por-contacto la da por replicada.
2. Matching 1:1 estricto por link (clave para descuadre POSITIVO — el conteo del dashboard es por FILAS, no por matching):
# agrupar opps de Marca por su CF "ID Oportunidad Sucursal" (resolve_opp_link_field_id)
# 1340 opps Marca = 1334 con link a opp de sucursal existente + 6 con link muerto/vacío
# los 1334 apuntan a solo 1333 opps distintas -> 1 link COMPARTIDO por 2 opps de Marca = +1
3. Atribución EQUIVOCADA #1 (descartada): el link compartido era kGda02 (opp MIGUEL ANGEL de Eugenia), reclamado por 2 opps de Marca (1A3P5b $80,200 y 1l0S9v $0). Parecía "doble réplica de Eugenia". Falso: al verificar en vivo, eran dos personas distintas llamadas Miguel Angel (tel …4949 Temixco vs …4950 Eugenia), cada una con 1 opp en su sucursal y 1 en Marca → balanceado en filas. El link compartido era un mislink (Frankenstein), NO la causa del +1.
4. Matching robusto por phone-de-contacto (porque los links estaban podridos):
# para cada opp de sucursal, buscar opp de Marca con MISMO phone de contacto (consumir 1:1)
# las opps de Marca sin pareja = sobrantes reales; las de sucursal sin pareja = faltantes
Con phone real salieron: sobran ZONYA Wo4MXw ($45k) y LUIS Fv4dLJ ($0); falta Salvador (+524431452883).
5. Atribución EQUIVOCADA #2 (descartada): "falta Salvador". Falso: Salvador SÍ tiene opp en Marca (NW09og). El matching falló porque su id_contacto_sucursal estaba podrido.
6. Verificación en vivo (la que cerró el caso) — contar opps por contacto y estado de cada link:
# por contacto en Marca: cuántas opps y a qué apunta su "ID Oportunidad Sucursal"
# ZONYA contacto wbUhES: 2 opps -> hrZq7j($60k lost, link OK a Eugenia IO969JW) + Wo4MXw($45k open, link P84gFZ MUERTO)
# LUIS contacto dMAc8A: 2 opps -> ezqhFc($40k lost, link OK s1fA9Wt) + Fv4dLJ($0 open, link 0l0ya7 MUERTO)
# Salvador contacto fpVvOAo: 1 opp NW09og(open, link a emsgo1) ; en Morelia tiene 2 (emsgo1 open + OWGU1u won) -> falta la WON
Confirmado en vivo que P84gFZ y 0l0ya7 dan 400 "Opportunity doesn't exist or is deleted" en Eugenia → links muertos: la opp de Eugenia se borró+recreó (id rotó), el n8n creó la réplica nueva (hrZq7j/ezqhFc, ambas createdAt hoy) pero no borró la vieja → 2 opps obsoletas en Marca.
Aritmética final del +1: +2 (ZONYA+LUIS obsoletas) −1 (Salvador WON faltante, multi-empeño) = +1.
CAUSA RAÍZ
- 2 réplicas obsoletas en Marca (ZONYA
Wo4MXw, LUISFv4dLJ): el n8n de sync de opps no borra la réplica vieja cuando el id nativo de la opp de sucursal rota (borrado+recreación). Quedan conid_oportunidad_sucursalapuntando a una opp ya inexistente. - Faltante estructural de multi-empeño (Salvador): Marca tenía
settings.allowDuplicateOpportunity = false→ GHL rechaza la 2ª opp de un contacto con400 "Can not create duplicate opportunity for the contact". Por eso el n8n solo replica la 1ª opp por contacto. Las sucursales ya tenían el flag entrue.
ACCIÓN
- DELETE obsoletas (piloto→1→1, con snapshot + audit).
ghl_client.delete_opportunity+script_audit.record_change(object_type='opportunity', field='__deleted__', old=<opp completa>). Runsdescuadre_opp_del_20260529_190910(ZONYA),…_191318(LUIS). Tras ZONYA el contador ya fue +1→0; tras LUIS →−1 (destapó el faltante). - Activar el flag con el token de agencia (
GHL_AGENCY_TOKENen.env; el token PIT por-location da 401 enPUT /locations):
python scripts/enable_duplicate_opportunity.py --apply --json
# PUT /locations/{Marca} body {"settings":{"allowDuplicateOpportunity":true}} (solo ese flag; GHL hace merge)
# headers REQUIEREN User-Agent (sin él, 403). run a37d23ffe6574e0eb2ee8433bce2e1f3. false->true verificado.
- Crear la opp WON de Salvador (multi-empeño, ya no da 400):
python scripts/create_missing_branch_opps_in_marca.py --apply --yes \
--location jE41bVhhnb5T505BFm4F --only-opp OWGU1uPoWvITmwOLIyvq --run-id descuadre_create_salvador_<ts>
VERIFICACIÓN
- Conteo opps Marca: 1340 → (−ZONYA) 1339 → (−LUIS) 1338 → (+Salvador WON) 1339 = sucursales 1339.
- Audit final:
diff.opportunities == 0.
import json; d=json.load(open('generated/agent/runs/descuadre_audit_final.json',encoding='utf-8-sig'))
print(d['totals']['diff']['opportunities']) # 0
- Salvador en Marca:
[('won',15000),('open',15000)](espejo de Morelia). - Réplicas buenas intactas: ZONYA
hrZq7j$60k lost, LUISezqhFc$40k lost.
EDGE-CASES / TRAMPAS
- El conteo del dashboard es por FILAS, no por matching.
opportunities_in_branch_not_in_brand=0puede convivir con un descuadre real (el audit considera "replicada" una opp de sucursal si su contacto tiene CUALQUIER opp en Marca). Cazar el positivo con matching 1:1 estricto por link. - Link MUERTO ≠ link vacío ≠ opp ausente.
create_missing_branch_opps_in_marca.py --all-branchesmarcó 6 CREATE, pero solo Salvador (multi=True) era real. Las otras 5 (Gerardo, Ernesto, Patricia, Lizeth, Temixco) tenían 1 opp en Marca con link muerto → crearlas habría hecho 5 DUPLICADOS (lección Maria/HR99). Regla: antes de aplicar el barrido, por cada candidata verificar cuántas opps tiene el contacto en Marca y el estado del link (VACÍO/MUERTO/a-otra=Frankenstein). Si ya tiene réplica ⇒ es RELINK, no CREATE. - "El contador en 0 puede ocultar basura." Antes de tocar nada el descuadre era +1, pero escondía 2 opps fantasma (+2) y un faltante real (−1). Defecto numérico ≠ defecto de integridad.
- El flag
allowDuplicateOpportunityno quedó activo permanentemente tras una operación previa (volvió afalse). Si alguien lo apaga, la replicación n8n de multi-empeños vuelve a fallar con 400. PUT /locationsnecesita token de AGENCIA (locations.write) + headerUser-Agent. El PIT por-location da 401.- Identidad podrida engaña al matching: homónimos con tel casi igual (…4949 vs …4950) parecen duplicados pero son personas distintas;
id_contacto_sucursalpuede estar podrido. Verificar SIEMPRE en vivo por id antes de mutar.
REUTILIZABLE
# --- Aislar el sobrante real en un descuadre POSITIVO de opps (matching 1:1 estricto por link) ---
import sqlite3; from paths import DB_PATH; import scripts.audit_brand_vs_branches_totals as A
conn=sqlite3.connect(str(DB_PATH)); conn.row_factory=sqlite3.Row
BRAND=A.BRAND_LOCATION_ID; blink=A.resolve_opp_link_field_id(conn,BRAND)
brand=A.load_opps(conn,BRAND)
branch_ids=set()
for r in conn.execute("SELECT location_id FROM accounts"):
if r['location_id'] not in (BRAND,'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'):
branch_ids|={o['id'] for o in A.load_opps(conn,r['location_id'])}
used=set()
for o in brand:
lv=A.extract_opp_link_value(o.get('custom_fields_json'),blink)
if lv and lv in branch_ids and lv not in used: used.add(lv)
else: print('SOBRANTE/mislink:',o['id'],o['name'],lv) # link vacío, muerto o duplicado
# --- Verificar/activar el flag (dry-run sin --apply) ---
# python scripts/enable_duplicate_opportunity.py # dry-run
# python scripts/enable_duplicate_opportunity.py --apply # activa (token agencia en .env)
# python scripts/enable_duplicate_opportunity.py --disable --apply # rollback del flag
# python scripts/check_allowDuplicate_settings.py # verificación read-only multi-cuenta
# --- Estado de un link en vivo (muerto?) ---
# GET /opportunities/{id} con Version 2021-07-28 + User-Agent -> 400 "doesn't exist or is deleted" = MUERTO
PENDIENTES
5 mislinks (calidad de dato, NO afectan el conteo) — réplicas en Marca con id_oportunidad_sucursal muerto/mal; re-enlazar (PUT del CF) sin crear duplicados:
- Gerardo (
Bj2bIN→ debe apuntar a Moreliax3AXkY) - Ernesto (
UNtCRNQ→5kDn6b) - Patricia (
OGQtfmjF→zzBzWC) - Lizeth (
j0iKZo→LGSPKo) - Miguel Angel Temixco (
1A3P5b): linkea a EugeniakGda02(Frankenstein) y trae valor $80,200 en vez de $56,671 (TemixcoOQBrOQN9) → re-enlazar y corregir valor.
ENLACES
- Memorias: positive_opp_descuadre_double_replica, opp_multiplicity_replication_gap, duplicate_resolution_rules, create_duplicate_phone_contact_marca, positive_descuadre_stale_cache
- Playbook: docs/PLAYBOOK_DESCUADRE.md
- Scripts: scripts/enable_duplicate_opportunity.py, scripts/create_missing_branch_opps_in_marca.py (flag
--all-branches), scripts/audit_brand_vs_branches_totals.py, scripts/check_allowDuplicate_settings.py - Artefactos: snapshots en
generated/migrations/(ver frontmatter); audits engenerated/agent/runs/descuadre_audit_*.json