Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
# 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`.