# 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=&limit=15 # startedAt, status, mode GET /api/v1/executions/?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': '', '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`.