--- id: CASE-2026-05-29-descuadre-opp-multiempeno fecha: 2026-05-29 categoria: descuadre, config_location, duplicado location_ids: - GbKkBpCmKu2QmloKFHy3 # Marca (Monte Providencia) - jE41bVhhnb5T505BFm4F # 85964 - MP - Morelia 1 (Salvador) - nF1uEaYB3mCK5em9bPn2 # 85974 - MP - Eugenia (ZONYA/LUIS origen) - yjqKxoO02rsdwdJZSPmD # 85950 - MP - Temixco (Frankenstein Miguel Angel) run_ids: - descuadre_opp_del_20260529_190910 # DELETE ZONYA Wo4MXw - descuadre_opp_del_20260529_191318 # DELETE LUIS Fv4dLJ - a37d23ffe6574e0eb2ee8433bce2e1f3 # PUT allowDuplicateOpportunity=true en Marca - descuadre_create_salvador_* # CREATE opp WON Salvador snapshots: - generated/migrations/descuadre_opp_20260529_snapshot.json # las 2 opps borradas + las que se conservan - generated/migrations/enable_dup_opp_GbKkBpCmKu2QmloKFHy3_20260529_193342.json # settings antes del PUT - generated/migrations/create_missing_branch_opps_20260529_193836.json # create Salvador status: resuelto (5 mislinks pendientes de re-enlace) memorias: - "[[positive_opp_descuadre_double_replica]]" - "[[opp_multiplicity_replication_gap]]" - "[[duplicate_resolution_rules]]" - "[[create_duplicate_phone_contact_marca]]" playbooks: - 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):** ```powershell 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): ```python # 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): ```python # 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: ```python # 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=)`. 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`): ```bash 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. ``` 3. **Crear la opp WON de Salvador** (multi-empeño, ya no da 400): ```bash python scripts/create_missing_branch_opps_in_marca.py --apply --yes \ --location jE41bVhhnb5T505BFm4F --only-opp OWGU1uPoWvITmwOLIyvq --run-id descuadre_create_salvador_ ``` ## VERIFICACIÓN - Conteo opps Marca: 1340 → (−ZONYA) 1339 → (−LUIS) 1338 → (+Salvador WON) **1339** = sucursales 1339. - Audit final: **`diff.opportunities == 0`**. ```python 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 ```python # --- 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 (`UNtCRNQ` → `5kDn6b`) - Patricia (`OGQtfmjF` → `zzBzWC`) - Lizeth (`j0iKZo` → `LGSPKo`) - **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 - 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](../PLAYBOOK_DESCUADRE.md) - Scripts: [scripts/enable_duplicate_opportunity.py](../../scripts/enable_duplicate_opportunity.py), [scripts/create_missing_branch_opps_in_marca.py](../../scripts/create_missing_branch_opps_in_marca.py) (flag `--all-branches`), [scripts/audit_brand_vs_branches_totals.py](../../scripts/audit_brand_vs_branches_totals.py), [scripts/check_allowDuplicate_settings.py](../../scripts/check_allowDuplicate_settings.py) - Artefactos: snapshots en `generated/migrations/` (ver frontmatter); audits en `generated/agent/runs/descuadre_audit_*.json`