154 lines
11 KiB
Markdown
154 lines
11 KiB
Markdown
---
|
||
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=<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`):
|
||
```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_<ts>
|
||
```
|
||
|
||
## 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`
|