Files
MP-Manager/docs/PLAYBOOK_DESCUADRE.md
2026-05-30 14:31:19 -06:00

285 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```
- **AC 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:12931380`):
| 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:1020: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`.