# Playbook — Investigación de descuadres Marca vs Sucursales

> Metodología de extremo a extremo para diagnosticar y resolver diferencias de conteo
> (contactos / oportunidades) entre la cuenta de Marca (Monte Providencia) y la suma de
> las sucursales, tal como las muestra la **Comparativa** del dashboard.
>
> Documenta la operación del **2026-05-29** (descuadre +5/+5 → 0/+2) como caso de estudio
> y destila el método para casos futuros. Cubre tres niveles: **programación**,
> **business intelligence** y **análisis de perspectivas** (el porqué de las decisiones).
>
> Léelo junto con [AGENTS.md](../AGENTS.md) y las memorias enlazadas al final.

---

## 0. TL;DR — el reflejo correcto

Ante un descuadre, **NO empieces a "arreglar" datos**. Empieza preguntando:

1. **¿El cache está fresco?** La Comparativa lee SQLite, no Bucéfalo en vivo. La causa #1 de un descuadre **positivo** (Marca > sucursales) es cache viejo.
2. **¿Qué dice el signo?** Negativo (sucursal > Marca) ⇒ faltantes / multi-empeño. Positivo (Marca > sucursal) ⇒ cache viejo, huérfanos creados directo en Marca, o fantasmas.
3. **¿Existe de verdad en GHL?** Verifica el contacto sospechoso **por id** (`GET /contacts/{id}`) Y por **índice** (`/contacts/search`). El delta entre ambos revela fantasmas.

Solo después de eso, y siempre con **dry-run → snapshot → apply → re-audit**, se toca algo.

---

## 1. De dónde sale el número

La Comparativa del dashboard se calcula con `run_audit()` en
[`scripts/audit_brand_vs_branches_totals.py`](../scripts/audit_brand_vs_branches_totals.py),
expuesta vía `GET /api/comparativa/marca-vs-sucursales` (`main.py:856`).

**Regla de oro:** `run_audit()` lee de `generated/data/mp_manager.sqlite` (el **cache**), **no**
de la API de GHL en vivo. Por lo tanto el número refleja el estado del **último sync por
location**, no el estado real de Bucéfalo en este instante.

Primer comando de diagnóstico, siempre:

```python
# ¿cuándo se sincronizó por última vez cada location sospechosa?
#   sync_log.finished_at  vs  date_added del contacto sospechoso
# Si el contacto se creó DESPUÉS del último sync de su location -> cache stale, no es huérfano.
```

El audit es **pesado** (fuzzy matching global). Córrelo en background y vuelca a archivo:

```powershell
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
```

(Vía MCP `run_script` con `expect_json=True` puede dar timeout; preferir background + archivo.)

---

## 2. Nivel Business Intelligence — descomponer el descuadre

### 2.1 Identidad de reconciliación

De la memoria `contact_descuadre_reconciliation`:

```
branch_sum − brand = (A − C multiplicidad) + (B missing-in-brand) − (D solo-Marca)
```

- **A−C multiplicidad**: un contacto de Marca que matchea con >1 contacto de sucursal (o
  viceversa) infla un lado sin inflar el otro.
- **B (missing-in-brand)**: contactos de sucursal que aún no se replican a Marca → bucket
  `contacts_in_branch_not_in_brand`.
- **D (solo-Marca)**: contactos que solo viven en Marca → bucket `contacts_in_brand_not_in_any_branch`.

### 2.2 Los 11 buckets de `run_audit()`

El JSON devuelto trae, bajo `missing`, estos buckets (nombres EXACTOS, `audit_…py:1293–1380`):

| Bucket | Qué detecta |
|---|---|
| `contacts_in_branch_not_in_brand` | Contacto en sucursal sin contraparte en Marca (ni por CF `id_contacto_sucursal` ni por phone/email/name). |
| `contacts_in_brand_not_in_assigned_branch` | Contacto de Marca con TIENDA asignada que no está en ESA sucursal. |
| `contacts_in_brand_present_in_other_branch_not_assigned` | Está en OTRA sucursal, no la asignada por el verificador. |
| `contacts_in_brand_probable_duplicate` | Marca tiene phone/email, no matchea en sucursal, pero hay homónimo con identificadores fuertes en la asignada ⇒ probable duplicado en Marca. |
| `contacts_in_brand_without_tienda` | Contacto de Marca sin el CF `TIENDA` poblado. |
| `contacts_in_brand_with_unknown_tienda` | TIENDA poblada pero no matchea ninguna fila del verificador. |
| `contacts_in_brand_not_in_any_branch` | No aparece en ninguna sucursal. **Fuente típica del descuadre positivo.** |
| `opportunities_in_branch_not_in_brand` | Opp de sucursal sin réplica en Marca (ni por CF `id_oportunidad_sucursal` ni vía contacto). |
| `opportunities_missing_id_field` | Opps con el CF "ID Oportunidad Sucursal" vacío / longitud inválida (calidad de dato, no necesariamente descuadre). |
| `contacts_missing_id_field` | Contactos con el CF "ID Contacto Sucursal" vacío / inválido. |
| `intra_brand_duplicates` | Duplicados DENTRO de Marca (mismo nombre normalizado, sin phone NI email). |

### 2.3 El signo del descuadre es un diagnóstico

- **Negativo** (sucursal > Marca): faltantes en Marca (bucket B) o **multi-empeño** no
  replicado (memoria `opp_multiplicity_replication_gap`, causa del histórico −28).
- **Positivo** (Marca > sucursal): casi siempre **cache viejo** o contactos creados directo
  en Marca (Facebook / formulario) que no cascaron. Rara vez son duplicados intra-Marca
  (verifica `intra_brand_duplicates` para descartarlo de inmediato).

### 2.4 Taxonomía de causas-raíz (observadas en el caso real)

| # | Causa | Cómo se ve | Acción |
|---|---|---|---|
| a | **Cache stale** | Contacto creado después del último sync de su location | Re-sync de la location + re-audit |
| b | **Fantasma de índice GHL** | `/contacts/search` lo devuelve, pero `GET/DELETE /contacts/{id}` da 404 | Esperar a que GHL reconcilie; NO borrar por id |
| c | **Colisión de identidad** | Dos personas comparten teléfono; un registro queda "Frankenstein" | Jerarquía de resolución + dry-run; a veces UPDATE, no DELETE |
| d | **Huérfano pre-fix** | Lead viejo (Facebook) que nunca cascó a sucursal | Bajar con la cascada ya arreglada, o aceptar |
| e | **Sucursal inexistente** | El contacto apunta a una sucursal que no es cuenta MP | Sin arreglo posible; documentar |

---

## 3. Nivel programación — el toolkit y los anclajes

### 3.1 Lectura / diagnóstico (read-only)

- MCP: `get_global_metrics`, `sync_logs`, `error_logs`, `list_accounts`, `get_account_metrics`.
- El audit completo: background + `Out-File utf8` (ver §1).
- `error_logs` es oro: cada error trae el `ghl_response_body` real. Ahí apareció el
  `{"message":"Contact not found for id:…"}` que delató el fantasma de índice.

### 3.2 Entender el matching (clave para leer los buckets)

En `audit_brand_vs_branches_totals.py`:

- `build_contact_index(contacts)` (`:430`) → `(by_phone, by_email, by_name)`.
- `find_match(contact, by_phone, by_email, by_name=None, …)` (`:458`): cascada
  **phone+name → email → nombre-solo**.
- `MATCH_THRESHOLD = 0.80` (`:53`): similitud mínima de nombre cuando el teléfono coincide.

> **Phone solo nunca es match** (memoria `matching_rules`). Si el teléfono coincide pero el
> nombre diverge < 0.80, es **colisión**, no match. Esta regla es exactamente la que hizo
> que el audit detectara — correctamente — el caso Rebeca/Maely como huérfano en vez de
> fusionarlos a ciegas.

### 3.3 Forense n8n (¿la automatización realmente corrió?)

`scripts/n8n_workflow_lib.py::load_credentials()` lee `n8n/n8n credencials.txt`
(secreto, no imprimir) y devuelve `(api_key, base_url)`. La API se consulta con header
`X-N8N-API-KEY`:

```python
GET /api/v1/workflows?limit=100                          # id, name, active
GET /api/v1/executions?workflowId=<id>&limit=15          # startedAt, status, mode
GET /api/v1/executions/<execId>?includeData=true         # runData -> camino de nodos
```

Con `includeData=true` se ve **qué nodos ejecutó** cada corrida: así se confirmó que la
cascada Marca→Sucursal **sí** creó el contacto en la sucursal (nodo
`Crear Contacto - Cuenta Objetivo - SUCURSAL` con status success). Workflows clave:

- `4UMRwxJdHFfOGHBp` — `[1604] Crear Contacto - MARCA A SUCURSAL V2` (dispara por **formulario**).
- `x4DqZ5FtSc43tdzB` — `[1604] Sincronización Sucursal → Marca - Crear Contacto`.
- `Cfgwp0bOtDW8zuKW` — sync de oportunidades.

El nodo `Obtener Info de cuenta origen - SUCURSAL` expone el `Location_ID` de origen → así se
ubicó que jorge/fernando venían de **Tampico**.

### 3.4 Verificación en vivo vs cache (cómo cazar fantasmas)

```python
import sync_engine as se, requests
tok = {a['location_id']: a['token'] for a in se.parse_accounts_csv()}   # NO usar main.TOKENS_CACHE
H = {'Authorization': f'Bearer {tok[LOC]}', 'Version': '2021-07-28', 'Accept': 'application/json'}

# (1) por id  -> store principal
requests.get(f'https://services.leadconnectorhq.com/contacts/{cid}', headers=H)
# (2) por índice -> lo que ve la sync
requests.post('https://services.leadconnectorhq.com/contacts/search',
              headers={**H, 'Content-Type': 'application/json'},
              json={'locationId': LOC, 'query': '<texto>', 'pageLimit': 20})
```

> **`main.TOKENS_CACHE` está vacío fuera del startup de FastAPI** (se puebla en el
> `@app.on_event("startup")`, `main.py:126/141`). Para scripts ad-hoc, cargar tokens con
> `sync_engine.parse_accounts_csv()`.
>
> **Diagnóstico del fantasma:** si (2) lo devuelve pero (1) da 400/404 "Contact not found",
> el contacto está borrado del store pero el índice de search va con retraso → cada sync lo
> revive. No se puede borrar (ya no existe por id); se auto-corrige al reconciliar GHL.

### 3.5 Mutación segura

- `ghl_client.update_contact(token, contact_id, data)` → `PUT /contacts/{id}` (`:173`).
- `ghl_client.delete_contact(token, contact_id, location_id)` → `DELETE` (`:176`).
- Tags: pasar el array completo deseado en el PUT (el `DELETE …/tags/{name}` por nombre da
  404 — memoria `ghl_tags_api`).
- Endpoint del dashboard `delete_comparativa_contact` (`main.py:2095`) respeta **dry-run**
  vía header `X-Dry-Run` (`is_dry_run_request` `:30`, `dry_run_response` `:44`).

> ⚠️ El endpoint del server usa el token cargado en startup. Si falla con 400 raro, prueba
> la mutación directa con el token del CSV (probado: el PUT a Rebeca funcionó con el token
> del CSV cuando el server había dado 400 por el fantasma).

---

## 4. Caso de estudio — la corrida del 2026-05-29

**Estado inicial:** Comparativa marca +5 contactos / +5 opps (Marca de más).

1. **Audit** → `intra_brand_duplicates: 0` (no son duplicados) y `contacts_in_brand_not_in_any_branch: 7`. Los 7 eran el origen del exceso.
2. **Triage de los 7:**
   - 4 creados **hoy** (adrian, juan carlos por formulario; jorge, fernando por sucursal).
   - 1 contacto `qa-test` (test21).
   - 2 leads viejos de Facebook (rebeca, guco).
3. **Forense n8n** confirmó que los 4 de hoy **sí** se crearon/replicaron correctamente en
   su sucursal (executions success). El problema: sus sucursales se habían sincronizado a las
   **13:58**, antes de las altas (20:10–20:50) → **cache stale**.
4. **Re-sync** de Villas del Sol, Tulyehualco y Tampico → los 4 matchearon. **Descuadre +5/+5 → 0/+2.**
5. **Caso Rebeca/Maely** (el `contacts_in_brand_not_in_any_branch` restante con seguimiento):
   - Un solo contacto en Marca con nombre "Rebeca Gaona" (lead Facebook nov-2025) pero con
     `ID Contacto Sucursal`, WhatsApp y **opp** de **Maely Linares** (Puebla, abr-2026).
   - Causa: dos personas comparten el mismo teléfono (+52 222 ··· ····); el autoenlace por teléfono
     pegó los datos de Maely sobre el contacto de Rebeca → registro "Frankenstein".
6. **Decisión de negocio (usuario):** conservar el registro reciente con seguimiento (Maely),
   descartar el viejo sin seguimiento (Rebeca).
7. **Pivote DELETE → UPDATE:** borrar el contacto de Marca habría borrado también la opp de
   Maely y la habría dejado huérfana (el registro era, de facto, la réplica de Maely). La
   acción correcta fue **sobrescribir la identidad** (nombre/email/tags) a Maely, conservando
   opp y enlace. `snapshot → dry-run (diff) → PUT → re-audit`. Resultado: el par cuadra.
8. **test21:** resultó ser un **fantasma de índice** — `/contacts/search` lo devolvía
   (`total:1`) pero `GET/DELETE` por id daban 404. La sync de las 17:10 lo revivió en cache.
   No accionable; se auto-corrige.

**Estado final:** contactos **0**, opps **+2** (opps auto-creadas en vuelo).

---

## 5. Análisis de perspectivas — por qué funcionó

- **Evidencia sobre inferencia.** Cada hipótesis se confirmó contra datos *antes* de actuar:
  executions de n8n (¿corrió?), `GET` por id vs search (¿existe?), `sync_log` (¿cache fresco?).
  Ninguna mutación se hizo "por intuición".
- **El cache como sospechoso #1.** Tratar el descuadre positivo como problema de cache —no de
  datos— evitó "arreglar" 4 contactos que ya estaban perfectos en su sucursal. El re-sync
  resolvió más que cualquier mutación.
- **El seguimiento del usuario como timón.** La regla de negocio ("quedarse con el registro
  con seguimiento") la puso el usuario; el rol técnico fue **traducirla a la acción correcta
  y advertir cuando lo que pidió (borrar) lograba lo contrario**. Esa advertencia evitó
  orfanar a Maely.
- **Disciplina dry-run / snapshot.** Convirtió un `PUT` irreversible (sobrescribir identidad)
  en algo reversible. El snapshot vive en `generated/migrations/`.
- **Defecto numérico ≠ defecto de integridad.** Rebeca cuadraba a 0 en el contador, pero era
  corrupción de identidad real. Se arregló por **integridad de datos**, no por mover el número.
- **Honestidad sobre límites.** test21 (fantasma) y guco (sucursal inexistente) no se forzaron;
  se documentaron como no-accionables. Mejor "esto no tiene arreglo y aquí está el porqué" que
  un parche cosmético que reaparece en la siguiente sync.

---

## 6. Checklist de investigación (pégalo y síguelo)

1. **Frescura de cache.** `sync_logs` por location vs `date_added` de sospechosos.
2. **Corre el audit** (background + JSON) y **lee los buckets por signo** (§2.3).
3. **Descarta duplicados** intra-Marca (`intra_brand_duplicates`) de entrada.
4. Para cada huérfano sospechoso: **verifica en vivo** por id vs search (§3.4).
   - Stale → **re-sync** de la location y **re-audit**.
   - Fantasma de índice → **esperar / monitorear** (no borrar por id).
   - Creado en Marca → **forense n8n** (¿cascó? ¿a dónde?).
5. **Colisión / duplicado real** → jerarquía de resolución (memoria `duplicate_resolution_rules`)
   + **snapshot → dry-run → apply**. Pregúntate si la acción correcta es UPDATE, no DELETE.
6. **Re-audit final.** Confirma que el descuadre bajó y que no introdujiste orfandad.
7. **Reporta en dos registros:** técnico (este nivel) y **no técnico** para el negocio.

---

## 7. Síntomas → causa probable

| Síntoma | Causa probable |
|---|---|
| El conteo **subió** tras un re-sync | Altas reales nuevas en GHL (no un error). |
| Un contacto **reaparece** tras borrarlo del cache | Fantasma de índice GHL (search lo revive). |
| `GET /contacts/{id}` 404 pero search lo lista | Borrado del store, índice rezagado (fantasma). |
| Huérfano con tag `sucursal` y sin Sucursal/TIENDA poblada | Vino por Sucursal→Marca con formulario sin ruteo. |
| Contacto y su opp tienen **nombres distintos** | Colisión por identificador compartido (teléfono/email). |
| Descuadre positivo con `intra_brand_duplicates: 0` | Cache viejo o huérfanos creados directo en Marca. |
| Mutación falla 400 vía el server pero el dato existe | Token del server distinto; reintenta con token del CSV. |

---

## Referencias

- [AGENTS.md](../AGENTS.md) · [CLAUDE.md](../CLAUDE.md) · [docs/GUIA_AGENTICA.md](GUIA_AGENTICA.md)
- Código: `scripts/audit_brand_vs_branches_totals.py`, `ghl_client.py`, `sync_engine.py`,
  `main.py`, `scripts/n8n_workflow_lib.py`.
- Memorias: `positive_descuadre_stale_cache`, `contact_descuadre_reconciliation`,
  `matching_rules`, `duplicate_resolution_rules`, `create_duplicate_phone_contact_marca`,
  `opp_multiplicity_replication_gap`, `ghl_tags_api`.
