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

11 KiB
Raw Blame History

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
GbKkBpCmKu2QmloKFHy3
jE41bVhhnb5T505BFm4F
nF1uEaYB3mCK5em9bPn2
yjqKxoO02rsdwdJZSPmD
descuadre_opp_del_20260529_190910
descuadre_opp_del_20260529_191318
a37d23ffe6574e0eb2ee8433bce2e1f3
descuadre_create_salvador_*
generated/migrations/descuadre_opp_20260529_snapshot.json
generated/migrations/enable_dup_opp_GbKkBpCmKu2QmloKFHy3_20260529_193342.json
generated/migrations/create_missing_branch_opps_20260529_193836.json
resuelto (5 mislinks pendientes de re-enlace)
positive_opp_descuadre_double_replica
opp_multiplicity_replication_gap
duplicate_resolution_rules
create_duplicate_phone_contact_marca
docs/PLAYBOOK_DESCUADRE.md

TRIGGERS

  • descuadre +1 opp / diferencia oportunidades / dashboard "Marca > sucursales" en opps
  • 400 "Can not create duplicate opportunity for the contact" al crear/replicar una opp
  • allowDuplicateOpportunity (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 Sucursal apunta a opp borrada (GET da 400 "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 en PUT /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

  1. 2 réplicas obsoletas en Marca (ZONYA Wo4MXw, LUIS Fv4dLJ): 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 con id_oportunidad_sucursal apuntando a una opp ya inexistente.
  2. Faltante estructural de multi-empeño (Salvador): Marca tenía settings.allowDuplicateOpportunity = false → GHL rechaza la 2ª opp de un contacto con 400 "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 en true.

ACCIÓN

  1. DELETE obsoletas (piloto→1→1, con snapshot + audit). ghl_client.delete_opportunity + script_audit.record_change(object_type='opportunity', field='__deleted__', old=<opp completa>). Runs descuadre_opp_del_20260529_190910 (ZONYA), …_191318 (LUIS). Tras ZONYA el contador ya fue +1→0; tras LUIS →−1 (destapó el faltante).
  2. Activar el flag con el token de agencia (GHL_AGENCY_TOKEN en .env; el token PIT por-location da 401 en PUT /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.
  1. 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, LUIS ezqhFc $40k lost.

EDGE-CASES / TRAMPAS

  • El conteo del dashboard es por FILAS, no por matching. opportunities_in_branch_not_in_brand=0 puede 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-branches marcó 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 allowDuplicateOpportunity no quedó activo permanentemente tras una operación previa (volvió a false). Si alguien lo apaga, la replicación n8n de multi-empeños vuelve a fallar con 400.
  • PUT /locations necesita token de AGENCIA (locations.write) + header User-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_sucursal puede 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 Morelia x3AXkY)
  • Ernesto (UNtCRNQ5kDn6b)
  • Patricia (OGQtfmjFzzBzWC)
  • Lizeth (j0iKZoLGSPKo)
  • Miguel Angel Temixco (1A3P5b): linkea a Eugenia kGda02 (Frankenstein) y trae valor $80,200 en vez de $56,671 (Temixco OQBrOQN9) → re-enlazar y corregir valor.

ENLACES