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

16 KiB
Raw Permalink Blame History

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:

  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, 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)
  • 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:

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 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_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. Auditintra_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 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 · 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.