285 lines
16 KiB
Markdown
285 lines
16 KiB
Markdown
# 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`.
|