16 KiB
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 y las memorias enlazadas al final.
0. TL;DR — el reflejo correcto
Ante un descuadre, NO empieces a "arreglar" datos. Empieza preguntando:
- ¿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.
- ¿Qué dice el signo? Negativo (sucursal > Marca) ⇒ faltantes / multi-empeño. Positivo (Marca > sucursal) ⇒ cache viejo, huérfanos creados directo en Marca, o fantasmas.
- ¿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,
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:
# ¿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:
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_duplicatespara 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_logses oro: cada error trae elghl_response_bodyreal. 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:
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)
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_CACHEestá 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 consync_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 — memoriaghl_tags_api). - Endpoint del dashboard
delete_comparativa_contact(main.py:2095) respeta dry-run vía headerX-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).
- Audit →
intra_brand_duplicates: 0(no son duplicados) ycontacts_in_brand_not_in_any_branch: 7. Los 7 eran el origen del exceso. - 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).
- 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.
- Re-sync de Villas del Sol, Tulyehualco y Tampico → los 4 matchearon. Descuadre +5/+5 → 0/+2.
- Caso Rebeca/Maely (el
contacts_in_brand_not_in_any_branchrestante 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".
- Un solo contacto en Marca con nombre "Rebeca Gaona" (lead Facebook nov-2025) pero con
- Decisión de negocio (usuario): conservar el registro reciente con seguimiento (Maely), descartar el viejo sin seguimiento (Rebeca).
- 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. - test21: resultó ser un fantasma de índice —
/contacts/searchlo devolvía (total:1) peroGET/DELETEpor 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ó?),
GETpor 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
PUTirreversible (sobrescribir identidad) en algo reversible. El snapshot vive engenerated/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)
- Frescura de cache.
sync_logspor location vsdate_addedde sospechosos. - Corre el audit (background + JSON) y lee los buckets por signo (§2.3).
- Descarta duplicados intra-Marca (
intra_brand_duplicates) de entrada. - 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?).
- 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.
- Re-audit final. Confirma que el descuadre bajó y que no introdujiste orfandad.
- 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 · CLAUDE.md · docs/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.