---
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`
