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
+258
View File
@@ -0,0 +1,258 @@
# AGENT_TOOLS — Capa agentica MCP para MP Manager
Este documento describe la capa MCP que permite a Claude Code (y otros clientes
MCP) operar el ecosistema MP Manager como herramienta. Es el **punto de entrada
canónico** para entender qué se expone como tool, cómo invocarla, qué garantías
de seguridad existen y dónde leer cuando algo falla.
> **Para humanos (Uriel)**: lee [GUIA_AGENTICA.md](GUIA_AGENTICA.md) — recetas, prompts copy-paste y cómo trabajar con Claude Code como manager operativo.
> **Para humanos (arquitectura)**: reglas de negocio en `CLAUDE.md` y `AGENTS.md`. Este archivo cubre solo la capa LLM.
> **Para LLM**: cuando arranques una sesión, lee `generated/agent/tools_manifest.json`
> primero — es el inventario actualizado y autogenerado.
---
## 1. Arquitectura
```
Claude Code ── stdio ──► python -m mcp_server
├── adapters.py ──► funciones Python directas (db, sync_*.run_sync, ghl_client)
└── run_script ──► subprocess scripts/*.py --json
generated/agent/runs/*.json (offload de payloads grandes)
```
- **Transport**: stdio. El SDK MCP oficial de Anthropic (`mcp[cli]>=1.0`).
- **Sin HTTP**: la mayoría de tools llaman funciones Python directas. La tool
genérica `run_script` usa subprocess para scripts que no exportan función.
- **Reutiliza el código existente**: `db.py`, `sync_missing_*.run_sync()`,
`script_audit`, `ghl_client`, `paths.*`. El MCP es una capa thin.
---
## 2. Arranque
`.mcp.json` en la raíz del repo:
```json
{
"mcpServers": {
"mp-manager": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "."
}
}
}
```
Claude Code detecta el archivo automáticamente y arranca el server al abrir
sesión en el directorio. Manualmente:
```bash
python -m mcp_server # arranca por stdio
python -m mcp_server.manifest # regenera tools_manifest.json
python scripts/audit_agent_readiness.py # refresca audit_report.json (insumo del manifest)
```
---
## 3. Seguridad — defaults y confirm_token
**Toda tool mutadora arranca con `apply=False`** (dry-run). Para aplicar
cambios reales el LLM debe pasar explícitamente:
```python
apply=True
confirm_token="I-HAVE-USER-CONFIRMATION"
```
Si `apply=True` sin el token correcto, la tool devuelve error sin tocar nada.
El token es literal — no se deriva ni se genera. Su propósito es que el LLM
sea forzado a **pedir confirmación al usuario** antes de incluirlo en la
llamada. Cualquier `apply=True` queda registrado en `script_audit` con un
`run_id` y es reversible vía el dashboard del SPA.
Workflow estándar (protocolo dry-run obligatorio del proyecto):
1. LLM llama tool con `apply=False` → obtiene plan/preview con números reales.
2. LLM resume al usuario los cambios.
3. Usuario confirma explícitamente.
4. **Piloto**: LLM llama con `apply=True` + `confirm_token` aplicando SOLO a una sucursal (filtrar por `location_id` o usar `--location` / args equivalentes).
5. LLM valida el resultado del piloto contra la API/DB y reporta al usuario.
6. Usuario confirma el batch al resto.
7. LLM aplica al lote completo. Cada apply genera `run_id` registrado en `script_audit` (rollback desde el dashboard).
**Nunca saltar el piloto** aunque la lógica parezca trivial. El blast radius en Bucéfalo es alto (50 cuentas, replicación bidireccional, reglas de negocio interdependientes). Si el script no soporta filtrado por sucursal, decirle al usuario y pedir guía antes de aplicar.
---
## 4. Catálogo de tools
| Tool | Categoría | Mutadora | Propósito |
|---|---|---|---|
| `list_accounts` | accounts | no | Lista cuentas (Marca + 49 sucursales). |
| `get_account` | accounts | no | Detalle de una cuenta por `location_id`. |
| `get_global_metrics` | metrics | no | Totales globales (contactos, opps, etc.). |
| `get_account_metrics` | metrics | no | Métricas de una sucursal. |
| `search_contacts` | contacts | no | Busca por nombre/email/teléfono en cache SQLite. |
| `get_contact` | contacts | no | Detalle de contacto por id. |
| `get_opportunities` | opps | no | Opps de una location (opcional `pipeline_id`). |
| `get_pipelines` | opps | no | Pipelines/etapas de una location. |
| `get_workflows` | workflows | no | Workflows de una location o de todas. |
| `sync_missing_contacts` | sync | **sí** | Sucursal→Marca contactos faltantes. |
| `sync_missing_opps` | sync | **sí** | Sucursal→Marca opps faltantes. |
| `sync_logs` | ops | no | Logs recientes de sync. |
| `error_logs` | ops | no | Errores recientes. |
| `agent_audit_report` | ops | no | Reporte de salud agentica. |
| `script_catalog` | ops | no | Inventario completo de scripts y tools. |
| `run_script` | advanced | **sí** | Ejecuta cualquier script de `scripts/` (subprocess). |
### 4.1 Cómo invocar `run_script`
Es la salida para los ~60 scripts no expuestos como tool dedicada. Pasa el
nombre (con o sin `.py`), los args como lista de strings, y opcionalmente
`expect_json=True` si el script soporta `--json` (vuelca a archivo si es grande).
Si el script muta GHL debes pasar `apply=True` + `confirm_token`, además de
los flags propios del script (típicamente `--apply --run-id <uuid>`).
Ejemplos:
```jsonc
// Auditoría read-only con JSON
{ "name": "audit_brand_vs_branches_totals", "args": ["--json"], "expect_json": true }
// Mutador: requiere confirm_token MCP + flags del script
{
"name": "cleanup_cross_branch_duplicates",
"args": ["--apply", "--yes", "--run-id", "<uuid>"],
"apply": true,
"confirm_token": "I-HAVE-USER-CONFIRMATION"
}
```
---
## 5. Manejo de payloads grandes
Cualquier tool puede devolver una de estas dos formas:
```jsonc
// Payload chico: inline
{ "ok": true, "summary": {...}, "details": [...] }
// Payload grande (>8KB serializado): offload a disco
{ "ok": true, "summary": {...}, "report_path": "generated/agent/runs/<tool>_<ts>.json" }
```
Cuando recibas `report_path`, lee el archivo solo si necesitas detalles
específicos. El `summary` está pensado para que decidas si vale la pena.
---
## 6. Recetas frecuentes
### 6.1 Investigar discrepancia de totales Marca vs sucursales
1. `get_global_metrics` para ver el descuadre.
2. `run_script("audit_brand_vs_branches_totals", ["--json", "--show-missing"], expect_json=true)`.
3. Si el reporte sugiere contactos faltantes, `sync_missing_contacts(apply=false)` para preview.
4. Pedir confirmación al usuario → `sync_missing_contacts(apply=true, confirm_token=...)`.
### 6.2 Buscar un contacto y revisar sus oportunidades
1. `list_accounts` para tener la lista de location_ids.
2. `search_contacts(location_id="GbKkBpCmKu2QmloKFHy3", query="<nombre/teléfono>")`.
3. `get_contact(location_id, contact_id)` para detalle.
4. `get_opportunities(location_id)` filtrando por contacto en cliente.
### 6.3 Revisar duplicados cross-branch antes de cleanup
1. `run_script("find_cross_branch_duplicates", ["--json"], expect_json=true)`.
2. Revisar la jerarquía de resolución (ver CLAUDE.md sección "Duplicate resolution rules" en memory).
3. `run_script("cleanup_cross_branch_duplicates", ["--apply", "--yes", "--run-id", "<uuid>"], apply=true, confirm_token="I-HAVE-USER-CONFIRMATION")`.
---
## 7. Reglas críticas heredadas (resumen)
Detalle completo en `CLAUDE.md`, `AGENTS.md` y entries de memory. Lo
indispensable para no romper nada:
- **Cuenta Marca**: `GbKkBpCmKu2QmloKFHy3` (Monte Providencia). Hardcoded en
`sync_engine.py` y `scripts/common.py` como `BRAND_LOCATION_ID`.
- **Sucursales**: 49 locations adicionales del CSV de tokens.
- **Dirección del sync**: contactos bidireccional Marca↔Sucursal, opps
unidireccional Sucursal→Marca (la sucursal manda).
- **Custom fields dinámicos**: nunca hardcodees IDs; usa `common.SchemaResolver`.
- **Marca de producto**: nuestro CRM se llama **Bucéfalo**. No mencionar "Go
High Level" en interfaces de usuario.
- **Servicios E3**: solo digital. No marketing tradicional, no diseño para imprenta.
- **Multi-opp gap**: la replicación Sucursal→Marca (via n8n) solo replica la
primera opp por contacto. Las opps adicionales (multi-empeño) no llegan a
Marca automáticamente — para eso existe `sync_missing_opps`.
- **n8n realtime**: la replicación Marca→Sucursal en tiempo real la hace el
workflow n8n `[1604]`, NO el sync batch.
- **sincorreo@gmail.com**: placeholder de contacto sin correo, causa falsos
matches por email en audits.
---
## 8. Estado del ecosistema (auto-actualizado)
El comando `python scripts/audit_agent_readiness.py` genera
`generated/agent/audit_report.json` con:
- Inventario completo de scripts (77) y su compliance con las convenciones.
- Endpoints FastAPI con clasificación tool-safe.
- Decisión sugerida para huérfanos (no registrados en `SCRIPTS_METADATA`).
- Issues detectados (mutadores sin `--apply`, sin `--run-id`, sin `--json`,
docstrings sin header).
**Snapshot inicial** (2026-05-27): 77 scripts, 49 registrados, 26 huérfanos,
59 endpoints (57 tool-safe), 157 issues totales. La normalización masiva de
issues está fuera de alcance de esta capa MCP — el reporte sirve como gate
para futuras contribuciones.
---
## 9. Convención para scripts nuevos
Para que un script nuevo sea tool-safe automáticamente:
```python
"""<oneliner del script>.
Category: audit | sync | cleanup | fix | migrate | search | browser
Mutates: yes | no
Tool-safe: yes | no
"""
# Args estándar (mutadores):
# --dry-run / --apply
# --run-id <uuid>
# --json
# Exit codes:
# 0 éxito
# 1 error
# 2 dry-run con hallazgos accionables
```
Si es read-only soportar `--json` lo hace consumible por el LLM con
`run_script(..., expect_json=True)`. Si es mutador, además registrar cambios
en `script_audit` para que el rollback del dashboard funcione.
---
## 10. Troubleshooting
| Síntoma | Diagnóstico |
|---|---|
| El server no aparece en Claude Code | Verifica `.mcp.json` en la raíz y que `python -m mcp_server` corra manualmente sin error. |
| `mcp` no instalado | `python -m pip install -r requirements.txt`. |
| Tool devuelve `audit_report.json no existe` | Corre `python scripts/audit_agent_readiness.py`. |
| `apply=True` rechazado | Te falta `confirm_token="I-HAVE-USER-CONFIRMATION"`. Es a propósito. |
| Sync timeout | Subir `timeout_sec` en `run_script`, o lanzar el script directo y monitorear `script_runs`. |
| Resultados inconsistentes con dashboard | El cache SQLite puede estar stale; correr `sync_all_accounts` (via endpoint o script) antes de auditar. |
+202
View File
@@ -0,0 +1,202 @@
# Guía agentica MP Manager
**Para Uriel.** Cómo usar Claude Code como manager operativo experto del ecosistema MP Manager. Si quieres el detalle técnico de las tools, lee [AGENT_TOOLS.md](AGENT_TOOLS.md); esta guía se enfoca en *cómo pedirle las cosas* y *qué esperar de vuelta*.
---
## 1. ¿Para qué sirve esto?
Claude Code es tu manager operativo de MP Manager: le pides auditorías, syncs, búsquedas, fixes o investigaciones, él decide qué tools llamar, te muestra preview, espera tu confirmación y aplica. Reglas de negocio de Monte Providencia (jerarquía de duplicados, Marca↔sucursal, multi-empeño, etc.) ya están en su contexto vía `CLAUDE.md` + memory.
---
## 2. Arrancar una sesión productiva
1. Abre Claude Code en `h:\MegaSync\Proyectos\MP Manager`.
2. Verifica que el MCP está conectado:
> **Prompt**: "Lista las tools de mp-manager que tienes disponibles."
Deberías ver 16 tools: `list_accounts`, `search_contacts`, `sync_missing_contacts`, etc. Si no aparecen, ve a [§8 Troubleshooting](#8-troubleshooting-rápido).
3. (Opcional) Pídele un snapshot inicial para calibrar el estado actual antes de operar — receta 1 abajo.
---
## 3. Contrato de seguridad (cómo funciona desde tu lado)
Todo lo que muta datos en Bucéfalo sigue **siempre** este protocolo de 5 pasos. No hay excepciones — aunque parezca trivial, aunque ya lo hayas autorizado antes para algo similar.
1. **Dry-run primero**. Claude llama la tool con `apply=False` (o el script con `--dry-run`) y te muestra el plan con números reales: cuántas entidades, qué cambia, valor estimado.
2. **Confirmación explícita tuya**. Claude te pregunta antes de mutar. Tú respondes en lenguaje natural ("sí, aplica", "adelante"). Sin tu input no toca producción.
3. **Piloto en 1 sucursal**. La primera aplicación va a UNA sucursal (la que indiques, o Claude propone una de bajo volumen / demo). Se valida el resultado contra la API antes de continuar.
4. **Batch al resto solo tras OK del piloto**. Si el piloto se ve bien, se aplica al lote completo — también con confirmación explícita.
5. **Rollback disponible**. Cada apply genera `run_id` que queda en `script_audit` (reversible desde el dashboard del SPA). Para cambios destructivos también se guarda snapshot en `generated/migrations/`.
**Banderas rojas — pídele preview de nuevo o cancela si pasa esto:**
- Te ofrece aplicar sin haberte mostrado preview primero.
- Quiere ir directo al batch sin piloto (especialmente para fixes nuevos o lógica no probada).
- Aplica y reporta un cambio que no esperabas.
- Te dice "ya lo apliqué" sin run_id.
- No te ofrece rollback cuando algo salió mal.
---
## 4. Recetas de uso frecuente
Cada receta trae el prompt literal, qué tools dispara, y qué esperar.
### 4.1 Snapshot diario del estado
> **Prompt**: "Dame un snapshot del estado actual: totales globales, descuadre Marca vs sucursales si hay, y los últimos errores de los logs."
**Por qué funciona**: arranca `get_global_metrics` + `agent_audit_report` (o `run_script audit_brand_vs_branches_totals --json`) + `error_logs`. Es read-only puro, sin riesgo.
**Qué esperar**: 3 bloques — números globales, lista corta de diferencias por sucursal, top errores con `error_id`.
---
### 4.2 Investigar descuadre de totales
> **Prompt**: "Hay descuadre en el conteo de contactos entre Marca y sucursales. Investiga la causa siguiendo la identidad estándar de descomposición (multiplicidad A-C, missing-in-brand B, solo-Marca D) y prepárame un plan dry-run para corregir lo accionable."
**Por qué funciona**: la frase "identidad estándar" activa la regla `contact_descuadre_reconciliation` de memory. Claude correrá `audit_brand_vs_branches_discrepancy --show-unsynced-contacts --json` y luego `sync_missing_contacts(apply=False)` para preview.
**Qué esperar**: descomposición numérica del descuadre + lista de contactos accionables + propuesta de qué sincronizar.
---
### 4.3 Buscar un contacto a través de sucursales
> **Prompt**: "Busca a `<nombre>` con teléfono `<10 dígitos>` en todas las sucursales y dime en cuáles aparece y si tiene oportunidades."
**Por qué funciona**: `list_accounts` para iterar + `search_contacts` por location. La regla `matching_rules` (phone+name ≥0.80) ya está en memory, así que evita falsos matches por solo teléfono.
**Qué esperar**: tabla con sucursal, contact_id, nombre coincidente, count de opps. Si quieres detalle, pídele "ahora dame las opps del contacto X en la sucursal Y".
---
### 4.4 Detectar y resolver duplicados cross-branch
> **Prompt**: "Corre el detector de duplicados cross-branch y propónme el cleanup usando la jerarquía estándar (valor monetario → status activo → contacto más antiguo → TIENDA). Quiero ver el plan antes de aplicar."
**Por qué funciona**: dispara `run_script find_cross_branch_duplicates --json` y luego `cleanup_cross_branch_duplicates --apply=False`. La jerarquía está en memory (`duplicate_resolution_rules`).
**Qué esperar**: lista de pares duplicados con la decisión propuesta por cada par. Cuando confirmes, ejecuta con `--apply --yes --run-id <uuid>` y te da el `run_id`.
---
### 4.5 Sincronizar contactos faltantes Sucursal → Marca
> **Prompt**: "Sincroniza a Marca los contactos de sucursal que aún no estén replicados. Hazme dry-run primero con muestra de 5 para revisar el payload."
**Por qué funciona**: `sync_missing_contacts(apply=False)`. El parámetro "muestra de 5" hace que Claude limite el preview en el resumen para que sea legible.
**Qué esperar**: muestra de 5 payloads + total proyectado. Tú confirmas → ejecuta con apply=True + confirm_token → te devuelve el run_id.
---
### 4.6 Sincronizar oportunidades faltantes (gap multi-empeño)
> **Prompt**: "Replica las opps adicionales por contacto que el workflow n8n [1604] no copió a Marca (gap multi-empeño). Dry-run primero."
**Por qué funciona**: el "gap multi-empeño" es una regla conocida (memory: `opp_multiplicity_replication_gap`). Claude llama `sync_missing_opps(apply=False)` que usa el campo `ID Oportunidad Sucursal` para filtrar.
**Qué esperar**: número de opps adicionales detectadas por contacto + plan de inserción.
---
### 4.7 Auditar custom fields y tags vs estándar de Marca
> **Prompt**: "Audita el estado de custom fields y tags en todas las sucursales contra el estándar de Marca. Reporta divergencias y propón fix si las hay."
**Por qué funciona**: combina `run_script audit_custom_fields_schema --all --json` + `run_script audit_tags_across_accounts --json`. El estándar (folders Contact/General Info/Additional Info/Block/Form, tags `formulario`/`revisar`/`qa-test`) viene de memory.
**Qué esperar**: tabla de divergencias por sucursal + propuestas (`fix_opportunity_picklist_alignment`, `cleanup_and_unify_tags`, `create_missing_brand_fields`, `apply_custom_field_layout`).
---
### 4.8 Triage de errores recientes
> **Prompt**: "Revisa los errores de las últimas 24 horas y dime qué patrones se repiten. Diagnóstica los top 3."
**Por qué funciona**: `error_logs(limit=200)` + agrupa por `event` / `exception_type`. El playbook de triage Playwright (memory `playwright_log_triage`) se activa si los errores son del browser.
**Qué esperar**: agrupación por tipo de error + lista de `error_id` para profundizar + diagnóstico breve.
---
### 4.9 Rollback de un run que rompió cosas
> **Prompt**: "El run `<run_id>` causó problemas. Revisa qué cambió y prepárame el rollback. No apliques hasta confirmar."
**Por qué funciona**: Claude lee `script_audit.script_change_log` por `run_id`, te muestra qué cambió (planned/applied), y propone revertir vía dashboard del SPA o script de rollback equivalente.
**Qué esperar**: lista de cambios del run + instrucciones para revertir + advertencia si algún cambio dependiente lo bloquea.
---
### 4.10 Tarea ad-hoc con script huérfano
> **Prompt**: "Necesito correr `<nombre_script>.py` con args `<...>`. Antes inspecciónalo, dime qué hace exactamente, si es mutador y qué efectos tiene. Luego dry-run y, si todo se ve bien, aplico yo."
**Por qué funciona**: `run_script(expect_json=True)` para los read-only; para mutadores Claude te explicará qué pide el contrato (`--apply --run-id`) antes de tocar nada.
**Qué esperar**: resumen del docstring + categorización + propuesta de invocación + preview.
---
## 5. Cómo formular prompts que funcionen bien
- **Sé específico con la sucursal**: usa el nombre comercial o el `location_id`. "En Cancún" funciona; "en una sucursal" no.
- **Empieza con preview**: para cualquier cosa que muta, di "dry-run primero" o "muéstrame el plan antes de aplicar".
- **Pide volcado a archivo si esperas reportes largos**: el MCP lo hace automático >8KB, pero decir "guárdame el reporte completo y devuélveme solo el summary" lo fuerza.
- **Apóyate en reglas conocidas**: frases como "usa la jerarquía estándar", "aplica el patrón Monte Providencia", "respeta la regla de matching phone+name" activan memory entries específicas y evitan que Claude reinvente.
- **Acota el alcance al confirmar**: en vez de "sí aplica todo", di "aplica solo los primeros 10" o "aplica solo Cancún". Reduce blast radius.
---
## 6. Qué NO pedirle (límites realistas)
- **Playwright sin sesión configurada**: tocar `ghl_browser_*.py` cuando el `storage_state` o el perfil Chrome no están al día va a fallar. Primero `python start_persistent_profile.bat` o validar `generated/browser/session.json`.
- **Mutaciones masivas sin audit reciente**: pedirle "limpia todos los duplicados" sin haber corrido el detector primero es receta para borrar lo que no querías.
- **Tocar workflows en producción a ciegas**: antes de modificar workflows usa `run_script health_check_workflows`.
- **Cosas fuera del alcance E3**: marketing tradicional, diseño para imprenta, asesoría legal/financiera. Claude te redirigirá al área que corresponda.
- **Imprimir o exfiltrar tokens del CSV**: aunque se lo pidas, no debe; los tokens son secretos.
---
## 7. Evolucionar el ecosistema (loop de mejora continua)
Conforme uses el MCP vas a notar fricción. Pídele a Claude que la resuelva:
- **"Este flujo lo repito mucho — conviértelo en tool dedicada del MCP"** → edita `mcp_server/server.py` y `manifest.py`.
- **"Script X no aparece en el inventario"** → refresca con `python scripts/audit_agent_readiness.py`, y si conviene, regístralo en `SCRIPTS_METADATA` del `script_runner.py` para que también salga en el SPA.
- **"Cuando agregues un script nuevo, asegúrate que cumple la convención"** → docstring header (`Category:`, `Mutates:`, `Tool-safe:`), soporta `--json`, y si muta también `--apply` + `--run-id`. El audit lo verifica.
- **"Hay un patrón repetido en N sucursales que aún no automaticé"** → pídele que diseñe un nuevo script + tool en una sola pasada.
---
## 8. Troubleshooting rápido
| Síntoma | Qué pedirle a Claude |
|---|---|
| No veo las tools de mp-manager | "Verifica `.mcp.json` en la raíz y arranca `python -m mcp_server` manualmente para ver el error de stdio." |
| Claude aplicó algo sin pedir confirmación | "Reporta el `run_id` y haz rollback inmediato. Después explica cómo te saltaste el preview." |
| El reporte es demasiado largo | "Ya debe estar volcado en `generated/agent/runs/`. Dame solo el summary ejecutivo del `report_path`." |
| Los totales no cuadran con el dashboard | "El cache SQLite puede estar stale. Corre `sync_all_accounts` (endpoint o script) y repite el audit." |
| El MCP devuelve `audit_report.json no existe` | "Corre `python scripts/audit_agent_readiness.py` antes de invocar tools que dependen del reporte." |
| Un script huérfano que necesito ya | "Inspeccióna `scripts/<nombre>.py`, dime qué hace, y úsalo con `run_script` siguiendo el contrato." |
| Sospecho que la API de Bucéfalo está rate-limited | "Revisa `error_logs` por códigos 429 y dime si conviene bajar `SYNC_ENGINE_MAX_WORKERS` o reintentar más tarde." |
Si el problema persiste, [AGENT_TOOLS.md §10](AGENT_TOOLS.md#10-troubleshooting) tiene el detalle técnico.
---
## 9. Referencias cruzadas
- [docs/AGENT_TOOLS.md](AGENT_TOOLS.md) — catálogo técnico de las 16 tools del MCP, contrato `confirm_token`, formatos de respuesta.
- [CLAUDE.md](../CLAUDE.md) — arquitectura del backend MP Manager (módulos, flujo de datos, reglas críticas).
- [AGENTS.md](../AGENTS.md) — comandos, gotchas de Bucéfalo, paginación, reglas Monte Providencia.
- [docs/PLAYWRIGHT_SESSION.md](PLAYWRIGHT_SESSION.md) / [PLAYWRIGHT_PATTERNS.md](PLAYWRIGHT_PATTERNS.md) — automatización browser.
- `generated/agent/tools_manifest.json` — inventario navegable autogenerado (tools + scripts + endpoints).
- `generated/agent/audit_report.json` — salud actual del ecosistema (correr `python scripts/audit_agent_readiness.py` para refrescar).
+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`.
+339
View File
@@ -0,0 +1,339 @@
# Playbook — Enlace y regularización de oportunidades Sucursal ↔ Marca
> Metodología para completar el campo de enlace **"ID Oportunidad Sucursal"**
> (`opportunity.id_oportunidad_sucursal`) en oportunidades de Marca (Monte Providencia) y
> para regularizar opps que existen de un solo lado del puente Sucursal↔Marca.
>
> Documenta la operación del **2026-05-29** (10 opps de Marca sin enlace → 9 backfilled +
> 1 regularizada de extremo a extremo) 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 y el loop con el usuario).
>
> Es el complemento "a nivel oportunidad" de [PLAYBOOK_DESCUADRE.md](PLAYBOOK_DESCUADRE.md)
> (que cubre el descuadre de conteo y los fantasmas de contacto). Léelo junto con
> [AGENTS.md](../AGENTS.md) y las memorias enlazadas al final.
---
## 0. TL;DR — el reflejo correcto
Cuando una opp de Marca tiene **"ID Oportunidad Sucursal" vacío**:
1. **No inventes el valor ni borres la opp de entrada.** El campo guarda el **id nativo de la
opp de la sucursal de origen**; es la **clave de idempotencia** que usa n8n para decidir
UPDATE vs CREATE. Llenarlo mal duplica; borrar a ciegas orfana.
2. **El puente es el contacto.** La opp de Marca casi nunca trae el nombre de la sucursal,
pero **su contacto sí trae `id_contacto_sucursal`** poblado. Ese campo te lleva al contacto
de la sucursal, y de ahí a su opp → cuyo id es el valor que buscas.
3. **Verifica por dos métodos independientes** antes de escribir, y **contra la API en vivo**
(no solo el cache), leyendo **la clave correcta** (`fieldValue`, ver §3.4).
4. Si **no existe** la opp espejo en la sucursal, el problema no es de enlace: es una opp que
vive de un solo lado. **Deja que la automatización (n8n) la cree** antes de hacerlo a mano
(§4.2).
Y siempre: **dry-run → confirmación del usuario → piloto de 1 → batch**, con `run_id` y
snapshot para rollback (memoria `feedback_dry_run_protocol`).
---
## 1. El campo de enlace y por qué importa
| Campo | fieldKey | Dónde | Qué guarda |
|---|---|---|---|
| **ID Oportunidad Sucursal** | `opportunity.id_oportunidad_sucursal` | En **Marca**: el id de la opp de la sucursal de origen. En **sucursal**: su **propio** id (self-link). | El enlace 1-a-1 opp Marca ↔ opp sucursal. |
| **ID Contacto Sucursal** | `contact.id_contacto_sucursal` | En **Marca**: el id del contacto de la sucursal. En **sucursal**: su propio id. | El puente a nivel contacto (memorias `opp_self_id_in_branch_field`, `contact_self_id_in_branch_field`). |
**Los IDs de custom field varían por sucursal — nunca los hardcodees.** En esta corrida, en
Marca, resultaron ser `j029pu3OU02ATNccJR6l` (opp link) y `E6lI9ykWhqpj7Pmi7Qd3` (contact
bridge), pero eso es **incidental a Marca**. Resuélvelos por nombre (§3.1).
**Por qué importa el enlace:** el workflow n8n de sync de oportunidades
(`Cfgwp0bOtDW8zuKW`, memoria `n8n_opp_sync_match`) hace match **100% por este campo** (con
fallback por nombre) para decidir si hace UPDATE o CREATE en Marca. Una opp de Marca sin el
campo es una opp "huérfana de enlace": el próximo webhook puede duplicarla en vez de
actualizarla.
---
## 2. Nivel Business Intelligence
### 2.1 El modelo del puente (contacto → opp)
```
opp Marca ──contact_id──► contacto Marca ──[id_contacto_sucursal]──► contacto Sucursal
(sus opps)
opp Sucursal ── su id == valor a escribir
en "ID Oportunidad Sucursal" de la opp Marca
```
La opp de Marca rara vez sabe de qué sucursal viene (el CF de ciudad es la localidad del
lead, no la cuenta). Pero el **contacto** sí: `id_contacto_sucursal` es el ancla. De ahí, las
opps de ese contacto en su sucursal dan el id que falta.
### 2.2 Dos sub-casos, dos acciones distintas
Tras resolver el contacto sucursal, agrupa por cardinalidad **M** (opps Marca del contacto) vs
**N** (opps sucursal del contacto):
| Caso | Qué significa | Acción |
|---|---|---|
| **M=1, N=1** (`match_unique`) | Enlace 1-a-1 limpio | **Backfill** del CF (§3.3) |
| **M=N>1** | Multi-empeño; empareja por (nombre,monto)→nombre | Backfill si el emparejamiento es inequívoco; si no, `review_ambiguous` |
| **M≠N** | Cardinalidades no calzan | `review_count_mismatch` — no tocar, revisar a mano |
| **N=0** (`branch_contact_no_opps`) | El contacto existe en sucursal pero **sin opp** | **No es problema de enlace** → regularizar (§4.2) |
> El caso `branch_contact_no_opps` es la trampa: parece "falta el enlace" pero en realidad
> **falta la opp de origen**. Llenar el campo es imposible (no hay id que apuntar). Ver §4.2.
### 2.3 ¿La opp de Marca es real o un artefacto?
Heurística (memoria `automation_artifact_opportunities`):
- **Artefacto de automatización**: `status=open`, `$0`, y `createdAt ≈ updatedAt ≈
lastStatusChangeAt` (creada segundos después del contacto, nunca tocada). La crea la
automatización al replicar un contacto, aunque la sucursal no tenga opp.
- **Opp real**: tiene movimiento, valor monetario, o status `won`/`lost`.
Esto decide la regularización: un artefacto sin espejo en sucursal **se borra**; una opp real
se conserva y se le crea/empareja el espejo.
### 2.4 Origen del lead: `attributionSource`
El `source` del contacto suele venir `null`. La verdad del origen está en
`attributionSource`:
- `{"medium": "manual", "sessionSource": "CRM UI"}` ⇒ **dado de alta a mano por un agente** en
la sucursal (no formulario, no Facebook). Por eso `source: null`.
- Ausencia de attribution + `source: "Formulario - Sitio Web"` ⇒ lead digital.
En el caso real, esto explicó por qué el contacto existía sin opp: el agente capturó el
contacto en la UI pero **no le creó oportunidad**.
> **Regla de negocio (AGENTS.md):** contactos son bidireccionales Marca↔Sucursal; **opps son
> unidireccionales Sucursal→Marca** (la sucursal es la fuente de verdad). Por eso la
> regularización **crea la opp en la sucursal**, no en Marca, y deja que cascade.
---
## 3. Nivel programación — el toolkit
### 3.1 Resolver nombres de custom field (id ↔ nombre)
Los nombres viven en la tabla `object_schemas` del cache (poblada por sync), con columnas
`location_id, object_key, field_id, field_name, field_key, field_type`:
```python
import sqlite3; from paths import DB_PATH
c = sqlite3.connect(DB_PATH); c.row_factory = sqlite3.Row
r = c.execute("SELECT object_key, field_name, field_key, field_type "
"FROM object_schemas WHERE location_id=? AND field_id=?",
(loc, field_id)).fetchone()
```
Para scripts nuevos, prefiere `scripts/common.py::SchemaResolver` + `FIELD_ALIASES` (resuelve
por **alias** estable, no por id). Verifica también el `field_type`: los `SINGLE_OPTIONS`
exigen que el valor sea una **label** válida del picklist (memoria
`custom_fields_picklist_alignment`); los `TEXT` aceptan texto libre.
### 3.2 El script de backfill (doble método de match)
`scripts/backfill_opp_sucursal_link.py` (memoria `backfill_opp_sucursal_link_script`):
- Solo escribe en **Marca**, idempotente (salta CF ya válido de 20 chars), snapshot en
`generated/migrations/`, audita en `script_audit` por `run_id` (reversible).
- **Importante:** matchea contacto Marca↔Sucursal con `common.match_contacts`
(**phone+nombre**, memoria `matching_rules`), **independiente** del campo
`id_contacto_sucursal`. Esto lo hace un **verificador cruzado**: si tu investigación manual
(vía `id_contacto_sucursal`) y el script (vía phone+nombre) coinciden, tienes dos métodos
independientes de acuerdo → confianza alta.
- Uso programático (permite acotar a un set de opps y correr piloto→batch):
```python
from scripts import backfill_opp_sucursal_link as bf
bf.run_match(opp_ids=[...], dry_run=True) # dry-run
bf.run_match(opp_ids=[UNA], dry_run=False, run_id="...") # piloto de 1
bf.run_match(opp_ids=[LAS_OTRAS], dry_run=False, run_id="...") # batch (mismo run_id)
```
Estados del plan: `match_unique` (aplicable), `review_ambiguous`, `review_count_mismatch`,
`branch_contact_no_opps`, `no_branch_contact`, `phone_collision`, `no_data`.
### 3.3 Crear / borrar / actualizar opps (firmas y payloads)
`ghl_client.py` (vía `sync_engine.ghl_client`):
```python
gc.get_opportunity(token, opp_id) # GET /opportunities/{id}
gc.create_opportunity(token, opp_data) # POST /opportunities/
gc.update_opportunity(token, opp_id, opp_data) # PUT /opportunities/{id}
gc.delete_opportunity(token, opp_id, location_id) # DELETE (params: locationId)
gc._request("GET", "/opportunities/search", token, params={...}) # ver §3.4
```
**Payload de creación** (probado; formato de
`create_opportunities_for_contacts_without_any.py`):
```python
{
"locationId": LOC, "name": "...", "status": "open", "monetaryValue": 0,
"pipelineId": "...", "pipelineStageId": "...", "contactId": "...",
"customFields": [ {"id": cf_id, "value": "..."} ] # CREATE usa {id, value}
}
```
**Payload de update de CF**: el backfill usa `{"id", "key", "field_value"}`; el formato
`{"id", "value"}` también funciona. Para opps nuevas en sucursal, setea su
`id_oportunidad_sucursal` = **su propio id** tras crearla (self-link), aunque n8n tiene
fallback de autoenlace (memoria `n8n_workflows_v2_hardened`).
### 3.4 Gotchas de la API GHL (los que costaron tiempo)
- **`get_opportunity` devuelve los CF bajo la clave `fieldValue`** — NO `fieldValueString`
(que sí usa el endpoint de búsqueda) ni `value`. Leer la clave equivocada da un
**falso negativo** al verificar post-write. En la corrida real, el piloto reportó
`applied=1` pero la primera verificación mostró `None` solo porque buscaba `fieldValueString`;
el dato sí estaba bajo `fieldValue`.
- **`GET /opportunities/search` usa snake_case**: `location_id`, `contact_id` (memoria
`ghl_opportunity_search_quirks`). La respuesta trae `customFields` pero **no** `tags`.
- **No uses paginación por offset ni `GET /opportunities/`** (AGENTS.md); para buscar por
contacto, `GET /opportunities/search?location_id=..&contact_id=..`.
- **Tokens en scripts ad-hoc**: `sync_engine.parse_accounts_csv()`, **no** `main.TOKENS_CACHE`
(vacío fuera del startup de FastAPI — ver PLAYBOOK_DESCUADRE §3.4).
### 3.5 Reversibilidad (obligatoria antes de mutar)
- `script_audit.create_run(run_id, script_name, arguments, locations)` →
`record_change(run_id, loc, "opportunity", obj_id, field_id, field_name, old, new)` →
`mark_change(change_id, "applied")` → `update_run_status(run_id, "completed")`.
- Para DELETE: **snapshot del objeto completo** a `generated/migrations/` ANTES de borrar
(permite recrearlo), y registra el `old_value` con la opp entera.
- Todo lo aplicado con `run_id` es **reversible desde el dashboard**.
---
## 4. Caso de estudio — la corrida del 2026-05-29
**Estado inicial:** 10 opps de Marca con "ID Oportunidad Sucursal" vacío
(CSV `comparativa_opps_missing_id_field`).
### 4.1 Las 9 con espejo — backfill
1. Para cada opp Marca → su contacto → `id_contacto_sucursal` → contacto sucursal (phone
coincidió 100% en las 10) → su única opp → ese id.
2. **Verificación cruzada:** el `dry-run` de `backfill_opp_sucursal_link.py` (que matchea por
phone+nombre, sin usar `id_contacto_sucursal`) devolvió **exactamente los mismos 9 ids** →
dos métodos independientes de acuerdo.
3. **Piloto de 1** (Cosme, la única `won`) → aplicar → **verificar en vivo** (tropezón del
`fieldValue`, §3.4, resuelto) → **batch de 8** bajo el mismo `run_id`
`backfill-opp-link-20260529`. **9/9 verificadas en vivo.**
### 4.2 La 1 sin espejo — Jorge Rosas (regularización extremo a extremo)
**Síntoma:** `branch_contact_no_opps`. El contacto existía en Tampico, pero con **0 opps**.
**Forense:**
- `attributionSource: {medium: manual, sessionSource: CRM UI}` ⇒ el contacto se creó **a mano
en la UI de Tampico** (20:49:57Z), sin opp.
- n8n `[1604]` lo replicó a Marca 4 s después (20:50:01Z).
- Una opp se **auto-creó en Marca** a las 20:50:05Z (`open`, `$0`, `createdAt == updatedAt ==
lastStatusChangeAt`) ⇒ **artefacto de automatización** sin espejo en la sucursal.
- Dato curioso registrado: "Jorge Rosas" es **también el nombre de un agente** de Tampico
(aparece en "Persona que atendió"); el contacto-cliente es entidad distinta (su teléfono).
**Acción (instruida por el usuario, paso a paso):**
1. **Borrar** el artefacto de Marca (snapshot + `delete_opportunity`, audit
`run_id=jorge-rosas-regularize-20260529`).
2. **Crear la opp en Tampico** (la fuente de verdad), pipeline `Standar`
(`ep1d4VpzRezVqWayFbBf`), etapa **PROSPECTO NUEVO**, mapeando del contacto: Modalidad de
Empeño = "Sin Dejarlo (GPS)", Vehículo = "hyundai creta 2017", Fuente de Prospecto =
"SUCURSAL"; luego self-link de su `id_oportunidad_sucursal`.
3. **Esperar 1 minuto** a que n8n (Sucursal→Marca) replique **solo**.
4. n8n **creó** la opp en Marca (~35 s después). **Verificar homologación 100%**: name,
status, $, Modalidad, Vehículo, Fuente y el enlace (`ID Oportunidad Sucursal` de Marca =
id de la opp de Tampico) — **todo coincidió, sin creación manual**.
**Estado final:** 10/10 resueltas (9 backfill + 1 regularizada), y de paso **se validó en
producción que el workflow n8n Sucursal→Marca funciona** end-to-end.
---
## 5. Análisis de perspectivas — por qué funcionó
- **Verificación cruzada por dos métodos.** El enlace se confirmó por `id_contacto_sucursal`
(investigación) **y** por `match_contacts` phone+nombre (el script), de forma independiente.
Coincidir al 100% por dos caminos distintos es lo que justificó aplicar sin dudar.
- **Vivo, no cache — y la clave correcta.** Toda verificación post-write fue contra
`GET /opportunities/{id}` en vivo. El falso negativo del `fieldValue` enseñó que "verificar"
no basta: hay que **leer el campo correcto**. Un `applied=1` no es prueba; el read en vivo sí
(cuando lees bien).
- **Probar la automatización antes de hacerlo a mano.** En vez de crear la opp de Marca
manualmente, se creó la de sucursal y **se le dio 1 minuto a n8n**. Resultado doble: se evitó
una posible **duplicación** (n8n + manual) y se **validó el workflow** en producción. La mano
era el plan B, no el A.
- **Orden de operaciones pensado.** Borrar el artefacto de Marca **antes** de crear la opp en
sucursal garantizó que n8n hiciera un CREATE limpio (no un UPDATE contra una opp basura ni un
duplicado).
- **Reversibilidad primero.** DELETE + CREATE quedaron auditados con snapshot del objeto
completo. Un borrado destructivo se volvió reversible.
- **El usuario como timón; el rol técnico, traducir y advertir.** El usuario fijó la estrategia
(borrar artefacto, crear en sucursal, probar n8n) con autorización **incremental**: "adelante"
para el dry-run, "sí adelante" para piloto→batch, e instrucciones explícitas paso a paso para
la regularización. Cada nivel de mutación esperó su confirmación; la autorización de un paso
**no se extendió** al siguiente más riesgoso.
- **Nombrar cuentas, no ids.** Cada `location_id` se reportó con su nombre (`Tampico`,
`Eugenia`, …): el usuario opera por nombre (memoria `name_account_with_location_id`).
- **Defecto de dato ≠ defecto de número.** El caso Jorge Rosas "cuadraba" como una opp en
Marca, pero era un artefacto sin sustento. Se resolvió por **integridad** (que la opp exista
donde debe y esté enlazada), no por mover un contador.
---
## 6. Checklist (pégalo y síguelo)
1. **Resuelve el field_id** de "ID Oportunidad Sucursal" por nombre (§3.1), no lo hardcodees.
2. Para cada opp Marca con el CF vacío: opp → contacto → `id_contacto_sucursal` → contacto
sucursal → sus opps. Anota **M vs N** (§2.2).
3. **Corre el dry-run** de `backfill_opp_sucursal_link.py` acotado a esas opps y **cruza** sus
`match_unique` con tu resolución manual. Deben coincidir.
4. **Piloto de 1 → verifica en vivo** (`get_opportunity`, clave `fieldValue`) **→ batch** bajo
el mismo `run_id`.
5. Los `branch_contact_no_opps`: **no es enlace, es opp faltante**. Decide artefacto vs real
(§2.3) con el usuario.
- Artefacto sin espejo → **borrar de Marca** (snapshot+audit) + **crear la opp en la
sucursal** + **esperar a n8n** + **verificar homología 100%**.
- Opp real → crear/emparejar el espejo según corresponda.
6. **Re-verificación final en vivo** de cada opp tocada (campo == id de opp sucursal esperado).
7. Si quedaron opps de sucursal nuevas, recuerda el `fill_opp_id_oportunidad_sucursal.py`
(self-link del lado sucursal) para mantener la consistencia que habilita el sync multi-opp.
---
## 7. Síntomas → causa probable
| Síntoma | Causa probable |
|---|---|
| Opp de Marca con "ID Oportunidad Sucursal" vacío y contacto **con** `id_contacto_sucursal` | Opp creada antes de que n8n poblara el enlace → **backfill**. |
| `branch_contact_no_opps` (contacto sucursal sin opp) | El agente capturó el contacto sin crear opp → **regularizar** (crear opp en sucursal). |
| Opp de Marca `open`/`$0` con `createdAt == updatedAt` | **Artefacto de automatización** (se auto-creó al replicar el contacto). |
| `applied=1` pero el read muestra el CF vacío | Estás leyendo `fieldValueString`/`value`; `get_opportunity` usa **`fieldValue`**. |
| Tras crear opp en sucursal, no aparece en Marca | Espera ~1 min a n8n; si no, revisa executions del workflow de opps (ver PLAYBOOK_DESCUADRE §3.3). |
| Contacto con `source: null` pero datos completos | Alta **manual en CRM UI** (`attributionSource.medium = manual`). |
| Dos opps del mismo contacto, una en Marca falta | Multi-empeño no replicado (memoria `opp_multiplicity_replication_gap`). |
---
## Referencias
- [PLAYBOOK_DESCUADRE.md](PLAYBOOK_DESCUADRE.md) · [AGENTS.md](../AGENTS.md) ·
[CLAUDE.md](../CLAUDE.md) · [docs/GUIA_AGENTICA.md](GUIA_AGENTICA.md)
- Código: `scripts/backfill_opp_sucursal_link.py`,
`scripts/create_opportunities_for_contacts_without_any.py`, `scripts/common.py`
(`match_contacts`, `SchemaResolver`), `script_audit.py`, `ghl_client.py`, `paths.py`
(`MIGRATIONS_DIR`).
- Memorias: `backfill_opp_sucursal_link_script`, `opp_self_id_in_branch_field`,
`contact_self_id_in_branch_field`, `automation_artifact_opportunities`,
`opp_multiplicity_replication_gap`, `n8n_opp_sync_match`, `n8n_workflows_v2_hardened`,
`feedback_dry_run_protocol`, `matching_rules`, `ghl_opportunity_search_quirks`,
`name_account_with_location_id`, `create_duplicate_phone_contact_marca`.
+735
View File
@@ -0,0 +1,735 @@
# Patrones probados de Playwright contra Bucéfalo / GHL
Este documento es la post-mortem del trabajo de hacer que el auto-login con 2FA por correo funcione end-to-end. Sirve como **referencia para futuros scripts** de automatización contra la UI de Bucéfalo (o GHL en general).
Va de la mano con:
- [PLAYWRIGHT_SESSION.md](PLAYWRIGHT_SESSION.md) — cómo manejar la sesión (storage_state vs perfil persistente, auto-login con `.env`).
- `memory/ghl_ui_quirks.md` — quirks operativos de la UI de Bucéfalo.
---
## Caso de estudio: el auto-login con 2FA
### Problema original
Tras configurar credenciales en `.env`, el botón "Renovar sesión Bucéfalo" disparaba un subprocess Playwright que debía:
1. Llenar email + contraseña.
2. Click en "Iniciar sesión".
3. Seleccionar el método 2FA "Email".
4. Esperar a que llegara el correo con el OTP.
5. Pegar el código en la UI.
6. Esperar al dashboard.
El primer intento funcionó hasta el paso 2. A partir del 3 el script reportaba "Auto-login falló" — pero el usuario veía que en realidad **el login sí completaba** (la barra de Bucéfalo mostraba "0 h de antigüedad"). Tres bugs encadenados.
### Diagnóstico: cómo lo descubrimos
Sin screenshots de debug, las iteraciones iniciales eran a ciegas. La táctica que funcionó:
1. **Agregar `_save_debug_screenshot(page, label)` en cada punto crítico** del flujo:
- `post_login` (tras enviar credenciales)
- `no_method_selector` (si el detector de método 2FA caduca)
- `before_otp_input` (justo antes de pedir el OTP por IMAP)
- `otp_input_search` (tras detectar dónde tipear el código)
- `code_typed` (con el código ya pegado)
- `final_state` (estado final, sea éxito o fallo)
2. **Mirar las capturas en orden** para reconstruir lo que pasó.
Las capturas revelaron los 3 bugs:
| # | Bug | Síntoma en captura |
|---|---|---|
| 1 | Bucéfalo NO muestra selector de método 2FA — manda el código directo al correo | `post_login` mostró directo la pantalla "Verificar el Código" con 6 inputs vacíos |
| 2 | El `.fill(code)` en un input con `maxlength="1"` solo acepta el primer caracter | `code_typed` mostró "8" en el primer input y los siguientes 5 vacíos |
| 3 | `_wait_for_login_completion` chequeaba solo `page.url`, pero Bucéfalo es SPA y mantiene la URL en `/` aunque el contenido sea el dashboard | `final_state` mostró el dashboard completo, pero el script reportó fallo y la URL seguía siendo `/` |
### Fixes aplicados (en orden de iteración)
**Iteración 1 — Selectores amplios para el método 2FA**: agregué 16 variaciones (button, div[role=button], radio, label, tarjetas con clase `option`/`method`, data-test-id). Hago polling 15 s buscando cualquiera de ellos. Si ninguno aparece, asumo que Bucéfalo mandó el código directo y continúo. — *Bug 1 cubierto.*
**Iteración 2 — Tipeo del OTP con `keyboard.type`**: en lugar de `input.fill(code)` (que rompe con maxlength=1), detecto los 6 inputs de un dígito (`input[maxlength="1"]`, filtrados a visibles), hago click en el primero para foco, y luego `page.keyboard.type(code, delay=80)`. Eso simula tipeo humano caracter por caracter; Vue captura cada keydown y mueve el foco automáticamente al siguiente input. — *Bug 2 cubierto.*
**Iteración 3 — Detección de login completado por DOM + URL**: `_wait_for_login_completion` ahora combina dos señales:
- URL fuera de `/login` o `/auth`.
- DOM ya no muestra el form de login (ningún `input[type="password"]` visible, ni texto "Verificar el Código", ni "Sign in to your account").
Requiere 2 polls consecutivos confirmando ambas señales (~4 s mínimo) para evitar falsos positivos durante la redirección. — *Bug 3 cubierto.*
### Resultado verificado
Corrida final desde terminal:
```
[AUTO] Detectados 6 inputs de 1 dígito (input[maxlength="1"]).
[AUTO] Código tipeado vía keyboard.type (modo: digits).
[INFO] URL actual: https://crm.bucefalocrm.io/
[INFO] URL actual: https://crm.bucefalocrm.io/agency_dashboard?tab=summary
[INFO] Login completado (URL fuera del login + DOM sin form de credenciales).
[ÉXITO] Sesión guardada en: H:\...\generated\browser\session.json
```
Tiempo total: ~30-45 s desde "click en Renovar" hasta sesión guardada.
---
## Lecciones generales para scripts contra Bucéfalo / SPAs
### 1. Nunca confíes solo en la URL
Bucéfalo (y GHL en general) son SPAs Vue/React. Las transiciones internas:
- **Pueden mantener la URL** mientras cambian el contenido (route guard, replaceState, re-render del root).
- **Pueden mostrar el form de login dentro del mismo iframe** cuando una request da 401, sin redirigir a `/login`.
- **Pueden cambiar la URL pero seguir mostrando contenido viejo** brevemente durante la hidratación.
**Patrón a usar**: combinar URL con DOM. Helpers ya existentes:
- `_any_frame_at_login(page)` en [`scripts/ghl_browser_workflow_manager.py`](../scripts/ghl_browser_workflow_manager.py) — detecta login por URL + DOM (input[type=password] visible, textos del form).
- `_looks_like_login_page(page)` en [`scripts/ghl_browser_session_generator.py`](../scripts/ghl_browser_session_generator.py) — versión similar para detectar fin del login.
### 2. Para inputs con `maxlength="1"` (PIN / OTP / tarjetas), usa `keyboard.type` con delay
`locator.fill("123456")` en un input con `maxlength="1"` se trunca y rompe el flujo. La alternativa:
```python
# Detectar inputs de un dígito (más permisivo que `[autocomplete=one-time-code]`)
inputs = page.locator('input[maxlength="1"]').all()
visible = [i for i in inputs if i.is_visible(timeout=200)]
if len(visible) >= 6:
visible[0].click() # foco en el primero
time.sleep(0.3)
page.keyboard.type(code, delay=80) # tipeo humano, Vue auto-advance
```
El `delay=80` ms es suficiente para que Vue/React capture cada keydown.
### 3. Selectores amplios y en cascada
Los selectores de UIs comerciales cambian con cada update. Patrón defensivo:
```python
SELECTORS = [
'button:has-text("Email")',
'button:has-text("Correo electrónico")',
'div[role="button"]:has-text("Email")',
'[data-test-id*="email"]',
'label:has-text("Email")',
# ...
]
deadline = time.time() + 15
while time.time() < deadline:
for sel in SELECTORS:
try:
loc = scope.locator(sel).first
if loc.count() > 0 and loc.is_visible(timeout=300):
loc.click(timeout=2000)
clicked = True
break
except Exception:
continue
if clicked: break
time.sleep(1)
```
Reglas:
- **Probar texto en español Y inglés** — la UI puede estar en otro idioma según preferencia del usuario.
- **No usar `wait_for_selector` rígido con un único selector** — puede caducar 20 s antes de que pruebes otra opción.
- **Hacer polling** dentro del `while time.time() < deadline`.
- **Soportar el caso de "no apareció"** como flujo válido (a veces la UI salta pasos).
### 4. Screenshots de debug en cada punto crítico
Es la herramienta # 1 para diagnosticar. Convención del proyecto:
```python
from paths import SCREENSHOTS_DIR # generated/browser/screenshots/
def _save_debug_screenshot(page, label):
try:
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = os.path.join(SCREENSHOTS_DIR, f"{label}_{ts}.png")
page.screenshot(path=path, full_page=True)
print(f"[DEBUG] Captura guardada: {path}")
return path
except Exception:
return None
```
Naming: `{prefijo_flujo}_{paso}_{timestamp}.png`. Ej: `autologin_post_login_20260523_184611.png`.
`scripts/cleanup_storage.py` los limpia automáticamente cada 30 días (configurable).
### 5. Reusar browser entre operaciones en bulk
Abrir/cerrar Chromium cuesta 8-10 s por operación. Para bulks con N items, el patrón es:
```python
with sync_playwright() as p:
browser, context, page = _open_browser(p)
_INTERRUPT_STATE["browser"] = browser
_INTERRUPT_STATE["context"] = context
try:
for item in items:
try:
_perform_action_on_page(page, item) # navega a la URL del item
except SessionExpiredError:
# abortar todo el bulk; los demás items también fallarían
break
except Exception as e:
# fallo aislado, continuar con el siguiente
...
time.sleep(2) # no martillar la API de GHL
finally:
_close_and_save(browser, context)
```
Helpers ya disponibles:
- `_open_browser(p)` y `_close_and_save(browser, context)` en `ghl_browser_workflow_manager.py` — manejan el modo (shared `storage_state` vs perfil persistente) y refrescan cookies al cerrar.
- `_INTERRUPT_STATE` global + `_install_signal_handlers()` — garantizan cleanup limpio si el server reinicia o el usuario cancela el task.
### 6. Validar contra la API de GHL, no contra la UI
La UI de Bucéfalo tiene bugs visuales (puede mostrar "Guardado" sin haber guardado, puede no refrescar tras una mutación). La API es la fuente de verdad.
```python
# Después de mutar vía DOM, esperar y reconsultar la API.
def _verify_status_via_api(location_id, workflow_id, target_active,
max_attempts=6, base_wait_sec=3):
for attempt in range(1, max_attempts + 1):
time.sleep(base_wait_sec * attempt) # backoff lineal
wfs = sync_engine.ghl_client.get_workflows(token, location_id)
actual = next((w for w in wfs if w.get("id") == workflow_id), None)
if actual and (actual["status"] in ("active","published")) == target_active:
return True
return False
```
GHL puede tardar hasta 20 s en propagar un cambio del builder a su API → reintentar con backoff es esencial.
### 7. Excepciones tipadas para flujos especiales
Si una condición de error invalida todo lo que sigue (sesión expirada en bulk, login redirect, etc.), levanta una excepción dedicada:
```python
class SessionExpiredError(Exception):
"""Bucéfalo redirigió al login → toda interacción posterior va a fallar."""
pass
```
Y en el caller, captura específicamente para abortar early:
```python
for item in items:
try:
_perform_on_page(page, item)
except SessionExpiredError:
# Marcar los items restantes como skipped sin intentarlos.
for remaining in items[idx:]:
results.append({"status": "skipped", "reason": "sesión expirada"})
break
except Exception as e:
# Error aislado, continuar.
...
```
Eso evita N timeouts de 30s cuando ya sabemos que todo fallará.
### 8. SSE para progreso en tiempo real
Los scripts largos (auto-login, bulks) imprimen líneas al stdout con marcadores que el frontend parsea por regex y traduce a estados humanos:
```python
# Backend (Python script):
print(f"[BULK {idx}/{total}] === '{name}' ({wf_id}) ===")
print(f"[BULK {idx}/{total}] RESULT: {status}") # success|failed|skipped
# Frontend (JS):
const m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (\w+)/);
if (m) { /* incrementar contador del status correspondiente */ }
```
Patrón clave: **un marcador `RESULT` único por item** (no contar líneas SKIP separadas — eso causa doble conteo). Ver `bulkParseLine` en [`static/js/app.js`](../static/js/app.js).
Para tareas largas que no son loops, traduce las líneas más relevantes a UI strings:
```js
function _interpretSessionLogLine(line) {
if (/Llenando email \+ contraseña/.test(line)) return { title: "Llenando credenciales…", detail: "..." };
if (/Esperando código OTP por IMAP/.test(line)) return { title: "Esperando el código en el correo…", detail: "..." };
// ...
return null;
}
```
Permite que el modal de progreso muestre algo como `"Esperando el código en el correo…"` en vez de `"[AUTO] Esperando código OTP por IMAP..."`.
### 9. Manejo de modales bloqueantes
Bucéfalo a veces muestra modales tipo "AI Builder habilitado" o "Novedades" que tapan los elementos. Patrón:
```python
_DISMISS_BUTTON_SELECTOR = (
'button:has-text("Entendido"), button:has-text("Got it"), '
'button:has-text("OK"), button:has-text("Aceptar")'
)
def _dismiss_blocking_modals(scope):
try:
btn = scope.locator(_DISMISS_BUTTON_SELECTOR).first
if btn.count() > 0 and btn.is_visible(timeout=500):
btn.click()
time.sleep(0.7)
return True
except Exception:
pass
return False
# Llamar PREEMPTIVAMENTE antes de tu click — y reintentar si reaparece.
for _ in range(3):
if not _dismiss_blocking_modals(scope):
break
# Tu acción:
target.click()
# Y otra vez por si el modal apareció después.
_dismiss_blocking_modals(scope)
```
Si el modal aparece durante un click crítico (toggle de switch), reintentar el click hasta 3 veces verificando el estado deseado.
### 10. Espera explícita por estabilización del estado
Algunos elementos cargan en el DOM antes de tener su estado real bound. Patrón:
```python
# En vez de leer aria-checked inmediatamente:
state = switch.get_attribute("aria-checked") # ⚠ puede ser el default, no el real
# Esperar networkidle (incluso si caduca, el sleep es útil) + margen:
try:
builder_frame.wait_for_load_state("networkidle", timeout=25000)
except Exception:
pass
time.sleep(3) # GHL termina de bindear el estado real
state = switch.get_attribute("aria-checked") # ✓ ahora confiable
```
---
## Anti-patterns observados
| Anti-pattern | Por qué falla | Alternativa |
|---|---|---|
| `confirm()` / `prompt()` / `alert()` nativos del browser | Rompen la estética del dashboard | `appConfirm({...})` / `appPrompt({...})` en `static/js/app.js` |
| `input.fill("123456")` en input con `maxlength="1"` | Solo acepta el primer caracter | `keyboard.type(code, delay=80)` con foco previo |
| `if "login" in page.url` como única señal de sesión expirada | Las SPAs no cambian URL | Combinar con `page.locator('input[type="password"]')` o textos visibles |
| `wait_for_selector` rígido a un selector único | Caduca antes de probar variantes | Polling sobre lista de selectores con `time.time() < deadline` |
| Abrir un Chromium nuevo por cada item de un bulk | 8-10 s perdidos por item | Reusar browser/context entre items con loop interno |
| Trust en el toast "Guardado" del UI como confirmación | Bucéfalo tiene bugs visuales | Reconsultar la API tras la mutación |
| Saltar pausa entre items del bulk | GHL rate-limita y gatilla antibot | `time.sleep(2)` o más entre operaciones |
---
## Plantilla recomendada para un script nuevo
Si quieres escribir un script nuevo de browser-automation contra Bucéfalo:
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tu descripción aquí."""
import os, sys, time
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
# Reusar helpers existentes del manager principal.
from scripts.ghl_browser_workflow_manager import (
_open_browser, _close_and_save,
_save_debug_screenshot,
_any_frame_at_login, SessionExpiredError,
_INTERRUPT_STATE, _install_signal_handlers,
ensure_playwright_browsers, session_file_status,
)
def _perform_action_on_page(page, params):
"""Hace lo tuyo en una página ya abierta. Lanza SessionExpiredError si Bucéfalo
redirige al login. Devuelve True si tuvo éxito."""
target_url = f"https://crm.bucefalocrm.io/.../{params['id']}"
page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
if _any_frame_at_login(page):
raise SessionExpiredError("Bucéfalo redirigió al login")
# ... tu lógica aquí, con screenshots de debug en puntos críticos ...
_save_debug_screenshot(page, "mi_paso_critico")
return True
def main():
_install_signal_handlers()
if not ensure_playwright_browsers():
sys.exit(1)
exists, age = session_file_status()
if not exists:
print("ERROR: Falta generated/browser/session.json. Renueva primero con 'Renovar sesión Bucéfalo'.")
sys.exit(1)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser, context, page = _open_browser(p)
_INTERRUPT_STATE["browser"] = browser
_INTERRUPT_STATE["context"] = context
try:
ok = _perform_action_on_page(page, {"id": "..."})
sys.exit(0 if ok else 1)
finally:
_close_and_save(browser, context)
if __name__ == "__main__":
main()
```
Y si el script debe correr desde el dashboard como subprocess, registra su entrada en `SCRIPTS_METADATA` dentro de `script_runner.py`.
---
## Checklist antes de mergear un script nuevo
- [ ] ¿Los selectores tienen variantes en español **y** inglés?
- [ ] ¿Cada paso crítico guarda screenshot con `_save_debug_screenshot`?
- [ ] ¿La detección de sesión expirada usa `_any_frame_at_login` (URL + DOM)?
- [ ] ¿Las mutaciones validan contra la API tras un margen de 3+ segundos con backoff?
- [ ] ¿Hay manejo de `SessionExpiredError` para abortar early en bulks?
- [ ] ¿El browser se cierra con `_close_and_save` (refresca cookies)?
- [ ] ¿El script registra contexto en `_INTERRUPT_STATE` para que las interrupciones cierren limpio?
- [ ] ¿Las pausas entre operaciones son al menos 2 s?
- [ ] ¿Para inputs maxlength=1, se usa `keyboard.type` con delay?
- [ ] ¿Hay un `RESULT:` único por item si es un bulk (para el parser frontend)?
- [ ] ¿`sys.stdout`/`sys.stderr` reconfigurados a UTF-8 al inicio del script? (evita `UnicodeEncodeError` en Windows cp1252 con `→`, `·`, etc.)
- [ ] Si escanea o lee el **editor visual** de workflows: ¿espera el frame correcto (`client-app-automation-workflows.leadconnectorhq.com`, no el principal) y fuerza el render completo con `#workflow-fit-to-screen`?
---
## Caso de estudio 2: el detector de anomalías en el editor visual de workflows
### Problema
Escanear el canvas de cada workflow buscando tres tipos de anomalía: ícono naranja en nodos (`img#pg-actions__icon--eh-show-error`), IDs de custom field sin resolver dentro del texto del nodo (~20 chars alfanuméricos entre comillas), y avisos globales (`span.n-button__content svg[class*="alert"]`).
El primer escaneo reportó **0 anomalías** en un workflow donde el usuario sabía que existía la cadena `If "Ct0n2f9dvjZNe9npY6WY" no está vac...`. Tomó cinco iteraciones llegar a un detector confiable.
### Lecciones aprendidas (orden de iteración)
#### L1 — El editor visual vive en un iframe externo, no en `crm.bucefalocrm.io`
La page principal (`https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}`) contiene un iframe a `https://client-app-automation-workflows.leadconnectorhq.com/...` donde está el builder real.
`_find_builder_frame()` original buscaba el primer frame con un switch o modal del AI Builder, lo cual funciona para toggles pero no garantiza que sea el frame del canvas. Para escanear nodos hay que **elegir el frame con MÁS matches del wrapper de nodos**, no el primero que tenga ≥1.
```python
# Estrategia que funcionó:
best_frame, best_count = None, 0
for frame in page.frames:
count = frame.locator(NODE_WRAPPER_SEL).count()
if count > best_count:
best_count = count
best_frame = frame
```
Diagnóstico: imprimir `page.frames` con `frame.url` + `count` por frame.
#### L2 — Vue Flow lazy-renderiza nodos: hay que forzar fit-to-screen
El builder de workflows está construido sobre **Vue Flow** (`vue-flow__node`, `vue-flow__minimap`). Vue Flow solo renderiza en el DOM los nodos visibles en el viewport actual — el resto está representado en el minimap pero **no existe como elementos del DOM**.
Sin fit-to-screen, un workflow de 23 nodos puede renderizar solo 2-5 (los visibles al cargar). Con el ícono de "Ajustar a la pantalla" clickeado, los 23 quedan accesibles.
```python
fit_btn = frame.locator('#workflow-fit-to-screen')
if fit_btn.count() > 0:
fit_btn.first.click(timeout=3000)
time.sleep(2)
fit_btn.first.click(timeout=2000) # doble click: el primero a veces solo muestra tooltip
```
IDs útiles del builder (todos están en el frame del iframe externo):
- `#workflow-fit-to-screen` — encajar todo el flujo. **Imprescindible para escaneo full.**
- `#workflow-zoom-in`, `#workflow-zoom-out`, `#workflow-zoom-value` — controles manuales.
- `#workflow-builder-tab-error-highlight` — tab "Resaltar errores" del builder, candidato para futuras detecciones.
- `#workflow-builder-tab-search-and-replace` — útil para buscar IDs específicos sin escanear el DOM.
#### L3 — Los `data-v-*` son hashes scoped de Vue que cambian con deploys
Inicialmente usé `[data-v-aad7ddfb], [data-v-72bc1535]` como selector porque esos hashes aparecían en el HTML que el usuario me compartió. Esos hashes son IDs internos que Vue genera al compilar — **cambian con cada deploy de GHL**. Selectores robustos por estructura/clase:
- `div.rounded-xl.node-shadow` — wrapper de nodos de acción.
- `div.rounded-xl.border-b-4` — wrapper de nodos Branch (border de color según tipo).
- `div.rounded-xl.nopan` — algunos wrappers.
- `#action-node-container` — id del contenedor (se repite por nodo, pero CSS no lo previene).
- `.vue-flow__node` con `data-id="<uuid>"` — wrapper estable del Vue Flow propio, **es el más confiable para identificar nodos individualmente**.
#### L4 — `inner_text()` respeta CSS `truncate`; usa `text_content()`
Los nodos del builder usan `class="truncate"` (Tailwind) para cortar texto visualmente con ellipsis. `inner_text(timeout=...)` de Playwright **devuelve solo el texto visible** según el CSS rendering — pierde caracteres después del corte.
```python
text = node.text_content(timeout=500) or "" # devuelve todo el texto del DOM, ignora truncate
```
Esto es crítico para la regex de IDs (`r'["“”]([A-Za-z0-9]{20})["“”]'`) que requiere las dos comillas: con `inner_text()` la comilla de cierre puede quedar oculta tras la elipsis.
#### L5 — Los íconos de alerta están posicionados absolutos FUERA del wrapper del nodo
El ícono `img#pg-actions__icon--eh-show-error` no es descendiente del `vue-flow__node` correspondiente — Vue Flow lo renderiza como overlay con `position: absolute` en un div separado en el DOM. Subir por `parentElement` no llega al nodo.
Solución: **usar `el.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4')`** que busca el ancestor más cercano con cualquiera de esos selectores. Si no encuentra, fallback a buscar el sibling con texto significativo.
```js
let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
// node.getAttribute('data-id') te da el UUID del nodo Vue Flow.
```
Reportar `data-id` del nodo en el output permite al usuario abrir el nodo específico en la UI (los `data-id` son los UUIDs que GHL usa internamente).
#### L6 — UTF-8 en stdout: la flecha `→` mata el script en Windows cp1252
Cuando el script se corre como subprocess desde el dashboard, su stdout va a un pipe. En Windows el encoding default es `cp1252` y caracteres como `→`, `·`, `«»` rompen con `UnicodeEncodeError`. Reconfigurar al inicio del script:
```python
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
# Python <3.7: fallback
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", line_buffering=True)
```
Esto aplica a **cualquier script Playwright nuevo** porque la consola del subprocess y las capturas SSE pasan por esos pipes.
#### L7 — Para inspeccionar el DOM real, dumpea el HTML del frame correcto
Cuando los selectores no matchean lo que esperas, el primer reflejo es ajustar el selector. El paso anterior es **dumpear el HTML real del frame que estás escaneando** y grepearlo. Patrón:
```python
if os.environ.get("ANOMALY_SCANNER_DEBUG"):
html = frame.evaluate("() => document.documentElement.outerHTML")
dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{wf_id[:8]}.html")
with open(dump_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"[DEBUG] Frame HTML dump: {dump_path} ({len(html)} chars)")
# También logguea cuál frame elegiste vs todos los disponibles:
for fi, f_ in enumerate(page.frames):
cnt = f_.locator(NODE_WRAPPER_SEL).count()
print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={f_.url[:120]}")
```
`frame.evaluate("() => document.documentElement.outerHTML")` retorna el HTML **live** (post-JS), mientras que `frame.content()` puede devolver una versión más temprana.
#### L8 — Las anomalías de campo pueden necesitar contexto semántico, no solo regex
La regex `r'["“”]([A-Za-z0-9]{20})["“”]'` matchea 20 chars alfanuméricos entre comillas. Pero esos 20 chars podrían ser el `workflow_id`, el `location_id`, un UUID de pipeline, o un identificador legítimo. Filtros que aplicamos:
- Descartar si el candidato coincide con `workflow_id` o `location_id` del propio workflow.
- Exigir keywords de contexto en el texto del nodo (`"no está vac"`, `"está vac"`, `"is empty"`, `"es igual"`, `"contiene"`, etc.).
Después de la primera corrida `--all`, revisar manualmente 5-10 hits y ajustar `CONTEXT_KEYWORDS` o endurecer la regex.
#### L9 — **Si la app ya valida algo, no lo re-implementes con heurísticas — pídeselo a la app**
El usuario nos mostró que GHL tiene un botón `#workflow-builder-tab-error-highlight` que se pinta naranja cuando el workflow tiene errores. Al clickearlo, se abre un panel con la lista exacta de nodos problemáticos, sus StepIds y descripciones del error generadas por el propio motor de validación de GHL.
Esto es **infinitamente más confiable** que cualquier heurística externa:
- Idioma-agnóstico (el botón existe con el mismo `id` en español y en inglés).
- Robusto a deploys (el `id` es estable).
- Lo que GHL considera "error" es exactamente lo que el usuario considera "error" en la UI.
- Da info estructurada: `node_name + StepId + descripción exacta del error`.
Patrón resultante (lo que ahora es el **detector primario** del scanner):
```python
def _extract_ghl_native_errors(frame):
btn = frame.locator('#workflow-builder-tab-error-highlight')
if btn.count() == 0:
return []
btn.first.click(timeout=3000)
time.sleep(2.5) # esperar render del panel
return frame.evaluate("""() => {
const out = [];
const stepIdRe = /StepId\\s*:?\\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
// Wrapper de cada error: 'flex flex-col gap-2 py-4 border-b border-gray-200'
const items = document.querySelectorAll('div[class*="border-b"][class*="border-gray-200"][class*="py-4"]');
const seen = new Set();
for (const el of items) {
const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
const m = stepIdRe.exec(txt);
if (!m || seen.has(m[1])) continue;
seen.add(m[1]);
const cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
const stepIdx = cleaned.toLowerCase().indexOf('stepid');
out.push({
node_label: cleaned.slice(0, stepIdx).trim().slice(0, 120),
step_id: m[1],
description: cleaned.slice(stepIdx).replace(stepIdRe, '').trim().slice(0, 400),
});
}
return out;
}""") or []
```
Aplicación general: **antes de escribir heurísticas para detectar X, busca si la app expone un tab/panel/badge que ya cuente lo que necesitas.** En GHL hay varios candidatos similares que pueden servir para otros escenarios:
- `#workflow-builder-tab-error-highlight` — errores del motor de validación (lo anterior).
- `#workflow-builder-tab-stats-view` — estadísticas de ejecución, ideal para auditar workflows con tasas altas de falla.
- `#workflow-builder-tab-version-history` — historial de cambios.
- `#workflow-builder-tab-search-and-replace` — buscar/reemplazar texto en nodos, atajo para encontrar IDs sin escanear DOM completo.
Las heurísticas externas (escanear DOM con selectores y regex) siguen siendo útiles como **complemento** para detectar cosas que la app NO considera error pero el usuario sí — p.ej. nuestro detector heurístico de `unresolved_field_id` encontró `Ct0n2f9dvjZNe9npY6WY` en un nodo Branch que GHL considera sintácticamente válido (porque la condición `If "X" no está vacío` se evalúa OK como texto literal) pero que para el negocio es un bug porque ese ID nunca se resolvió al nombre del campo.
**Política recomendada para el reporte**:
- La detección nativa de la app es la **fuente primaria**: si reporta un nodo, ese nodo se incluye con info rica (descripción del error oficial).
- Las heurísticas son **complemento**: corren igual, pero si un hallazgo heurístico apunta al MISMO nodo (por `data-id`/StepId) que un hallazgo nativo, se suprime el heurístico para evitar ruido (`if node_id in native_step_ids: continue`).
- Hallazgos heurísticos en nodos NO cubiertos por la app se mantienen — es exactamente donde aportan valor.
### Integración con el dashboard de bulk-operations
Para que el progreso del escaneo se renderice solo en la bulk-bar existente (Publicar/Borrador/Eliminar), el script tiene que emitir exactamente las líneas que `app.js` parsea:
```
[BULK X/N] === 'workflow_name' (workflow_id) en location_id ===
[BULK X/N] RESULT: success|failed|skipped
=== RESUMEN BULK-<TARGET> ===
```
El frontend hace `await mutateFetch(\`/api/workflows/bulk-${target}\`, ...)` con target en `{draft, publish, delete, scan-anomalies}`. Para sumar una operación nueva basta con:
1. Endpoint `POST /api/workflows/bulk-{target}` que escribe batch JSON y dispara el script.
2. Script que acepte `--batch-file <path>` y emita las líneas BULK.
3. Botón en la bulk-bar + función JS que llame `_executeBulkOperation('{target}', items, label)`.
## Lección 19. Paralelismo conservador: N browsers, 1 sesión compartida
**Problema:** los bulks Playwright procesaban workflows uno por uno (~30-50s c/u). Un scan/bulk completo sobre las 50 cuentas tomaba horas.
**Intento fallido (no replicar):** "1 proceso → 1 `Browser` → N `BrowserContext` en threads" — la sync API de Playwright lleva un **greenlet event loop por thread**; cualquier objeto creado en el thread main (`Browser`, `Context`, `Page`) explota al usarse desde otro thread con:
```
greenlet.error: cannot switch to a different thread (which happens to have exited)
```
**Solución que funciona:** cada worker thread abre su **propio** `sync_playwright().start()` + `browser.launch()` + `new_context(storage_state=SESSION_FILE)`. La sesión sí se comparte (1 login → N browsers reusando cookies), pero cada thread tiene su event loop dedicado.
Implementación en `ghl_browser_workflow_manager._run_parallel_bulk` y `ghl_browser_workflow_anomaly_scanner.scan_workflows_parallel`.
**Topología real:**
```
1 proceso → N threads (ThreadPoolExecutor)
└── cada thread: sync_playwright().start()
└── chromium.launch() ← N browsers Chromium
└── new_context(storage_state=SESSION_FILE)
└── new_page()
```
**Reglas duras:**
1. **NUNCA paralelizar con perfil persistente** (`GHL_BROWSER_PROFILE_DIR`). Chromium bloquea el profile dir → segundo browser que intenta abrirlo cuelga. Detectar al inicio y forzar `workers=1`.
2. **NUNCA paralelizar el login.** OTP por IMAP es un buzón compartido — 2 procesos compitiendo por el mismo correo se pisan. Genera 1 sesión secuencial, todos los workers reusan `session.json`.
3. **Cada thread crea su propio `sync_playwright().start()` + `browser`.** No intentes compartir un Browser entre threads — la sync API tiene un greenlet loop por thread y los objetos están atados al loop que los creó.
4. **`storage_state` se persiste best-effort al cerrar cada context.** Cookies refrescadas por GHL durante el run quedan en `session.json` para la próxima ejecución. No es transaccional; con N workers, gana el último.
5. **Cancelación cooperativa con `threading.Event`.** Cuando un worker captura `SessionExpiredError`, setea `cancel_event`; los demás revisan en el bucle y salen limpio. No swallow de excepciones.
6. **Pausa entre items, no entre workers.** `per_action_pause` (3s default mutador, 1s read-only) por worker, interrumpible mediante chequeo de `cancel_event` en sleep granular (`step=0.25s`).
7. **Agrupar la queue por `location_id`.** Reduce navegación lateral y refresh de cookies cross-sucursal.
8. **Cada cambio del mutador sigue auditándose con `script_audit`.** SQLite serializa writes; activamos WAL en `init_audit_db` para evitar `database is locked` con 2-5 workers.
9. **Tope de 5 workers.** Más allá, el cuello deja de ser CPU/browser y empieza a ser anti-bot/throttling de Bucéfalo. La ganancia plana se vuelve riesgo.
10. **Métricas en `generated/logs/parallel_runs.jsonl`** (append). Comparar `duration_s` y `failed/skipped` entre runs para detectar regresiones o señal de throttling.
**Cuándo subir workers:**
- Read-only (scanner): hasta 5 sin problema.
- Mutador (manager bulk-draft/publish/delete): empezar en 2, máximo 3-4. Cada acción dispara API validate post-mutación.
- Si ves `[METRICS] failed` creciendo o `SESIÓN EXPIRADA` en bulks que antes pasaban → bajar workers, subir `--action-pause`.
**CLI:**
```bash
# scanner read-only, agresivo
python scripts/ghl_browser_workflow_anomaly_scanner.py --all --workers 4 --action-pause 1
# bulk-draft conservador
python scripts/ghl_browser_workflow_manager.py --action bulk-draft \
--batch-file generated/runtime/batch/draft_batch.json --workers 2 --action-pause 3
```
**Verificación:** ver `[METRICS] start` / `[METRICS] end` en stdout. Run a workers=1 como baseline, luego workers=N, comparar `rate` y `failed`.
**Resultados medidos (2026-05-28, scanner read-only sobre Bucéfalo):**
| Workers | Items | Tiempo | s/item | Speedup |
|---------|-------|--------|--------|---------|
| 1 | 6 | 312.9s | 52.2 | baseline |
| 2 | 6 | 192.8s | 32.1 | **1.62×** |
| 4 | 12 | 200.9s | 16.7 | **3.13×** (vs baseline extrapolado) |
No hubo `SessionExpiredError` ni fallos en ninguna iteración. Cada browser extra consume ~150-200 MB RAM en headless; con 4 workers son ~800 MB-1 GB adicionales — cómodo en una máquina con 8 GB+.
Las acciones por fila individual reusan el mismo flujo con `items = [oneItem]` — no hace falta endpoint separado.
## Lección 20. Auditoría granular paralela + observabilidad
El manager Playwright **registra cada mutación en `script_audit`** siguiendo el patrón gold-standard de `sync_missing_opps_to_brand.py`:
```python
# antes del intento
change_id = script_audit.record_change(run_id, location_id, "workflow", wf_id,
field_id="status", field_name="status",
old_value=current, new_value=target) # status = "planned"
# tras la mutación + validación API
script_audit.mark_change(change_id, "applied") # éxito
# o
script_audit.mark_change(change_id, "failed", msg) # cualquier path de error
```
Inyectado en `_perform_toggle_item`, `_perform_delete_item`, y `rename_via_playwright_dom`. Thread-safe (SQLite con WAL + busy_timeout=30s; nuevas conexiones por llamada via `script_audit.get_conn`).
**Bootstrap idempotente**: tanto manager como scanner llaman `script_audit.create_run(...)` (que usa `INSERT OR IGNORE`) al inicio de `main()`. Esto garantiza que runs lanzados desde CLI (sin pasar por el dashboard) tampoco queden huérfanos.
**Log estructurado por-run**: `script_logger.RunLogger` (en raíz) escribe una línea JSONL por evento a `generated/logs/script_runs/{run_id}.jsonl`. Cada línea: `{ts, level, run_id, event, worker_id, location_id, workflow_id, status?, change_id?, error_id?, duration_ms?}`. Thread-safe (lock interno) — los N workers paralelos escriben sin race.
### Las 4 fuentes de verdad para forensics
| Síntoma / pregunta | Dónde mirar |
|---|---|
| ¿Qué se intentó/aplicó? | `script_audit.get_run_summary(run_id)` o `python scripts/audit_run.py <run_id>` |
| Bulk falló a la mitad | `generated/logs/script_runs/{run_id}.jsonl` (cronología por worker) |
| Error específico de Playwright | `generated/logs/errors.jsonl` filtrar por `error_id` o por `context.run_id` |
| Performance / speedup | `generated/logs/parallel_runs.jsonl` |
| Estado visual al fallar | `generated/browser/screenshots/*_{loc}_{wf}.png` |
| Listado rápido de runs recientes | `python scripts/audit_run.py --list 20` |
### CLI de inspección — `scripts/audit_run.py`
```bash
python scripts/audit_run.py <run_id> # resumen + cambios + eventos + errores
python scripts/audit_run.py --list 20 # últimos 20 runs con counts
python scripts/audit_run.py <run_id> --json # JSON crudo (para jq/pipes)
python scripts/audit_run.py <run_id> --events 50 # más eventos del JSONL
```
Combina las 4 fuentes en una sola vista. No depende del dashboard ni de FastAPI.
### Sobre rollback
`script_audit.rollback_run` revierte mutaciones de **custom fields** via PUT. Las mutaciones DOM de workflows (toggle/delete/rename) **se registran pero no son auto-revertibles** — el undo manual es ejecutar el bulk inverso:
| Acción original | Reversión |
|---|---|
| bulk-draft | bulk-publish del mismo batch |
| bulk-publish | bulk-draft del mismo batch |
| bulk-delete | recrear (no recuperable desde GHL) — confirmar antes de aplicar |
| rename | rename con el old_value que guardó `script_change_log` |
Eso queda documentado y trazable; ningún cambio se pierde en el éter.
+206
View File
@@ -0,0 +1,206 @@
# Sesión persistente de Bucéfalo en Playwright
Este documento explica cómo funcionan los scripts del repo que automatizan la UI de Bucéfalo CRM con Playwright (toggle de workflows, renombrar, eliminar, etc.), por qué a veces se cae la sesión, y cómo recuperarla.
Audiencia: quien mantiene `scripts/ghl_browser_*.py` o usa el dashboard para mutar workflows. Si alguien (humano o Claude) llega aquí buscando "por qué falla el delete/toggle/rename de workflows", este es el lugar.
---
## Por qué necesitamos Playwright
La API oficial de GHL (`services.leadconnectorhq.com`) no expone todas las operaciones — en particular:
- Toggle Borrador ↔ Publicado de un workflow
- Renombrar un workflow
- Eliminar un workflow
Para esas tres acciones, los scripts automatizan **la UI web** con Playwright sobre Chromium en modo headless. Toda la lógica vive en [scripts/ghl_browser_workflow_manager.py](../scripts/ghl_browser_workflow_manager.py); la sesión se genera con [scripts/ghl_browser_session_generator.py](../scripts/ghl_browser_session_generator.py).
---
## Dos modos de sesión
Los scripts soportan dos modos de persistencia. Se elige con la variable de entorno `GHL_BROWSER_PROFILE_DIR`.
### Modo 1 — Shared `storage_state` (por defecto)
- **Cuándo se usa**: cuando `GHL_BROWSER_PROFILE_DIR` no está definida (el caso normal).
- **Cómo funciona**: el archivo `generated/browser/session.json` contiene cookies + localStorage. Cada script abre un browser limpio con esas cookies, hace lo suyo, y guarda las cookies actualizadas de vuelta en `generated/browser/session.json`.
- **Pros**: simple, soporta scripts en paralelo (el archivo se lee al inicio y se escribe al final, no hay locks).
- **Cons**: GHL puede interpretar cada arranque como un "navegador nuevo" porque no hay IndexedDB ni cache compartido. Si GHL invalida la sesión por detectar dispositivos múltiples, este modo se cae más rápido.
### Modo 2 — Perfil de Chrome persistente
- **Cuándo se usa**: cuando defines `GHL_BROWSER_PROFILE_DIR` apuntando a un directorio. Lo más fácil es lanzar el servidor con [start_persistent_profile.bat](../start_persistent_profile.bat).
- **Cómo funciona**: Playwright usa `launch_persistent_context()` con un perfil completo en disco — igual que un Chrome real. Persiste cookies HttpOnly, IndexedDB, cache, localStorage, service workers.
- **Pros**: GHL trata el perfil como un "dispositivo" estable. Sesión mucho más duradera. Login dura semanas en vez de horas/días.
- **Cons**: **no puedes correr dos scripts en paralelo** contra el mismo perfil — Chrome bloquea el directorio mientras un proceso lo usa. Si necesitas ejecuciones concurrentes (raro en este repo), no uses este modo.
---
## Auto-login con 2FA por correo (IMAP)
Si configuras un archivo `.env` con las credenciales de Bucéfalo + IMAP de tu correo, el `ghl_browser_session_generator.py` hace **todo el login solo**: llena email + contraseña, selecciona "Email" en el selector de método 2FA, lee el código del correo vía IMAP y lo pega.
### Configuración
1. Copia `.env.example` a `.env`:
```cmd
copy .env.example .env
```
2. Edita `.env` y rellena:
- `BUCEFALO_LOGIN_EMAIL` / `BUCEFALO_LOGIN_PASSWORD` — credenciales de tu usuario de Bucéfalo.
- `EMAIL_IMAP_HOST` — servidor IMAP de tu correo (típicamente el dominio de tu hosting, ej. `c1101854.sgvps.net`).
- `EMAIL_IMAP_PORT` — 993 (IMAPS) por defecto.
- `EMAIL_IMAP_USER` / `EMAIL_IMAP_PASSWORD` — credenciales de la cuenta de correo a la que llega el código MFA.
3. Verifica que el `.env` esté excluido de Mega/Git (el `.megaignore` y `.gitignore` del repo ya lo cubren).
### Comportamiento
- Si están **todas** las credenciales en `.env`, el session generator corre en `headless=True` (sin abrir ventana) y completa el login en ~30-60 s.
- Si **falta alguna**, cae al modo manual: abre el navegador visible para que tú completes el login.
- Si quieres forzar el modo manual aunque haya credenciales, usa `--no-auto`:
```cmd
python scripts/ghl_browser_session_generator.py --no-auto
```
### Probar el lector IMAP por separado
Si solo quieres validar que IMAP funciona y el parser extrae el código:
```cmd
python scripts/email_otp_reader.py
```
Te pide que provoques un código (intenta loggearte a Bucéfalo) y al recibirlo lo imprime.
### Seguridad
- El archivo `.env` contiene credenciales en texto plano. Manténlo solo en tu equipo local.
- Si crees que el `.env` pudo haberse copiado a un lugar no seguro (cloud público, repo público, captura de pantalla), cambia las contraseñas inmediatamente.
- Los `.env.example` y `.megaignore` del repo están diseñados para evitar que esto pase por descuido.
## Cómo arrancar cada modo
### Modo 1 (default)
```cmd
start.bat
```
Si nunca generaste sesión, en el dashboard ve a la pestaña **Workflows GHL** y dale al botón **"Renovar sesión Bucéfalo"**. Inicia sesión + MFA en la ventana que se abre — el archivo `generated/browser/session.json` se crea solo.
### Modo 2 (perfil persistente)
```cmd
start_persistent_profile.bat
```
El .bat:
1. Setea `GHL_BROWSER_PROFILE_DIR=<repo>/generated/browser/profile`.
2. Lanza `python main.py` con esa variable en el entorno.
La primera vez te toca generar el perfil:
1. En el dashboard, dale a **"Renovar sesión Bucéfalo"**.
2. Login + MFA en la ventana.
3. El perfil queda guardado en `generated/browser/profile/`. Próximos arranques no piden login.
> ⚠️ Si quieres volver al modo 1, basta con cerrar el server y abrirlo con `start.bat`. Los dos modos no se mezclan: el modo 1 usa `generated/browser/session.json`, el 2 usa `generated/browser/profile/`.
---
## Síntomas y diagnóstico
### "Chromium me cerró la sesión de mi navegador personal"
GHL detectó dos sesiones activas (la tuya y la de Playwright) e invalidó la más vieja.
- **Modo 1**: muy probable. Cada arranque "huele" a dispositivo nuevo.
- **Modo 2**: poco probable. El perfil persistente se ve como el mismo dispositivo.
**Solución**: cambia al modo 2 con `start_persistent_profile.bat`.
### "ERROR: No se pudo interceptar la sesión de usuario de GHL"
El script (ya no usa este flujo, pero el mensaje puede aparecer en versiones viejas) o el flujo DOM no pudo cargar la página porque te redirigieron al login.
**Solución**: renueva la sesión desde el botón del dashboard. Si pasa muy seguido, cambia al modo 2.
### "Sesión Bucéfalo: 49.5 h de antigüedad" en el dashboard
El indicador muestra la antigüedad de `generated/browser/session.json`. Una sesión vieja **no garantiza** que esté caducada — GHL puede aceptarla. Pero a partir de 24 h, el dashboard advierte.
- Si la corrida falla, renueva la sesión.
- Si la corrida funciona, el archivo se actualiza solo (el script refresca cookies al cerrar).
### "ERROR de navegador: BrowserType.launch: Executable doesn't exist at …"
Faltan los binarios de Chromium. El script intenta instalarlos solo en el primer fallo:
```cmd
python -m playwright install chromium
```
### "El cambio NO persistió en GHL"
Tras un toggle exitoso visual, la API de GHL sigue devolviendo el estado viejo. Causas:
- El workflow tiene un trigger inválido (Webhook sin URL, etc.) y GHL revierte el cambio en silencio.
- Bug visual de la UI — el script ya hace un reload + revalida automáticamente.
Si el script reporta este error después de los reintentos, abre el workflow en el navegador, revisa los triggers, y vuelve a intentar.
### "Proceso interrumpido (signal …)"
El subprocess de Playwright fue matado externamente — típicamente porque reiniciaste el server o cancelaste el task desde el dashboard. El handler de señales:
- Cierra Chromium limpio (sin zombies).
- Refresca cookies en `generated/browser/session.json`.
- Consulta la API y te dice si el cambio **sí se aplicó pese a la interrupción**.
Si fue antes del click del switch: no pasa nada, reintenta.
Si fue después: revisa el log — la línea `[INTERRUPCIÓN] El cambio SÍ se aplicó` te lo dice.
---
## Reglas para mantener los scripts
Si tocas `scripts/ghl_browser_*.py`:
1. **No abras un `browser = p.chromium.launch(...)` manual**. Usa `_open_browser(p)` — respeta el modo (shared / persistent).
2. **No cierres con `browser.close()`**. Usa `_close_and_save(browser, context)` — refresca cookies y limpia el estado del handler de interrupciones.
3. **Registra el browser y context en `_INTERRUPT_STATE`** justo después de abrirlos. Si no, los handlers de interrupción no podrán cerrarlos.
4. **No confíes en la UI para validar mutaciones**. La fuente de verdad es la API de GHL. Tras cualquier guardado visual, llama a `_verify_status_via_api(...)` (o un equivalente) con reintentos.
5. **Selectores frágiles**: si GHL cambia el HTML, los selectores hardcodeados (`#cmp-header__btn--save-workflow`, `.n-switch`, textos "Eliminar flujo de trabajo"/"Delete workflow") pueden romperse. Tomar screenshot de debug es el primer paso de diagnóstico (`_save_debug_screenshot(page, "label")`).
---
## Cómo reportar un error útilmente
Cuando un script de Playwright falla y necesitas ayuda (humano o IA), incluir esta información acorta el diagnóstico de horas a minutos:
1. **El `error_id` completo**. Aparece en los logs como `error_id=550af7d5-…`. Permite consultar [error_log](../db.py) con todo el contexto (return_code, comando exacto, últimas 80 líneas de output).
2. **El `task_id`** si lo ves (aparece como `(task d819f354-…)`). Permite cruzar con `script_runs` para ver el estado de la auditoría.
3. **Las últimas 20-30 líneas del log** que viste. Aunque el `error_log` ya las guarda, a veces el truncado pierde la línea más informativa.
4. **Si hay screenshots en `generated/browser/screenshots/`** con timestamp cercano al fallo: nómbralos. Los scripts guardan capturas en cada punto crítico (`delete_no_row_*`, `switch_no_change_*`, `post_save_state_*`, etc.).
5. **El estado de la sesión**: ¿cuánto tiene `generated/browser/session.json`? El dashboard te lo dice arriba de la tabla de Workflows. Si tiene más de 24 h, renueva primero y reintenta antes de pedir ayuda.
6. **Qué intentaste hacer**: acción (`toggle-status`/`delete`/`rename`), `location_id`, `workflow_id`, y qué esperabas que pasara.
Ejemplo de un buen reporte:
> El toggle-status del workflow `67f98059-…` en `Z64WQKORPVwXb5mn68Ef` falló a las 14:03.
> Log dice `error_id=9f58fa5d-…` y `[ERROR] La tabla de workflows no cargó`.
> Screenshot: `generated/browser/screenshots/delete_list_failed_20260523_142713.png`.
> La sesión tiene 12 h, renovada hoy en la mañana.
Con eso ya se puede empezar a diagnosticar sin tener que pedir info de vuelta.
## Comportamiento conocido de la UI de Bucéfalo
Documentado también en `memory/ghl_ui_quirks.md` para sesiones de Claude:
- El builder de workflows carga dentro de un iframe en `client-app-automation-workflows.leadconnectorhq.com`. Cualquier selector debe operar sobre ese frame, no sobre la página principal.
- El listado de workflows está en otro iframe distinto.
- Al cargar el builder, el switch de Publicar/Borrador arranca en `aria-checked=false` y luego cambia cuando el cliente recibe el estado real. Esperar 25-30 s antes de leer.
- El botón "Guardar/Save" dice "Guardado/Saved" por defecto **incluso sin cambios**. No es señal de estado cargado.
- Aparece un modal "AI Builder habilitado" con botón "Entendido" que tapa el switch — hay que cerrarlo preemptivamente, y a veces reaparece tras el click. Por eso el script reintenta el toggle hasta 3 veces.
- Tras "Guardar", GHL puede tardar **hasta 20 s** en propagar el cambio a la API. La validación contra API hace 6 reintentos con backoff incremental.
- A veces se necesita un F5 manual para destrabar bugs visuales — el script lo hace automático tras los primeros reintentos.
@@ -0,0 +1,78 @@
---
id: CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca
fecha: 2026-05-29
categoria: custom_field | cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "yjqKxoO02rsdwdJZSPmD (85950 - MP - Temixco)"]
run_ids: ["4b26f163-f87c-48c5-be53-462b2e6f53da"]
snapshots: []
status: parcial
memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `contacto en Marca sin datos de vehículo`, `customFields count: 0 en Marca`, `réplica Sucursal→Marca sin custom fields`
- `Sincronización Sucursal` (source) + tag `sincronizado-sucursal` con CFs vacíos
- `Cristhian laura ramirez juarez`, `Hugo Lopez`, `Temixco`, `+527772582548`, `+527772162523`
- `nombres de custom field idénticos entre sucursal y Marca` (descarta name-mismatch)
- `Export_Contacts_leads` CSV con columnas de vehículo vacías
## SÍNTOMA
CSV export de Marca: 2 leads de Temixco (`Cristhian` `U9DWipeW9XhQTZZMzFl9`, `Hugo` `bUNqMZaLI1QCn4yQM6qT`)
llegaron a Marca **sin** Versión/Marca/Año de vehículo, Modalidad, Fuente, Sucursal. Edgar (Tampico) sí llegó completo.
## DIAGNÓSTICO (read-only)
1. `mcp get_contact` (cache) Marca → `custom_fields: []`. Cache SQLite con `synced_at` ANTERIOR a la creación → stale, no concluir aún ([[positive_descuadre_stale_cache]]).
2. `search_contacts` por fragmento de teléfono en sucursales de Morelos (777 = Cuernavaca) → ambos en **Temixco** (`yjqKxoO02rsdwdJZSPmD`) con 6-8 CFs completos.
3. Comparación de nombres de contact CFs Temixco vs Marca vs Tampico (tabla `object_schemas`): **idénticos**. → **DESCARTA** la hipótesis de name-mismatch del workflow [1604] (el análisis estático del jsCode sugería divergencia de nombres; es FALSO).
4. GET en VIVO (no cache) de los 4 contactos vía `ghl_client._request('GET', f'/contacts/{id}', token)`:
- Marca (ambos): `customFields=0`, `dateAdded == dateUpdated` (nunca actualizados).
- Temixco (ambos): 8 CFs; `dateUpdated` ~4h después de creación (= workflow [2004] que añadió CANAL DE ORIGEN + TIENDA).
5. Ejecuciones n8n del workflow Sucursal→Marca (`x4DqZ5FtSc43tdzB`) consultadas, pero el set reciente no cubre la ventana de creación real (2026-05-29 ~22:40/22:48 **UTC**); no se obtuvo el log del momento exacto.
## CAUSA RAÍZ
1. **Confirmada:** la réplica Sucursal→Marca creó el contacto en Marca (39-40 s después del de sucursal) **sin copiar ningún custom field**, y no hubo UPDATE posterior.
2. **NO es** divergencia de nombres de esquema (verificado idénticos).
3. **Probable (confianza media):** carrera de tiempo — el flujo leyó el contacto origen antes de que los CFs del formulario estuvieran disponibles. Es intermitente (Edgar/Tampico sí funcionó por el mismo path). Confirmación pendiente requiere log de ejecución n8n del momento de creación.
## ACCIÓN
Backfill manual de 8 CFs Temixco→Marca (mapeo por nombre → `object_schemas` de Marca), confirmado por el owner.
- `run_id=4b26f163-f87c-48c5-be53-462b2e6f53da` (script_audit, reversible). Script inline (no archivo).
- Patrón: `gc._request('PUT', f'/contacts/{cid}', mtok, json={'customFields':[{'id':marca_field_id,'field_value':val}, ...]})`.
- Orden: piloto Cristhian → verificación live (8 CFs) → Hugo.
## VERIFICACIÓN
Antes: ambos Marca `customFields=0`. Después (GET live): ambos `customFields=8` con valores correctos
(Año 2026/2011, Marca ITALIKA/mercedes benz, Versión DM 200/c280, Modalidad Tradicional (Resguardo),
Fuente SUCURSAL/PROSPECCIÓN, Sucursal "Temixco, Morelos", CANAL DE ORIGEN SUCURSAL, TIENDA TEMIXCO).
## EDGE-CASES / TRAMPAS
- **No confiar en la cache SQLite** para concluir CFs vacíos: `synced_at` puede ser anterior a la creación del contacto. Verificar SIEMPRE en vivo antes de backfillear.
- El análisis estático del jsCode del workflow puede mentir: dio "name-mismatch" como causa, falso. Verificar nombres reales en `object_schemas`.
- PUT contacto usa clave `field_value` (no `value`) y mergea CFs (no reemplaza).
- Edgar (Tampico) se omitió a pedido del owner: ya tenía datos completos (control de que el path SÍ funciona a veces).
## REUTILIZABLE
```python
# Nombre de CF -> field_id de una location (tabla object_schemas)
import sqlite3, paths
conn=sqlite3.connect(paths.DB_PATH); conn.row_factory=sqlite3.Row
nameid={}
for r in conn.execute("SELECT field_id,field_name FROM object_schemas WHERE location_id=? AND object_key='contact'",(loc,)):
nameid.setdefault(r['field_name'], r['field_id'])
# GET live + PUT CFs
import sync_engine; from sync_engine import ghl_client as gc
tok={a['location_id']:a for a in sync_engine.parse_accounts_csv()}[loc]['token']
gc._request('PUT', f'/contacts/{cid}', tok, json={'customFields':[{'id':fid,'field_value':v}]})
```
## PENDIENTES
- **Causa raíz n8n al 100%:** revisar ejecuciones de `x4DqZ5FtSc43tdzB` en la ventana 2026-05-29 ~22:40/22:48 UTC
(paginar más atrás con `/api/v1/executions?workflowId=...&includeData=true`) para confirmar carrera vs gap de lógica.
- **Sistémico:** si es carrera, hay riesgo de más contactos Temixco (u otras sucursales) con CFs vacíos en Marca.
Considerar un audit que liste contactos Marca con `source='Sincronización Sucursal'` y `customFields=0` para barrido masivo.
## ENLACES
- Memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]], [[n8n_2004_canal_origen_complemento]]
- Workflow Sucursal→Marca: `x4DqZ5FtSc43tdzB`; helper `scripts/n8n_workflow_lib.py`
- Caso relacionado: [2026-05-29-n8n-2004-canal-origen-tiempo-real.md](2026-05-29-n8n-2004-canal-origen-tiempo-real.md)
@@ -0,0 +1,63 @@
---
id: CASE-2026-05-29-corrector-baserow-verificador
fecha: 2026-05-29
categoria: config_location
location_ids: ["DB63 Baserow tablas 749/750", "WLPVTRxg7W074dfzBxZL (85956 PLAZA EL SALADO)", "nF1uEaYB3mCK5em9bPn2 (Eugenia, E2E)", "pMPs9M4RaGJvWwfIFVIo (Grand Plaza Toluca, creada)"]
run_ids: ["baserow PATCH/POST 749+750 (8 acciones)"]
snapshots: ["generated/migrations/baserow_mesa_control_fix_749_20260529_224430.json", "generated/migrations/baserow_verificador_fix_750_20260529_224430.json"]
status: resuelto
memorias: ["baserow_api_y_corrector", "n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "erandi_intermediaria_mp"]
playbooks: []
---
## TRIGGERS
- `Baserow`, `bsrow.consultoriae3.com`, `token-auth JWT`, `tabla 749`, `tabla 750`, `SC BUCEFALO`
- `Verificador desactualizado`, `n8n no encuentra sucursal`, `match webhook 749 750`
- `PLAZA EL SALADO 85932 85956`, `La Viga 58932`, `Eugenia MP -Eugenia espacio`, `REYNOSA 85958 85970`
- `corrector baserow`, `cuentas_oficiales.csv`, `baserow_client.py`, `fix_baserow_verificador.py`
- `pendientes Erandi SUCURSAL`
## SÍNTOMA
El workflow n8n [2004] no llena sucursal/tienda (ni dispara el complemento de Canal de Origen) para varias sucursales porque el Verificador en Baserow está desactualizado. Confirmado en vivo: Eugenia se cortaba antes del PUT.
## DIAGNÓSTICO
- Acceso a la API de Baserow (read-only) con email/password → JWT. DB 63 "Bucefalo": tabla 749 (Mesa de control: Nombre/Location_ID/API_token) y 750 (Verificador: SUCURSAL/TIENDA/SC BUCEFALO/ID LOCATION BUCEFALO/SC TOKEN BUCEFALO).
- El match del workflow es `webhook location.name → 749.Nombre (7235) → 750.SC BUCEFALO (7247)`. Inspección de ejecuciones n8n (includeData) mostró: para Eugenia el Verificador 750 devolvía 0 filas porque `750.SC BUCEFALO="85974 - MP -Eugenia"` (sin espacio) ≠ `749.Nombre="85974 - MP - Eugenia"`.
- Cruce con la **lista oficial** de cuentas (que el owner proveyó como `n8n/cuentas_oficiales.csv`, name↔location_id) reveló: 1 nombre malo en 749 (PLAZA EL SALADO con `85932`, real `85956`), 5 nombres mal en 750, 1 fila ausente (Grand Plaza Toluca), SUCURSAL vacíos. NOTA: usar la lista oficial, NO `parse_accounts_csv` (su `resolve_location_name` metía ruido, p.ej. doble espacio en Marina Nacional que no existía en GHL).
## CAUSA RAÍZ
Baserow 749/750 con nombres desalineados del nombre real de la cuenta (typos de número, espacios, mayúsculas) → el match exacto del workflow falla y corta el flujo. Filas oficiales ausentes en 750.
## ACCIÓN
Corrector `scripts/fix_baserow_verificador.py` (dry-run→apply, backup previo de 749 y 750). Fuente de verdad: `n8n/cuentas_oficiales.csv` para el nombre; Verificador CSV local para SUCURSAL/TIENDA. 8 acciones aplicadas 2026-05-29 (0 errores):
- PATCH 749.Nombre PLAZA EL SALADO `85932``85956`.
- PATCH 750.SC BUCEFALO: La Viga `58932``85932`, PLAZA EL SALADO `85932``85956`, TAPACHULA `85963-``85963 -`, REYNOSA `85958``85970`, Eugenia espacio.
- PATCH 750.SUCURSAL METEPEC ← `Metepec, Estado de México` (CSV).
- POST 750 fila nueva Grand Plaza Toluca.
Filas 750 NO oficiales (Morelia 3 `rET7...`, segundo Grand Plaza `Xqpdy12...`) NO se tocaron. Cliente reusable `scripts/baserow_client.py`; auditoría `scripts/audit_baserow_verificador.py`.
## VERIFICACIÓN
- Re-auditoría: `749.Nombre != oficial = 0`, `750.SC BUCEFALO != oficial = 0`, ausentes = 0.
- E2E Eugenia: ensuciar canal de un WEB_USER → disparar webhook `8d574598` → en t+5s canal=SUCURSAL, sucursal='Narvarte Oriente, Ciudad de México', tienda='EUGENIA'. El [2004] ahora llega al PUT y el complemento corre.
## EDGE-CASES / TRAMPAS
- NO usar `parse_accounts_csv`/`resolve_location_name` como fuente de nombres (ruido: doble espacios). Usar la lista oficial provista.
- El nombre debe coincidir EXACTO en los 3 puntos (webhook, 749, 750); un espacio o dígito rompe el match.
- SUCURSAL/TIENDA sin fuente NO se inventan → lista para Erandi.
## REUTILIZABLE
```python
from scripts.baserow_client import BaserowClient
c = BaserowClient.from_credentials()
rows = c.list_rows(750) # user_field_names
c.update_row(750, row_id, {"SC BUCEFALO": "..."}, dry_run=True)
```
## PENDIENTES
- **Erandi:** completar en Baserow 750 el `SUCURSAL` de SENDERO, Independencia, Isidro Fabela, Grand Plaza Toluca (+`TIENDA` de Grand Plaza Toluca). Lista: `generated/reports/baserow_pendientes_erandi.json`.
- Decidir si las 2 filas 750 no oficiales (Morelia 3, segundo Grand Plaza) deben borrarse o si esas cuentas se dan de alta oficialmente.
## ENLACES
- Scripts: `scripts/baserow_client.py`, `scripts/audit_baserow_verificador.py`, `scripts/fix_baserow_verificador.py`. Fuente: `n8n/cuentas_oficiales.csv`.
- Backups: `generated/migrations/baserow_*_20260529_224430.json`. Lista Erandi: `generated/reports/baserow_pendientes_erandi.json`.
- Memorias: [[baserow_api_y_corrector]], [[n8n_2004_canal_origen_complemento]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,153 @@
---
id: CASE-2026-05-29-descuadre-opp-multiempeno
fecha: 2026-05-29
categoria: descuadre, config_location, duplicado
location_ids:
- GbKkBpCmKu2QmloKFHy3 # Marca (Monte Providencia)
- jE41bVhhnb5T505BFm4F # 85964 - MP - Morelia 1 (Salvador)
- nF1uEaYB3mCK5em9bPn2 # 85974 - MP - Eugenia (ZONYA/LUIS origen)
- yjqKxoO02rsdwdJZSPmD # 85950 - MP - Temixco (Frankenstein Miguel Angel)
run_ids:
- descuadre_opp_del_20260529_190910 # DELETE ZONYA Wo4MXw
- descuadre_opp_del_20260529_191318 # DELETE LUIS Fv4dLJ
- a37d23ffe6574e0eb2ee8433bce2e1f3 # PUT allowDuplicateOpportunity=true en Marca
- descuadre_create_salvador_* # CREATE opp WON Salvador
snapshots:
- generated/migrations/descuadre_opp_20260529_snapshot.json # las 2 opps borradas + las que se conservan
- generated/migrations/enable_dup_opp_GbKkBpCmKu2QmloKFHy3_20260529_193342.json # settings antes del PUT
- generated/migrations/create_missing_branch_opps_20260529_193836.json # create Salvador
status: resuelto (5 mislinks pendientes de re-enlace)
memorias:
- "[[positive_opp_descuadre_double_replica]]"
- "[[opp_multiplicity_replication_gap]]"
- "[[duplicate_resolution_rules]]"
- "[[create_duplicate_phone_contact_marca]]"
playbooks:
- docs/PLAYBOOK_DESCUADRE.md
---
## TRIGGERS
- `descuadre +1 opp` / `diferencia oportunidades` / dashboard "Marca > sucursales" en opps
- `400 "Can not create duplicate opportunity for the contact"` al crear/replicar una opp
- `allowDuplicateOpportunity` (flag de settings de location)
- multi-empeño "no se replica a Marca" / contacto con 2 opps en sucursal y 1 en Marca
- `link muerto` / `ID Oportunidad Sucursal` apunta a opp borrada (GET da `400 "Opportunity doesn't exist or is deleted"`)
- réplica obsoleta que el n8n no borró tras rotar el id nativo de una opp
- token de agencia / `GHL_AGENCY_TOKEN` / 401 en `PUT /locations`
## SÍNTOMA
Dashboard Comparativa: **+1 en oportunidades** (Marca **1340** vs suma sucursales **1339**). Contactos también +1 (benigno, aparte). Signo **positivo** ⇒ sospechar cache viejo, huérfanos en Marca o doble réplica (ver [[positive_descuadre_stale_cache]]).
## DIAGNÓSTICO
**1. Audit base (read-only, vuelca a archivo):**
```powershell
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
```
Reveló: `diff.opportunities=1`, **`opportunities_in_branch_not_in_brand=0`** (¡nada falta según el audit!), `intra_brand_duplicates=0`. → El +1 es una opp de Marca "de más" que el audit no marca, porque su matching por-contacto la da por replicada.
**2. Matching 1:1 estricto por link** (clave para descuadre POSITIVO — el conteo del dashboard es por FILAS, no por matching):
```python
# agrupar opps de Marca por su CF "ID Oportunidad Sucursal" (resolve_opp_link_field_id)
# 1340 opps Marca = 1334 con link a opp de sucursal existente + 6 con link muerto/vacío
# los 1334 apuntan a solo 1333 opps distintas -> 1 link COMPARTIDO por 2 opps de Marca = +1
```
**3. Atribución EQUIVOCADA #1 (descartada):** el link compartido era `kGda02` (opp MIGUEL ANGEL de Eugenia), reclamado por 2 opps de Marca (`1A3P5b` $80,200 y `1l0S9v` $0). Parecía "doble réplica de Eugenia". **Falso:** al verificar en vivo, eran **dos personas distintas** llamadas Miguel Angel (tel …4949 Temixco vs …4950 Eugenia), cada una con 1 opp en su sucursal y 1 en Marca → **balanceado en filas**. El link compartido era un mislink (Frankenstein), NO la causa del +1.
**4. Matching robusto por phone-de-contacto** (porque los links estaban podridos):
```python
# para cada opp de sucursal, buscar opp de Marca con MISMO phone de contacto (consumir 1:1)
# las opps de Marca sin pareja = sobrantes reales; las de sucursal sin pareja = faltantes
```
Con phone real salieron: sobran **ZONYA `Wo4MXw` ($45k)** y **LUIS `Fv4dLJ` ($0)**; falta **Salvador (+524431452883)**.
**5. Atribución EQUIVOCADA #2 (descartada):** "falta Salvador". **Falso:** Salvador SÍ tiene opp en Marca (`NW09og`). El matching falló porque su `id_contacto_sucursal` estaba podrido.
**6. Verificación en vivo (la que cerró el caso)** — contar opps por contacto y estado de cada link:
```python
# por contacto en Marca: cuántas opps y a qué apunta su "ID Oportunidad Sucursal"
# ZONYA contacto wbUhES: 2 opps -> hrZq7j($60k lost, link OK a Eugenia IO969JW) + Wo4MXw($45k open, link P84gFZ MUERTO)
# LUIS contacto dMAc8A: 2 opps -> ezqhFc($40k lost, link OK s1fA9Wt) + Fv4dLJ($0 open, link 0l0ya7 MUERTO)
# Salvador contacto fpVvOAo: 1 opp NW09og(open, link a emsgo1) ; en Morelia tiene 2 (emsgo1 open + OWGU1u won) -> falta la WON
```
Confirmado en vivo que `P84gFZ` y `0l0ya7` dan `400 "Opportunity doesn't exist or is deleted"` en Eugenia → **links muertos**: la opp de Eugenia se borró+recreó (id rotó), el n8n creó la réplica nueva (`hrZq7j`/`ezqhFc`, ambas `createdAt` hoy) pero **no borró la vieja** → 2 opps obsoletas en Marca.
**Aritmética final del +1:** `+2` (ZONYA+LUIS obsoletas) `1` (Salvador WON faltante, multi-empeño) `= +1`.
## CAUSA RAÍZ
1. **2 réplicas obsoletas en Marca** (ZONYA `Wo4MXw`, LUIS `Fv4dLJ`): el n8n de sync de opps no borra la réplica vieja cuando el id nativo de la opp de sucursal **rota** (borrado+recreación). Quedan con `id_oportunidad_sucursal` apuntando a una opp ya inexistente.
2. **Faltante estructural de multi-empeño** (Salvador): Marca tenía `settings.allowDuplicateOpportunity = false` → GHL rechaza la 2ª opp de un contacto con `400 "Can not create duplicate opportunity for the contact"`. Por eso el n8n solo replica la 1ª opp por contacto. Las sucursales ya tenían el flag en `true`.
## ACCIÓN
1. **DELETE obsoletas** (piloto→1→1, con snapshot + audit). `ghl_client.delete_opportunity` + `script_audit.record_change(object_type='opportunity', field='__deleted__', old=<opp completa>)`. Runs `descuadre_opp_del_20260529_190910` (ZONYA), `…_191318` (LUIS). Tras ZONYA el contador ya fue +1→0; tras LUIS →−1 (destapó el faltante).
2. **Activar el flag** con el **token de agencia** (`GHL_AGENCY_TOKEN` en `.env`; el token PIT por-location da 401 en `PUT /locations`):
```bash
python scripts/enable_duplicate_opportunity.py --apply --json
# PUT /locations/{Marca} body {"settings":{"allowDuplicateOpportunity":true}} (solo ese flag; GHL hace merge)
# headers REQUIEREN User-Agent (sin él, 403). run a37d23ffe6574e0eb2ee8433bce2e1f3. false->true verificado.
```
3. **Crear la opp WON de Salvador** (multi-empeño, ya no da 400):
```bash
python scripts/create_missing_branch_opps_in_marca.py --apply --yes \
--location jE41bVhhnb5T505BFm4F --only-opp OWGU1uPoWvITmwOLIyvq --run-id descuadre_create_salvador_<ts>
```
## VERIFICACIÓN
- Conteo opps Marca: 1340 → (ZONYA) 1339 → (LUIS) 1338 → (+Salvador WON) **1339** = sucursales 1339.
- Audit final: **`diff.opportunities == 0`**.
```python
import json; d=json.load(open('generated/agent/runs/descuadre_audit_final.json',encoding='utf-8-sig'))
print(d['totals']['diff']['opportunities']) # 0
```
- Salvador en Marca: `[('won',15000),('open',15000)]` (espejo de Morelia).
- Réplicas buenas intactas: ZONYA `hrZq7j` $60k lost, LUIS `ezqhFc` $40k lost.
## EDGE-CASES / TRAMPAS
- **El conteo del dashboard es por FILAS, no por matching.** `opportunities_in_branch_not_in_brand=0` puede convivir con un descuadre real (el audit considera "replicada" una opp de sucursal si su contacto tiene CUALQUIER opp en Marca). Cazar el positivo con matching 1:1 estricto por link.
- **Link MUERTO ≠ link vacío ≠ opp ausente.** `create_missing_branch_opps_in_marca.py --all-branches` marcó **6 CREATE**, pero **solo Salvador (`multi=True`) era real**. Las otras 5 (Gerardo, Ernesto, Patricia, Lizeth, Temixco) tenían **1 opp en Marca con link muerto** → crearlas habría hecho **5 DUPLICADOS** (lección Maria/`HR99`). **Regla:** antes de aplicar el barrido, por cada candidata verificar *cuántas opps tiene el contacto en Marca* y el *estado del link* (`VACÍO`/`MUERTO`/`a-otra`=Frankenstein). Si ya tiene réplica ⇒ es RELINK, no CREATE.
- **"El contador en 0 puede ocultar basura."** Antes de tocar nada el descuadre era +1, pero escondía 2 opps fantasma (+2) y un faltante real (1). Defecto numérico ≠ defecto de integridad.
- **El flag `allowDuplicateOpportunity` no quedó activo permanentemente** tras una operación previa (volvió a `false`). Si alguien lo apaga, la replicación n8n de multi-empeños vuelve a fallar con 400.
- **`PUT /locations` necesita token de AGENCIA** (`locations.write`) + header `User-Agent`. El PIT por-location da 401.
- **Identidad podrida engaña al matching:** homónimos con tel casi igual (…4949 vs …4950) parecen duplicados pero son personas distintas; `id_contacto_sucursal` puede estar podrido. Verificar SIEMPRE en vivo por id antes de mutar.
## REUTILIZABLE
```python
# --- Aislar el sobrante real en un descuadre POSITIVO de opps (matching 1:1 estricto por link) ---
import sqlite3; from paths import DB_PATH; import scripts.audit_brand_vs_branches_totals as A
conn=sqlite3.connect(str(DB_PATH)); conn.row_factory=sqlite3.Row
BRAND=A.BRAND_LOCATION_ID; blink=A.resolve_opp_link_field_id(conn,BRAND)
brand=A.load_opps(conn,BRAND)
branch_ids=set()
for r in conn.execute("SELECT location_id FROM accounts"):
if r['location_id'] not in (BRAND,'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'):
branch_ids|={o['id'] for o in A.load_opps(conn,r['location_id'])}
used=set()
for o in brand:
lv=A.extract_opp_link_value(o.get('custom_fields_json'),blink)
if lv and lv in branch_ids and lv not in used: used.add(lv)
else: print('SOBRANTE/mislink:',o['id'],o['name'],lv) # link vacío, muerto o duplicado
# --- Verificar/activar el flag (dry-run sin --apply) ---
# python scripts/enable_duplicate_opportunity.py # dry-run
# python scripts/enable_duplicate_opportunity.py --apply # activa (token agencia en .env)
# python scripts/enable_duplicate_opportunity.py --disable --apply # rollback del flag
# python scripts/check_allowDuplicate_settings.py # verificación read-only multi-cuenta
# --- Estado de un link en vivo (muerto?) ---
# GET /opportunities/{id} con Version 2021-07-28 + User-Agent -> 400 "doesn't exist or is deleted" = MUERTO
```
## PENDIENTES
**5 mislinks (calidad de dato, NO afectan el conteo)** — réplicas en Marca con `id_oportunidad_sucursal` muerto/mal; re-enlazar (PUT del CF) sin crear duplicados:
- Gerardo (`Bj2bIN` → debe apuntar a Morelia `x3AXkY`)
- Ernesto (`UNtCRNQ``5kDn6b`)
- Patricia (`OGQtfmjF``zzBzWC`)
- Lizeth (`j0iKZo``LGSPKo`)
- **Miguel Angel Temixco (`1A3P5b`)**: linkea a Eugenia `kGda02` (Frankenstein) y trae valor $80,200 en vez de $56,671 (Temixco `OQBrOQN9`) → re-enlazar **y** corregir valor.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[opp_multiplicity_replication_gap]], [[duplicate_resolution_rules]], [[create_duplicate_phone_contact_marca]], [[positive_descuadre_stale_cache]]
- Playbook: [docs/PLAYBOOK_DESCUADRE.md](../PLAYBOOK_DESCUADRE.md)
- Scripts: [scripts/enable_duplicate_opportunity.py](../../scripts/enable_duplicate_opportunity.py), [scripts/create_missing_branch_opps_in_marca.py](../../scripts/create_missing_branch_opps_in_marca.py) (flag `--all-branches`), [scripts/audit_brand_vs_branches_totals.py](../../scripts/audit_brand_vs_branches_totals.py), [scripts/check_allowDuplicate_settings.py](../../scripts/check_allowDuplicate_settings.py)
- Artefactos: snapshots en `generated/migrations/` (ver frontmatter); audits en `generated/agent/runs/descuadre_audit_*.json`
@@ -0,0 +1,69 @@
---
id: CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real
fecha: 2026-05-29
categoria: cascada_n8n
location_ids: ["todas las sucursales (workflow n8n compartido)", "nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, gap Baserow)", "uJEn2iuUficuml9zxAnt (85976 - MP - Cancún, E2E ok)"]
run_ids: ["n8n workflow ddUEORBEtZLzsQF2 versionId 6e9a405c→069558e3"]
snapshots: ["n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json"]
status: resuelto
memorias: ["n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "createdby_only_in_individual_get", "erandi_intermediaria_mp", "feedback_dry_run_protocol"]
playbooks: []
---
## TRIGGERS
- `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `actualizar contact.sucursal contact.tienda`
- `Canal de Origen tiempo real`, `complemento n8n canal origen`
- `Verificador 750 devuelve 0`, `Baserow 750 no encuentra sucursal`, `flujo se corta antes del PUT`
- `Eugenia no está en Baserow 750`, `sucursal renombrada Baserow desincronizado`
- `createdBy distinto por ejecución`, `esUsuario IF n8n`
- `n8n executions API includeData`, `verify_post versionId`
## SÍNTOMA
El batch `fix_branch_user_origin.py` corrige el backlog, pero los contactos NUEVOS creados por usuario en sucursal siguen naciendo sin Canal de Origen. Se pide complementar el workflow en tiempo real [2004] (corre al crear contacto) para que ponga Canal de Origen=SUCURSAL solo a los creados por usuario.
## DIAGNÓSTICO
1. Estructura del [2004] (GET read-only vía `n8n_workflow_lib`): flujo lineal Webhook→Datos de Lead→Omitir @ezcorp→Baserow 749 (cuentas)→Datos API→GET /contacts/{id}→GET /locations/{id}/customFields→Code (resuelve sucursal/tienda por fieldKey)→Baserow 750 (Verificador)→PUT /contacts/{id} (sucursal+tienda). Nodo huérfano `...SUCURSAL1` (POST opportunities/search) sin entrada = código muerto.
2. `GET /contacts/{id}` SÍ devuelve `createdBy.source` (ver [[createdby_only_in_individual_get]]).
3. fieldKey de "CANAL DE ORIGEN" = `contact.fuente_de_posible_cliente` (heredado), consistente en 4 sucursales muestreadas, picklist incluye SUCURSAL. Resolver por nombre con fallback a fieldKey.
## CAUSA RAÍZ
1. (gap a cerrar) Ni el [2004] ni el workflow nativo de GHL escribían Canal de Origen del contacto.
2. (hallazgo) **Baserow tabla 750 (Verificador) desincronizado:** el [2004] busca por `location.name` (field 7247); si la sucursal no está con su nombre actual, devuelve 0 filas y **el flujo se corta ANTES del PUT** (sucursal/tienda y canal). Confirmado en Eugenia "85974 - MP - Eugenia" (renombrada, ver [[erandi_intermediaria_mp]]). El batch usa el Verificador CSV LOCAL (sí la tiene) → no se notaba.
## ACCIÓN
Edición del workflow [2004] vía `scripts/n8n_workflow_lib.py` con `n8n/_add_canal_origen_branch.py` (idempotente, backup→dry-run→apply→verify):
- Extiende el Code node: resuelve `canal` (por fieldKey/nombre) y expone `createdBySource`/`esUsuario` leyendo `$('Obtener Contacto Cuenta Origen - SUCURSAL').item.json.contact.createdBy.source`.
- Añade tras el PUT sucursal: IF "Creado por usuario" → [true] PUT Canal de Origen=SUCURSAL → Tag+ sucursal → Tag- formulario → Tag- facebook-ads (DELETEs con `onError: continueRegularOutput`). [false]=fin.
- NO toca Fuente de Prospecto. Workflow desactivado/reactivado para el PUT estructural. versionId 6e9a405c→069558e3, 14→19 nodos.
## VERIFICACIÓN
E2E disparando el webhook de producción (`https://webhookn8.consultoriae3.com/webhook/8d574598-...`) con `{contact_id,email,location:{name}}` + inspección de ejecuciones vía `GET /api/v1/executions/{id}?includeData=true`:
- Eugenia (85974): Verificador 750 devolvió **0 filas** → flujo cortado antes del PUT → Code esUsuario=True correcto pero IF no corre. (Confirma el gap Baserow, no falla del cambio.)
- **Cancún (85976, sí está en 750):** contacto WEB_USER ensuciado a FORMULARIO → webhook → en t+5s canal=**SUCURSAL**, tags=['sucursal']. ✓
- INTEGRATION (solana, Eugenia): Code esUsuario=False → no toca canal (FACEBOOK intacto). ✓
- JUAN CARLOS (ensuciado en Eugenia para la 1ª prueba) restaurado manualmente a SUCURSAL.
## EDGE-CASES / TRAMPAS
- Dos webhooks casi simultáneos → execs consecutivas (52760/52761); confirmar `body.contact_id` por ejecución antes de concluir.
- El mismo contact_id devuelve `createdBy` correcto con el token de la sucursal; el "INTEGRATION" que vi al inicio era de la ejecución del OTRO contacto (confusión de execs), no del token.
- Probar el E2E SOLO en sucursales presentes en Baserow 750 (Cancún sí; Eugenia no).
- DELETE de tags inexistentes: usar `onError: continueRegularOutput` para no romper el flujo.
## REUTILIZABLE
```python
# Inspeccionar ejecuciones n8n por nodo (qué corrió, outputs):
from scripts.n8n_workflow_lib import load_credentials, N8NClient
c=N8NClient(*load_credentials())
st,data=c._request('GET','/api/v1/executions/<EXID>?includeData=true')
rd=data['data']['resultData']['runData'] # {nodeName: [{data:{main:[[items]]}}]}
```
## PENDIENTES
- [HECHO 2026-05-29] **Baserow 749/750 corregido** con el corrector automático — ver [[CASE-2026-05-29-corrector-baserow-verificador]] / [[baserow_api_y_corrector]]. Eugenia y los demás nombres ya alinean; E2E OK. Solo queda que Erandi complete SUCURSAL/TIENDA sin fuente (4 sucursales) — `generated/reports/baserow_pendientes_erandi.json`.
- [HECHO] Agendado: Tarea Windows "MP Origen Check" (diaria 07:00) corre el dry-run y deja alerta en `generated/runtime/origen_check_alert.json`; el owner aplica desde el dashboard. Ver [[origen_check_agendado]].
## ENLACES
- Script mutación: `n8n/_add_canal_origen_branch.py`; lib `scripts/n8n_workflow_lib.py`.
- Batch: `scripts/fix_branch_user_origin.py`.
- Backup/rollback: `n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json`.
- Memorias: [[n8n_2004_canal_origen_complemento]], [[super_script_fix_branch_user_origin]], [[createdby_only_in_individual_get]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,88 @@
---
id: CASE-2026-05-29-origen-sucursal-contactos-usuario
fecha: 2026-05-29
categoria: custom_field
location_ids: ["nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, piloto)", "todas las sucursales productivas (47, batch); excluye Marca GbKkBpCmKu2QmloKFHy3 y demos Vf7qQl3L9vakJ8hDtQ8e / Z64WQKORPVwXb5mn68Ef"]
run_ids: ["fbuo-cc20241b7a6f (piloto Eugenia)", "fbuo-batch-8c31110b2d (batch 47 sucursales)"]
snapshots: []
status: resuelto
memorias: ["createdby_only_in_individual_get", "super_script_fix_branch_user_origin", "feedback_dry_run_protocol", "name_account_with_location_id"]
playbooks: []
---
## TRIGGERS
- `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `INTEGRATION`
- `contactos creados por usuario`, `canal de origen sucursal`, `origen sucursal`
- `createdBy no viene en el listado`, `GET /contacts/ omite createdBy`
- `fix_web_user_branch_contacts roto`, `siempre detecta 0`
- `fix_branch_user_origin.py`, `super script origen sucursal`
- `Fuente de Prospecto ALIANZA`, `PROSPECCIÓN`, `no sobrescribir Fuente de Prospecto`
- `tag formulario -> sucursal`, `etiqueta de origen única`
- `Canal de Origen de la Oportunidad = Sucursal`
## SÍNTOMA
Los contactos creados a mano en una sucursal (por un empleado) no traen canal de origen confiable: el campo nativo `source` llega vacío/None y no indica "sucursal". Esto ensucia el CF `Canal de Origen` (contacto y opp) y las etiquetas de origen. Objetivo: identificar los contactos creados 100% por usuario en sucursal y dejarlos con origen = Sucursal (CF + tag), propagando a sus oportunidades. Solo sucursales (no Marca, no demos).
## DIAGNÓSTICO
Pasos read-only (todos con el helper `tag_canal_origen_workflow`):
1. Primer dry-run del super script leyendo `createdBy` del **listado**`WEB_USER a corregir: 0`. Distribución: `(vacío): 129`. Sospecha: el listado no trae `createdBy`.
2. Comparación listado vs GET individual de un contacto:
- Listado `GET /contacts/`: keys incluyen `source` (=None), `attributions`, pero **NO** `createdBy`.
- Individual `GET /contacts/{id}`: trae `createdBy = {source: 'WEB_USER', sourceName: 'EUGENIA- 85974 MP', channel: 'APP', ...}` y `attributionSource = {medium: 'manual', sessionSource: 'CRM UI'}`.
3. Muestra de 20 GETs individuales en Eugenia: 3 WEB_USER + 17 INTEGRATION → el criterio discrimina perfecto. Proxy del listado: WEB_USER ≈ `attributions[0].medium == 'manual'`.
4. Audit log oficial del CRM para `maMw3C8QmhGVChRqL36y` (JUAN CARLOS RAMIREZ): "Action: Created, Modified by: Web user" → coincide con `createdBy.source == WEB_USER`.
5. Schema de contacto Eugenia (resolución de campos por alias, correcta):
- `Canal de Origen``KLEZyRNR0jrldccerErV` (name real "CANAL DE ORIGEN")
- `Fuente de Prospecto``QN1BNTKgCzcSOHa2wSZc`
- `Sucursal``pmrGTW3tIa7oz7rQJMVx`, `TIENDA``H3g8J4NbgbcM4glyW9GZ`
6. Distribución de valores en Eugenia (SQLite): `Canal de Origen` {SUCURSAL 84, FORMULARIO 28, FACEBOOK 14, vacío 3}; `Fuente de Prospecto` {SUCURSAL 84, LEAD DIGITAL 42, **ALIANZA 2**, **PROSPECCIÓN 1**}. → `Fuente de Prospecto` contiene valores de negocio que NO deben pisarse.
## CAUSA RAÍZ
1. **`createdBy` solo viene en el GET individual** del contacto; el listado paginado lo omite (ver [[createdby_only_in_individual_get]]). El script previo `scripts/fix_web_user_branch_contacts.py` lo leía del listado → roto silenciosamente (siempre 0).
2. Los contactos creados por empleado quedan con `createdBy.source` ∈ {`WEB_USER` (UI web), `MOBILE_USER` (app móvil)}; los replicados desde Marca por n8n quedan `INTEGRATION`.
## ACCIÓN
Super script nuevo `scripts/fix_branch_user_origin.py` (registrado en SCRIPTS_METADATA como "Origen Sucursal (contactos creados por usuario)"). Ver [[super_script_fix_branch_user_origin]]. Orden contacto→opp:
- Contacto: tag único `sucursal` (quita `formulario`/`facebook-ads`), `Canal de Origen` = SUCURSAL. Si falta `Sucursal`/`TIENDA`, se completan desde el Verificador CSV (`load_verifier_map`).
- TODAS las opps del contacto: `Canal de Origen de la Oportunidad` = Sucursal + propaga `Sucursal`/`TIENDA`.
- **NO toca `Fuente de Prospecto`** (decisión del owner: preserva ALIANZA/PROSPECCIÓN). No sincroniza a Marca.
Protocolo dry-run → piloto → batch ([[feedback_dry_run_protocol]]):
```
# Dry-run (Fase 1):
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2
# Piloto:
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2 --apply --run-id fbuo-cc20241b7a6f
# Batch:
python scripts/fix_branch_user_origin.py --all --apply --run-id fbuo-batch-8c31110b2d
```
## VERIFICACIÓN
- Piloto Eugenia (run `fbuo-cc20241b7a6f`, success): 89 creados por usuario, 6 contactos, 98 opps. JUAN CARLOS post-apply en vivo: tags=['sucursal'], Canal de Origen='SUCURSAL', Sucursal='Narvarte Oriente, Ciudad de México', TIENDA='EUGENIA', **Fuente de Prospecto='ALIANZA' intacto**; su opp Tpd964ztTwgNf1ipL5NC con Canal de Origen de la Oportunidad='Sucursal' + Sucursal propagado.
- Consistencia Sucursal: 126/129 contactos ya tenían 'Narvarte Oriente, Ciudad de México'; los 3 vacíos quedaron con el MISMO valor del Verificador. Sin divergencia.
- Batch (run `fbuo-batch-8c31110b2d`, success): 359 creados por usuario detectados en 47 sucursales, 48 contactos + 273 opps corregidos, 0 errores. Auditoría: 110 cambios contact + 284 opp, todos `applied`.
## EDGE-CASES / TRAMPAS
- **No leer `createdBy` del listado** → siempre 0. Hay que GET individual por contacto (costoso pero fiel; el dashboard paraleliza por sucursal).
- **No sobrescribir `Fuente de Prospecto`**: contiene ALIANZA/PROSPECCIÓN (valores de negocio), no solo SUCURSAL/LEAD DIGITAL.
- Incluir **MOBILE_USER** además de WEB_USER (ambos = creación manual por empleado).
- 8 sucursales tenían **0 contactos** (no tocadas) y 5 tienen **Verificador con Sucursal vacía** + 2 **no están en el Verificador**: si más adelante reciben contactos creados por usuario sin Sucursal, no se autocompletará hasta corregir el Verificador.
## REUTILIZABLE
```python
# createdBy SOLO en GET individual:
full = ghl_request("GET", f"/contacts/{cid}", token); inner = full.get("contact") or full
src = (inner.get("createdBy") or {}).get("source") # WEB_USER | MOBILE_USER | INTEGRATION
```
## PENDIENTES
- Corregir el Verificador para las sucursales con Sucursal vacía / ausentes (Isidro Fabela, SENDERO, Grand Plaza, Independencia, Morelia 3, + las 2 ausentes) por si reciben contactos creados por usuario.
- Confirmar si las 8 sucursales con 0 contactos es esperado (sucursales nuevas) o falta sync/acceso.
- Identificación de origen Facebook Ads / formulario en sucursal (fuera de alcance de este caso).
## ENLACES
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[feedback_dry_run_protocol]], [[name_account_with_location_id]]
- Scripts: `scripts/fix_branch_user_origin.py`, helpers de `scripts/tag_canal_origen_workflow.py`, `scripts/fill_sucursal_tienda_from_location.py` (`load_verifier_map`)
- Roto/superado: `scripts/fix_web_user_branch_contacts.py`
- Logs: `generated/logs/fbuo_batch_8c31110b2d.log`
@@ -0,0 +1,79 @@
---
id: CASE-2026-05-29-tienda-vacia-formulario-sitio-web
fecha: 2026-05-29
categoria: custom_field | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "rQYjjwsGnjEGagskOxix (85930 TULYEHUALCO)", "nRSeOhlhQ3vyirTKYhPi (85961 VILLAS DEL SOL)", "blRZ21GlzgUCA7bl2uVw (85975 Querétaro)", "R34lUVVpltnB8Z1RqnEB (85971 Satélite)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares)"]
run_ids: ["26217ad9-934f-40d5-af69-bd0cbb5c02e4 (backfill TIENDA)", "bb27026c-1d99-458d-be1d-d34b7498b1a4 (delete Guco)"]
snapshots: []
status: parcial
memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]], [[name_account_with_location_id]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `TIENDA vacía`, `campo TIENDA empty`, `lead sin tienda`, `Formulario - Sitio Web TIENDA`
- `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `SUCURSAL contains ambiguo`
- `Toluca Estado de México 3 filas`, `Metepec 3 filas`, `750 filas duplicadas SUCURSAL`
- `createdBy INTEGRATION OAUTH`, `source Formulario - Sitio Web`, `CANAL DE ORIGEN FORMULARIO`
- contactos: `Juan Carlos espiritu`, `Adrian Garza`, `Gerardo Juárez`, `Luis Fernando mejía`, `Jesús Niño`, `Jorge Erick hernandez`, `Miguel Velasquez`, `Jorge Enrique ibarra`, `Guco Aseram`
## SÍNTOMA
Export de Marca: 9 leads `source="Formulario - Sitio Web"` con campo **TIENDA vacío**. Hipótesis inicial del owner: "creados antes de la optimización n8n". FALSA (ver causa).
## DIAGNÓSTICO (read-only)
1. GET live (no cache) de los 9 en Marca → TIENDA vacía confirmada; 8 traen `ID Contacto Sucursal`.
2. Ubicación de la sucursal por `ID Contacto Sucursal` (tabla `contacts` cache) + GET live → **TIENDA vacía TAMBIÉN en la sucursal** (no es gap de replicación; nunca se asignó en origen).
3. `createdBy.source=INTEGRATION` / `Formulario - Sitio Web` → ni [2004] ni `fix_branch_user_origin.py` los cubren (esos = WEB_USER/MOBILE_USER).
4. Conteo global: de **175** contactos `Formulario - Sitio Web`, solo **16 (9%)** con TIENDA vacía = estos 8 × 2 (Marca+sucursal). El 91% sí la tiene → NO sistémico por source.
5. TIENDA canónica por sucursal (consenso 85-119 contactos): se deriva del **branch físico**, no del texto `Sucursal`. (`object_schemas` + `contacts.custom_fields_json`.)
6. Workflow Marca→Sucursal V2 `4UMRwxJdHFfOGHBp` (webhook formulario): nodo Baserow `Obtener Info de cuenta objetivo - SUCURSAL` filtra **tabla 750, field 7240 (SUCURSAL), operador `contains` = `$json.Contacto.Sucursal`**, y hay IF **`Si hay más de un resultado`**.
7. Test directo Baserow 750 (`baserow_client.BaserowClient.from_credentials`, `list_rows(750)`):
- `Toluca, Estado de México`**3 filas** (SC `85935 - MP - Pilares`, TIENDA GRAND PLAZA / ISIDRO FABELA / INDEPENDENCIA).
- `Metepec, Estado de México`**3 filas** (Pilares/PILARES + 2× METEPEC/METEPEC dup).
- Tulyehualco / Playa del Carmen / Querétaro / Satélite → **1 fila** (resuelven bien).
## CAUSA RAÍZ (doble)
1. **Determinística (Luis Fernando=Toluca, Jorge Enrique=Metepec):** Baserow 750 tiene filas **ambiguas/duplicadas** donde el mismo `SUCURSAL` mapea a múltiples TIENDA → el filtro `contains` devuelve >1 → IF `Si hay más de un resultado` → el flujo no asigna TIENDA. **Recurrirá** en todo lead Toluca/Metepec hasta limpiar 750.
2. **Transitoria (los otros 6, match único en 750):** debían resolver; TIENDA vacía = fallo puntual de ejecución. Confirmación 100% requeriría logs n8n del momento (probablemente no retenidos).
- **DESCARTADO:** "pre-optimización" (7 de 8 son 2026-05-29, post-fix 05-28) y "name-mismatch de esquema".
## ACCIÓN
1. Backfill TIENDA (sucursal + Marca), valor = TIENDA canónica del branch físico, `run_id=26217ad9-934f-40d5-af69-bd0cbb5c02e4`:
TULYEHUALCO (Juan Carlos, Jorge Erick), VILLAS DEL SOL (Adrian), QUERETARO (Gerardo, Miguel), SATELITE (Jesús), PILARES (Luis Fernando, Jorge Enrique). 16 PUTs, piloto Juan Carlos → verificado → lote.
- Nota: a Luis Fernando/Jorge Enrique se les puso **PILARES** (consenso de su branch real), que es más correcto que las filas rotas de Baserow.
2. Delete contacto huérfano **Guco Aseram** `HEb1qBGilEReVITtq0GZ` (Marca; sucursal Villahermosa ya no existe, sin contraparte). `run_id=bb27026c-1d99-458d-be1d-d34b7498b1a4`. DELETE → `succeeded:true`; GET posterior → HTTP 400 (gone).
## VERIFICACIÓN
- Backfill: GET live de los 16 → TIENDA = valor esperado (16/16 OK).
- Delete: GET `/contacts/HEb1qBGilEReVITtq0GZ` → HTTP 400.
- Pendiente re-sync de las 5 sucursales + Marca para que la cache refleje (la verdad viva ya es correcta).
## EDGE-CASES / TRAMPAS
- TIENDA se deriva del **branch físico**, NO del texto libre `Sucursal` (Luis Fernando: Sucursal="Toluca" pero branch=Pilares → TIENDA=PILARES).
- Verificar SIEMPRE en vivo: la cache puede no reflejar asignaciones recientes.
- Baserow 750: el filtro del workflow es `contains` sobre `SUCURSAL` (field 7240), distinto del match por `SC BUCEFALO` (7247) que usa el corrector. Las filas ambiguas rompen este path aunque el corrector de nombres esté OK.
## REUTILIZABLE
```python
# Test de ambigüedad Baserow 750 por SUCURSAL (lo que ve el workflow)
from scripts.baserow_client import BaserowClient
c=BaserowClient.from_credentials(); rows=c.list_rows(750)
hits=[r for r in rows if r.get('SUCURSAL') and 'Toluca, Estado de México'.lower() in str(r['SUCURSAL']).lower()]
# >1 hit => el lead de esa Sucursal NO recibirá TIENDA
```
```python
# TIENDA canónica de un branch (consenso de sus contactos)
from collections import Counter; import json
# parse contacts.custom_fields_json, contar valores del field_id de TIENDA del branch
```
## PENDIENTES
- **Limpiar Baserow 750:** deduplicar / desambiguar filas con mismo `SUCURSAL` y distinta TIENDA (Toluca→3, Metepec→3). Mientras existan, los leads Toluca/Metepec seguirán sin TIENDA. Evaluar extender el corrector (`fix_baserow_verificador.py`) o añadir desambiguación en el workflow (`Si hay más de un resultado`).
- **6 casos transitorios:** si recurren, revisar ejecuciones de `4UMRwxJdHFfOGHBp` en la ventana de creación.
- **Sucursal text de Luis Fernando** ("Toluca") inconsistente con su branch (Pilares/Metepec). No tocado; reportado.
- Re-sync de las 5 sucursales + Marca.
## ENLACES
- Memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]]
- Workflow: `4UMRwxJdHFfOGHBp` (Marca→Sucursal V2); helper `scripts/n8n_workflow_lib.py`, `scripts/baserow_client.py`
- Caso relacionado: [2026-05-29-corrector-baserow-verificador.md](2026-05-29-corrector-baserow-verificador.md)
@@ -0,0 +1,100 @@
---
id: CASE-2026-05-30-comparativa-auditoria-completa-buckets
fecha: 2026-05-30
categoria: descuadre | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "uZnMH5bO6MXTHcgHeyq9 (85935 - MP - Pilares, hub)", "NSDniGzjxotVDNa5YxqW (85937 - MP - METEPEC, shell)"]
run_ids: ["45554292-1b2d-491c-8107-b0ebf81c0b86 (cleanup Patricia)", "fill-temixco-20260530", "bf-cristhian-20260530", "bf-hugo-20260530", "isai-tienda-20260530", "isai-opp-20260530", "fix-identity-collisions-20260530 (sarahi name + miguel TIENDA)", "fix-sarahi-tienda-20260530", "cleanup link 9i1rDQa (isai dup rpDH)", "create-miguel-brand-opp-20260530"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_122326.json", "generated/migrations/fix_identity_collisions_20260530_130329.json", "generated/migrations/create_miguel_brand_opp_20260530_132718.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[verificador_tipo_de_tienda_colapso]]", "[[positive_descuadre_stale_cache]]", "[[audit_hub_map_metepec_pilares]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md"]
---
## TRIGGERS
- `Comparativa Marca vs Sucursales sigo viendo discrepancia`
- `auditar por completo los buckets`, `optimizar toda la sección Comparativa`
- `opps +1 descuadre positivo`, `opportunities_in_brand_duplicate_link 2`
- `contacts_in_brand_present_in_other_branch_not_assigned 84 falsos positivos`
- `TIENDA=METEPEC vive en Pilares 85935`, `Metepec 85937 vacío hub digital`
- `audit lee Verificador CSV no Baserow`, `falta fila METEPEC→85935 digital`
- `DIGITAL_HUB_BY_SHELL`, `hub-map en código audit_brand_vs_branches_totals`
- `PATRICIA PARRA NAVARRO zzBzWC4adBrdTA8WhQph`, `réplica abandoned $0 updatedAt==createdAt`
## SÍNTOMA
Comparativa del dashboard: contactos cuadraban (diff 0) pero **opps +1** (Marca 1341 vs sucursales 1340). El usuario reportó discrepancia persistente y pidió auditar TODOS los buckets + optimizar la sección. Cache fresco (sync 2026-05-30 11:1811:19).
## DIAGNÓSTICO
1. Frescura de cache: `sync_logs` → todas sincronizaron 11:1811:19. No es stale.
2. Audit completo a JSON (read-only):
```
python scripts/audit_brand_vs_branches_totals.py --json > generated/agent/runs/descuadre_audit_20260530.json
# OJO: el redirect en Windows escribe cp1252, no utf-8. Leer con .decode('cp1252').
```
3. Buckets con items: `opportunities_in_brand_duplicate_link=2`, `present_in_other_branch_not_assigned=84`, `contacts_missing_id_field=4`, `without_tienda=2`, `in_branch_not_in_brand=1`, `not_in_any_branch=1`, `opportunities_missing_id_field=1`. Resto 0.
4. **El +1 de opps** = bucket `opportunities_in_brand_duplicate_link`: 2 opps de Marca con el MISMO link `zzBzWC4adBrdTA8WhQph` (Morelia 1). `extra_opps=1`.
5. **El bucket 84** descompuesto por (expected→actual): **82/84 = TIENDA=METEPEC esperado 85937, actual Pilares 85935.** Verificador CSV mapea METEPEC→85937 (vacío, físico), pero los leads digitales viven en el hub Pilares. Las otras tiendas del cluster (GRAND PLAZA/ISIDRO/SENDERO) SÍ tienen fila digital →85935; METEPEC no. → falsos positivos.
6. Los contactos cuadraban a 0 **enmascarando** 2 problemas que se cancelan: `in_branch_not_in_brand=1` (sarahi sarabia, real) + `not_in_any_branch=1` (test21 harness, prueba).
## CAUSA RAÍZ
1. **+1 opps**: réplica duplicada en Marca (n8n `Cfgwp0bOtDW8zuKW` hizo CREATE en vez de UPDATE por carrera de indexado). La sobrante: `sAxBY01AQNwSr0OExQof` *abandoned* $0 created==updated 2026-05-30 03:02, colgada de contacto distinto (edgar morales). Mismo patrón que [[positive_opp_descuadre_double_replica]].
2. **84 ruido**: el audit lee el Verificador CSV, que NO codifica la consolidación de hub (Toluca/Metepec/Lerma→Pilares 85935) que Baserow 750 sí tiene ([[verificador_tipo_de_tienda_colapso]]). Falta la fila digital METEPEC→85935.
## ACCIÓN
**A) Optimización del audit (código, sin tocar Bucéfalo)** — autorizada "hub-map en código":
- En `scripts/audit_brand_vs_branches_totals.py`: constante `DIGITAL_HUB_BY_SHELL` (shell loc → hub Pilares `uZnMH5bO6MXTHcgHeyq9`) + check en el bucket `present_in_other_branch`: si la sucursal asignada es shell de un hub y el contacto está en el hub, se considera bien asignado.
- Resultado: bucket 84 → **2** (los 2 reales: luis fernando tienda=PILARES quirk; miguel angel EUGENIA→Temixco).
**B) Corrección +1 opps** — autorizada "borrar opp duplicada":
```
# dry-run (verifica en vivo, snapshot):
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link zzBzWC4adBrdTA8WhQph
# apply:
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --only-link zzBzWC4adBrdTA8WhQph
# run_id=45554292-1b2d-491c-8107-b0ebf81c0b86 borró sAxBY01AQNwSr0OExQof (conservó OGQtfmjFk31M5eXKDBpO open $70k)
# re-sync Marca para refrescar cache:
python -c "import sync_engine as se; a=next(x for x in se.parse_accounts_csv() if x['location_id']=='GbKkBpCmKu2QmloKFHy3'); se.sync_account(a['location_id'], a['token'])"
```
**C) Pendientes (2ª tanda, misma sesión)** — todos aplicados con dry-run+snapshot+audit:
- `fill_contact_id_sucursal.py --location Temixco --apply`: cristhian/hugo lado sucursal.
- `backfill_brand_contact_id_sucursal.py --only-contact <U9DWipe|bUNqMZaL> --apply`: cristhian/hugo lado Marca.
- `fix_brand_tienda_from_sucursal.py --only-contact JV9g9tWO --apply`: isai TIENDA→ECATEPEC (reporta "Errores:1" cosmético al persistir local; el PUT a GHL sí aplica, verificado en vivo).
- `backfill_opp_sucursal_link.py --only-opp 0eYLJ6 --apply`: enlazó la opp huérfana de isai → **destapó** que isai tenía 2 réplicas en Marca (rpDH+0eYLJ) de 1 opp en Ecatepec.
- `cleanup_brand_duplicate_replica_opps.py --only-link 9i1rDQa --apply`: borró rpDH (réplica nueva), conservó 0eYLJ (original, más antigua). → destapó el faltante 1.
- **sarahi** (colisión de teléfono con "luis enrique suchil"): el contacto Marca m6QK tenía email+opp de sarahi pero nombre de luis → UPDATE firstName/lastName→"Sarahí Sarabia" (NO sync, evita 3er duplicado) + TIENDA ATLACOMULCO→ATIZAPAN. jBaK (luis real) intacto.
- **miguel** (TIENDA EUGENIA→TEMIXCO) + el faltante 1 era su empeño físico Temixco OQBrOQN9 ($56,671) sin réplica. NO es multi-empeño: son 2 opps DISTINTAS (empeño físico ene-08 $56,671 vs lead digital Marca abr-24 $80,200 sin link, CF vacíos). Se **creó** réplica en Marca (8HITkGkOn3gN23Tl8LBr, con link a OQBrOQN9) SIN tocar la digital — script ad-hoc reusando `resolve_brand_pipeline_and_stage`+`create_opportunity`; el link se setea con PUT separado (el POST no guarda customFields). Gotcha: tras el PUT el GET inmediato da link=None (latencia indexado GHL); re-sync confirma válido.
- **luis fernando** (TIENDA=PILARES, vive en Pilares): se agregó 85940 Isidro Fabela (0 contactos) a `DIGITAL_HUB_BY_SHELL` → resuelto.
## VERIFICACIÓN
| | Inicial | Tras 1ª tanda | Final |
|---|---|---|---|
| Contactos diff | 0 | 0 | **0** |
| Opps diff | **+1** | 0 | **0** (1340=1340) |
| `opportunities_in_brand_duplicate_link` | 2 | 0 | 0 |
| `present_in_other_branch_not_assigned` | 84 | 2 | **0** |
| `contacts_missing_id_field` | 4 | 4 | **0** |
| `opportunities_missing_id_field` | 1 | 1 | **0** |
| `contacts_in_branch_not_in_brand` | 1 | 1 | **0** |
| Items accionables totales | ~95 | — | **2 (solo test21, fantasma)** |
> Lección clave: el "diff 0" inicial de opps era engañoso — enmascaraba el duplicado de isai (+1) contra el faltante de miguel (1). El faltante NO aparecía en `opportunities_in_branch_not_in_brand` (=0) porque el bucket matchea por contacto y miguel ya tenía una opp en Marca (gap multi-opp). Se cazó con un diff 1:1 de links sucursal↔Marca (parsear `fieldValueString`, no `value`).
## EDGE-CASES / TRAMPAS
- El redirect `> file.json` en Windows NO escribe utf-8; el JSON sale cp1252. Decodificar con `cp1252`.
- `sync_account(location_id, token)` — son DOS args posicionales, no el dict de cuenta.
- El audit lee SQLite (cache). Tras un DELETE en GHL hay que **re-sync de la location** antes de re-auditar o el número no cambia.
- El hub-map mapea varios shells→Pilares pero solo METEPEC generaba falsos positivos (los demás ya resolvían al hub vía CSV o tienen 0 contactos). Mapearlos todos es inocuo y a prueba de cambios de orden en el CSV.
## REUTILIZABLE
- Descomponer cualquier bucket grande por (expected_branch → actual_branch) con Counter antes de concluir "error de datos": muchas veces es mapeo del Verificador, no datos.
- `cleanup_brand_duplicate_replica_opps.py --only-link <link>` es el camino seguro para el descuadre positivo de opps por doble réplica (verifica en vivo + snapshot + script_audit).
## PENDIENTES
- **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice. Sigue en `without_tienda` + `not_in_any_branch`.
- **Lead digital de miguel** (opp Marca 1A3P5b $80,200, sin link): es un lead digital creado directo en Marca que nunca bajó a su sucursal (Temixco). Idealmente lo baja la cascada n8n Marca→Sucursal. Queda solo-Marca; el conteo cuadra igual. Revisar si ese lead digital es válido y debe cascar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[verificador_tipo_de_tienda_colapso]], [[positive_descuadre_stale_cache]], [[name_account_with_location_id]], [[audit_hub_map_metepec_pilares]]
- Playbook: docs/PLAYBOOK_DESCUADRE.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/cleanup_brand_duplicate_replica_opps.py
- Caso previo relacionado (réplica duplicada, quedó parcial): docs/casos/2026-05-30-descuadre-opp-replica-duplicada-marca.md
@@ -0,0 +1,87 @@
---
id: CASE-2026-05-30-descuadre-opp-deadlink
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "2eJPAdEGjC7iPhDDAeoy (85977 - MP - Interlomas)"]
run_ids: ["99365455-dee6-4f1f-b52a-9076683e02bb"]
snapshots: ["generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[positive_descuadre_stale_cache]]", "[[ghl_opportunity_search_quirks]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +2` / descuadre positivo de opps Marca > Sucursales
- audit con **todos los buckets de opps en 0** (`opportunities_in_brand_duplicate_link: 0`, `opportunities_in_branch_not_in_brand: 0`, `opportunities_missing_id_field: 0`) pero `diff.opportunities` ≠ 0
- opp de Marca con "ID Oportunidad Sucursal" poblado pero `GET /opportunities/{link}` → HTTP **400** `"Opportunity doesn't exist or is deleted."`
- "link muerto" / dead-link / réplica obsoleta que el n8n no borró al rotar el id de la opp de sucursal
- contacto con 2 opps en Marca (una link válido, otra link muerto) y 1 sola opp viva en sucursal
## SÍNTOMA
Dashboard Comparativa: Marca 1,341 opps / Sucursales (suma) 1,339 → **+2 descuadre detectado**. Contactos cuadrados (0).
## DIAGNÓSTICO
1. **Frescura de caché** (sospechoso #1 de positivo): `sync_logs` → "Sincronizar Todo" recién corrido 2026-05-30 10:09; Marca synced 10:09:13 con 1341 opps. Caché FRESCO → +2 real, no stale. (Los 3 "running syncs" del metrics eran zombies del 2026-05-20, ignorar.)
2. **Audit completo** (`scripts/audit_brand_vs_branches_totals.py --json`): diff opps +2 pero **TODOS los buckets de opps = 0**. Ningún bucket lo explica → caso fuera de la cobertura del audit.
3. **Matching 1:1 robusto manual**: agrupar opps de Marca por su link (`extract_opp_link_value`, field `j029pu3OU02ATNccJR6l`) vs set de ids nativos de las 47 sucursales (excl. demos). Resultado: **4 opps de Marca con link MUERTO** (apunta a id que no está en ninguna sucursal); 0 sin link; 0 link duplicado; 1337 con link válido.
4. **Triage de las 4** (por contacto): 2 contactos (Ernesto, Gerardo) tienen una **2ª opp en Marca con link VÁLIDO** a la opp viva → la de link muerto sobra (+2). Los otros 2 (Patricia, Lizeth) tienen **1 sola opp** mal enlazada → 1:1, no inflan conteo (el audit las empareja vía contacto, por eso reportó 0 faltantes).
5. **Verificación EN VIVO**: `GET /opportunities/{deadlink}` → HTTP 400 "doesn't exist or is deleted" en los 4. `GET /opportunities/search?contact_id=` → cada contacto de sucursal tiene exactamente 1 opp viva (coincide con caché).
**Callejón descartado:** los 2 scripts de cleanup NO sirven aquí. `cleanup_brand_duplicate_replica_opps.py` agrupa por link COMPARTIDO (aquí cada link muerto es único). `cleanup_brand_orphan_opportunities.py` empareja por NOMBRE → ve la réplica obsoleta como "sincronizada" (mismo nombre que la opp viva) y no la toca.
## CAUSA RAÍZ
Réplicas **obsoletas** en Marca que el workflow n8n de sync de opps (`Cfgwp0bOtDW8zuKW`) dejó atrás cuando el id nativo de la opp de sucursal **rotó** (la opp original se borró/recreó en la sucursal). La réplica vieja quedó con el `ID Oportunidad Sucursal` apuntando a un id ya inexistente; el n8n creó una nueva réplica con el id nuevo en vez de actualizar la vieja → **doble réplica**. Patrón idéntico a [[positive_opp_descuadre_double_replica]].
## ACCIÓN
Confirmación explícita del usuario (borrar 2 + re-enlazar 2). Snapshot live previo en `generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json`. Todo bajo `run_id=99365455-dee6-4f1f-b52a-9076683e02bb` en `script_audit` (reversible desde dashboard).
- **DELETE** (réplicas obsoletas en Marca): `UNtCRNQHqLf4Vv4vdY39` (Ernesto Chavez, open $10k) + `Bj2bINlklDAoLmTsyg3r` (Gerardo Padilla, open $50k). `gc.delete_opportunity`.
- **RELINK** (`ID Oportunidad Sucursal` → id vivo, `gc.update_opportunity` customFields): `OGQtfmjFk31M5eXKDBpO` (Patricia) `RNwRPgWEpi0nWIBYKbeZ``zzBzWC4adBrdTA8WhQph`; `j0iKZoeeYb1wNOaWHwNN` (Lizeth) `wYLZJd4Xpj0K9HYyikWX``LGSPKoeeEQWEq39HpPLi`.
## VERIFICACIÓN
- Borradas: `GET` → HTTP 400 ambas. Re-enlaces: `GET` confirma el link nuevo en ambas.
- Re-sync Marca: `sync_account` → 1339 opps (antes 1341).
- Re-audit: **diff opps = 0** (1339 = 1339), contactos = 0. Descuadre resuelto.
## EDGE-CASES / TRAMPAS
- GHL devuelve **400 (no 404)** para opp borrada. Tratar 400 como "no existe".
- El campo link sale bajo clave `fieldValue` en `GET /opportunities/{id}` (no `value`). Ver [[ghl_opportunity_search_quirks]].
- No confundir este caso con stale cache: aquí el caché estaba fresco. Verificar SIEMPRE frescura primero igual.
- Patricia/Lizeth NO se borran (son la única réplica 1:1); borrarlas habría creado un faltante 2. Distinguir "sobra" (contacto tiene otra opp con link válido) de "mal enlazada" (única opp).
## REUTILIZABLE
Snippet de detección de dead-link replicas (no lo cubre ningún bucket del audit):
```python
import sqlite3, json, paths
BRAND='GbKkBpCmKu2QmloKFHy3'; DEMOS={'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'}
c=sqlite3.connect(str(paths.DB_PATH)); c.row_factory=sqlite3.Row
LINK='j029pu3OU02ATNccJR6l' # field_id opportunity.id_oportunidad_sucursal en Marca
def extract(cf,fid):
if not cf: return None
for f in (json.loads(cf) or []):
if isinstance(f,dict) and f.get('id')==fid: return f.get('value') or f.get('fieldValue')
branch_ids={r['id'] for r in c.execute("SELECT id FROM opportunities WHERE location_id NOT IN (?,?,?)",(BRAND,*DEMOS))}
for o in c.execute("SELECT id,name,contact_id,custom_fields_json FROM opportunities WHERE location_id=?",(BRAND,)):
lv=(extract(o['custom_fields_json'],LINK) or '').strip()
if lv and lv not in branch_ids: print('DEAD-LINK', o['id'], o['name'], '->', lv)
```
Verificación live de un dead-link: `GET https://services.leadconnectorhq.com/opportunities/{id}` con header `Version: 2021-07-28` → 400 = borrada.
## CAUSA DE FONDO (atacada 2026-05-30)
Investigación del workflow `Cfgwp0bOtDW8zuKW` (vía API n8n) reveló que **NO se puede arreglar en el workflow**: el trigger es un Webhook de creación/actualización de opp (payload con datos de vehículo/fuente); **GHL NO dispara evento de borrado de opp**. Por tanto una cascada de borrado en tiempo real (borrar la réplica de Marca cuando la opp de sucursal se borra) es **inviable** — no hay disparador. Además `Decidir Match` ya está correcto para opps vivas (Baserow global tabla 754 → fallback contacto → CREATE). Gap secundario detectado: `Crear Oportunidad - MARCA` y `Actualizar Oportunidad - MARCA (v2)` no tienen salida → no hacen upsert a Baserow en tiempo real (frescura solo por backfill cada 30 min); no es la causa del dead-link.
**Solución desplegada = reconciliador determinista periódico** (backstop que converge dead-links a 0 sin importar cómo surjan):
- `scripts/reconcile_brand_deadlink_opps.py`: detección cache (link no en set de ids de sucursal) → verificación EN VIVO (GET 400) → `classify()` puro (DELETE réplica obsoleta / RELINK id rotado / SKIP ambiguo o cache stale) → snapshot + script_audit. Dry-run default. Registrado en `SCRIPTS_METADATA` (dashboard, mutator).
- `scripts/scheduled_deadlink_check.py` + `run_deadlink_check.bat` + Tarea Programada Windows **"MP Deadlink Check"** (diaria 7:15am): corre dry-run con `--resync-first` y deja `generated/runtime/deadlink_check_alert.json` si hay accionables; el owner aplica desde el dashboard (protocolo dry-run).
- **Validación 100%:** self-test 8/8 (`--self-test`, lógica de decisión); E2E RELINK en vivo (corromper link→fake, detect→GET400→relink al id vivo, auto-reversible); E2E DELETE en vivo (opp desechable creada+borrada); dry-run real = 0; descuadre global = 0.
## GAP SECUNDARIO CERRADO (2026-05-30)
Cableado el **upsert a Baserow en tiempo real** tras CREATE/UPDATE en `Cfgwp0bOtDW8zuKW` (sus salidas estaban vacías). `n8n/_add_baserow_opp_upsert.py --apply` agregó 2 nodos (`Preparar Upsert Mapeo` + `Crear Mapeo - Baserow`), diseño create-only condicional con `onError=continueRegularOutput` (no puede romper la replicación). Cierra la ventana de 30 min del backfill: una opp recién replicada queda mapeada al instante → una re-ejecución hace UPDATE, no CREATE duplicado. **Validado E2E live** replicando el webhook real (exec 52872 crea la fila; exec 52873 re-dispara → match Baserow → no duplica). Ver [[n8n_opp_idempotency_baserow_mapping]].
## PENDIENTES
- Considerar agregar al audit un bucket "dead-link" (link de Marca no presente en ningún id nativo de sucursal) para que la Comparativa lo reporte sin correr el reconciliador.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[positive_descuadre_stale_cache]], [[ghl_opportunity_search_quirks]], [[duplicate_resolution_rules]], [[n8n_opp_sync_match]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/backfill_opp_sucursal_link.py (patrón PUT relink)
- Snapshot: generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json
@@ -0,0 +1,85 @@
---
id: CASE-2026-05-30-descuadre-opp-replica-duplicada-marca
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca - Monte Providencia)"]
run_ids: ["cdo-pilot-180d299cdd (piloto ANSELMO, 1 borrado)", "cdo-batch-13ce0ae8c0 (lote, 7 borrados)"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092546.json (piloto)", "generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092637.json (lote)"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[n8n_opp_sync_match]]", "[[matching_rules]]", "[[form_submissions_source_of_truth]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +10` / descuadre positivo Marca > Sucursales
- `réplicas duplicadas en Marca` / `mismo ID Oportunidad Sucursal` / dos opps de Marca apuntan a la misma opp de sucursal
- bucket `opportunities_in_brand_duplicate_link`
- workflow n8n `Cfgwp0bOtDW8zuKW` ("Sincronizar Oportunidad - Nodos Nuevos (Create/Update)") hace CREATE en vez de UPDATE
- nodo `Decidir Match (Create vs Update)`, `Buscar Oportunidades del Contacto - MARCA`
- `cleanup_brand_orphan_opportunities.py` reporta CERO huérfanas pero el descuadre sigue positivo
- caso antelmo↔anselmo / mismo teléfono distinto contacto
## SÍNTOMA
Dashboard 2026-05-30: OPORTUNIDADES MARCA 1349 vs SUCURSALES (suma) 1339 → **DIFERENCIA +10** (descuadre positivo). Contactos cuadrados (0).
## DIAGNÓSTICO
1. `python scripts/cleanup_brand_orphan_opportunities.py`**0 huérfanas**; 1345/1349 opps protegidas por el link "ID Oportunidad Sucursal". Si todas tienen contraparte y aun así Marca>Sucursales, la única explicación es **varias opps de Marca apuntando al MISMO id de sucursal**.
2. Query de unicidad del link (extract_opp_link_value + OPP_ID_PATTERN sobre `opportunities` location Marca, agrupando por valor): **8 valores de link con 2 opps de Marca cada uno → 8 opps sobrantes**. Confirmado.
3. `createdAt` en vivo (el cache SQLite NO guarda fecha — `opportunities.date_added` es NULL): cada par = 1 original (creado 2026-04-24..05-22) + 1 duplicado **creado en ráfaga 2026-05-29 ~20:4821:14 hora local (02:4803:14 UTC del 05-30)**, con `updated==created` (intactos).
4. n8n `Cfgwp0bOtDW8zuKW`: el nodo `Decidir Match (Create vs Update)` busca opps **acotado al contacto de Marca resuelto** (`/opportunities/search?contact_id=<Set Contact ID Resuelto>`) y compara el CF "ID Oportunidad Sucursal" (`fieldValueString ?? fieldValue`) contra el id de la opp de sucursal. Sin match → CREATE (sin fallback por nombre, intencional).
5. **Evidencia decisiva**: para 2 pares, `keep.contactId != delete.contactId`. La búsqueda del nodo por el contacto del KEEP SÍ devuelve el CF (bajo `fieldValueString`) → el código de match está bien. El fallo es que original y duplicado cuelgan de **contactos de Marca distintos**:
- ANSELMO: `lGfjbkKEB25jittwcKLd` "ANSELMO SANCHEZ" (tel +525523396616, temo6715@gmail.com, 05-20) vs `KEo4p3e5OvWAvosnYrtT` "Antelmo López Rodríguez" (**mismo tel**, cuauhplayer@gmail.com, 05-27).
- MIGUEL: `RwxMQr0Ywvydjr3veCYo` "Miguel Angel" (tel +527775114949, ene-08) vs `hE9U9Q62Xgd0wPeq6L80` "MIGUEL ANGEL" (**tel distinto, sin email**, 05-28).
## CAUSA RAÍZ
Una **misma opp de sucursal** se replicó a Marca **dos veces, cada vez sobre un contacto de Marca diferente**, porque la identidad del contacto en Marca es ambigua (mismo teléfono con nombre/email variante; o teléfono distinto sin email). El nodo `Decidir Match` evalúa la llave de idempotencia (id de opp de sucursal) **solo dentro del contacto resuelto ese run**, no de forma global en Marca. Al resolver un contacto distinto al que tiene la opp original, no la encuentra y hace CREATE → réplica duplicada. (No es un bug del lector de CF; es scope del match + duplicidad/ambigüedad de contacto.)
## ACCIÓN
- **Detección (read-only, aplicada):** nuevo bucket `opportunities_in_brand_duplicate_link` en `scripts/audit_brand_vs_branches_totals.py` (agrupa opps de Marca por valor de link; >1 = duplicado; recomienda keep/delete por jerarquía). Expuesto en dashboard (tarjeta + export CSV `bucket=brand_duplicate_link_opps`).
- **Limpieza (mutador, dry-run validado, PENDIENTE de aplicar):** `scripts/cleanup_brand_duplicate_replica_opps.py`. Detecta clusters vía audit, trae cada opp en vivo, conserva la canónica (jerarquía: valor → status → createdAt más antiguo) y borra las sobrantes. Snapshot en `generated/migrations/` + `script_audit` (reversible por run_id). Endpoint `POST /api/comparativa/cleanup-duplicate-opps` + botón "Limpiar duplicados" + registrado en `script_runner.py`.
- Dry-run: 8 clusters / 8 a borrar; en los 8 conserva el original antiguo y borra el duplicado del 2026-05-30. Comando: `python scripts/cleanup_brand_duplicate_replica_opps.py` (dry-run) → `--apply --run-id <uuid>`.
- **Raíz n8n (PENDIENTE de confirmación del owner):** endurecer `Cfgwp0bOtDW8zuKW` para que la idempotencia no dependa del contacto resuelto. Ver PENDIENTES.
## VERIFICACIÓN
- Antes: bucket `opportunities_in_brand_duplicate_link` = 16 items / 8 grupos / 8 sobrantes; `diff.opportunities` = +10; brand opps = 1349.
- **Después (CONFIRMADO 2026-05-30):** piloto borró 1 (ANSELMO `prRKgLINCgclX9V3O6R0`, verificado: GET da 400 "deleted", la canónica `yjiU8pjCkohiPpJGZlH6` permanece). Lote borró 7 (CESAR, JOSE LUIS ARQ, SANTIAGO FLORES, "d", Alfonso Mendoza, MARIA DE LOS ANGELES, MIGUEL ANGEL). Re-sync de Marca → **brand opps 1349 → 1341 (8)**, bucket duplicados = **0**, `diff.opportunities` = **+10 → +2**.
- El **+2 residual** es estructural: opps de Marca cuyo origen vive en cuenta demo/excluida de la suma filtrada (1339), NO duplicados (bucket=0 y orphan-check=0). Benigno; documentar si se desea cero absoluto.
- Comando de verificación: `python scripts/audit_brand_vs_branches_totals.py --json` y leer `missing.opportunities_in_brand_duplicate_link`.
- GHL responde **400** (no 404) al GET de una opp borrada → `fetch_opp_live` trata 400/404/"deleted"/"doesn't exist" como inexistente (fix tras el 1er intento de lote que crasheó en fase de planificación, sin borrar nada).
## EDGE-CASES / TRAMPAS
- `cleanup_brand_orphan_opportunities.py` NO ve este problema: trata el link como salvaguarda y nunca verifica unicidad. No concluir "todo limpio" con ese script en un descuadre positivo.
- `opportunities.date_added` es NULL en el cache → para fechar "cuándo surgió" hay que ir en vivo (`GET /opportunities/{id}``createdAt`). El limpiador desempata por createdAt en vivo, no por el cache.
- Al borrar la opp duplicada queda un **contacto de Marca posiblemente huérfano de opp** (p.ej. "Antelmo López"). Es un problema de contacto aparte (ver `delete_intra_brand_duplicates.py` + [[matching_rules]]); el limpiador de opps NO lo toca.
- Phone solo nunca es match ([[matching_rules]]): ANSELMO/Antelmo comparten tel pero son contactos distintos en Marca; no fusionar a ciegas.
## REUTILIZABLE
```bash
# Detectar opps de Marca con link duplicado (read-only):
python scripts/audit_brand_vs_branches_totals.py --json # -> missing.opportunities_in_brand_duplicate_link
# Limpiar (dry-run -> piloto -> lote):
python scripts/cleanup_brand_duplicate_replica_opps.py
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link <id_opp_sucursal> --apply --run-id <uuid>
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --run-id <uuid>
# Inspeccionar workflow n8n de sync de opps:
python -c "import sys;sys.path.insert(0,'scripts');import n8n_workflow_lib as l;c=l.N8NClient(*l.load_credentials());import json;print(json.dumps(c.get_workflow('Cfgwp0bOtDW8zuKW')['nodes'],ensure_ascii=False)[:2000])"
```
## FIX PREVENTIVO n8n (APLICADO 2026-05-30) — opción (c) mapeo Baserow
Idempotencia GLOBAL por id de opp de sucursal, independiente del contacto.
- **Tabla Baserow creada por API**: DB 63, **table_id=754** "Mapeo Opp Sucursal-Marca": `id_opp_sucursal` (primario, field 7280), `id_opp_marca` (7283), `location_id_sucursal`, `updated_at`. Se extendió `scripts/baserow_client.py` con `create_table/create_field/update_field/delete_field`.
- **Backfill**: `scripts/backfill_baserow_opp_mapping.py --table-id 754 --apply` → 1341 mapeos (0 duplicados). Gotcha: el JWT de Baserow EXPIRA a mitad (~1004 creates → 401 ERROR_INVALID_ACCESS_TOKEN); re-ejecutar (idempotente, `ya_ok`) completa el resto.
- **Rewire `Cfgwp0bOtDW8zuKW`** (`n8n/_add_baserow_opp_idempotency.py --apply`; backup `n8n/backup_pre_baserow_opp_idempotency_*.json`; versionId nuevo `9caa764a-...`): nodo Baserow `Buscar Mapeo Opp - Baserow` (getAll tabla 754, filtro field 7280 == opp_id sucursal, `alwaysOutputData`+`onError=continueRegularOutput`) insertado `Set Contact ID Resuelto → [lookup] → Buscar Oportunidades del Contacto - MARCA → Decidir Match`. `Decidir Match` reescrito: si mapeo Baserow → UPDATE global; si no, fallback CF por contacto; si nada → CREATE. Degrada a la lógica previa si Baserow falla (try/catch).
- **Validación (data-layer, segura)**: lookup por link mapeado devuelve 1 fila con `id_opp_marca` correcto (ANSELMO/MIGUEL); link inexistente → 0 filas. NO se disparó webhook real (evita mutar producción); la próxima sync real ejercita el path.
- **Frescura**: Tarea Programada Windows **"MP Baserow Opp Mapping"** (cada 30 min, `run_baserow_opp_mapping.bat`) corre el backfill idempotente para mapear opps nuevas.
## PENDIENTES
1. (Opcional) Confirmar con webhook real / próxima ejecución de producción que el path UPDATE-vía-Baserow funciona E2E (riesgo bajo: degrada a lógica previa si algo falla).
2. Investigar si el evento del 2026-05-29 ~21:00 (re-disparo masivo) fue corrida manual/replay y acotarlo.
3. (Mejora) Persistir `createdAt`/`updatedAt` de GHL en `opportunities` (`db.py`) para no depender de GET en vivo al fechar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[n8n_opp_sync_match]], [[matching_rules]], [[form_submissions_source_of_truth]], [[duplicate_resolution_rules]], [[name_account_with_location_id]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/cleanup_brand_duplicate_replica_opps.py, scripts/audit_brand_vs_branches_totals.py
- Workflow: n8n Cfgwp0bOtDW8zuKW
@@ -0,0 +1,72 @@
---
id: CASE-2026-05-30-sucursal-tag-en-leads-digitales
fecha: 2026-05-30
categoria: custom_field, cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "HvDw9Eg3rjrwkbQJXqfi (Marina Nacional)", "R34lUVVpltnB8Z1RqnEB (Satélite)", "KEZ7dAhgwzK4uZfMvZuj (Puebla)", "nRSeOhlhQ3vyirTKYhPi (Villas del Sol)", "nF1uEaYB3mCK5em9bPn2 (Eugenia)", "rQYjjwsGnjEGagskOxix (Tulyehualco)", "XkduzafvwsrWcEFg6Qlj (Cd. Carmen)"]
run_ids: ["pilot-sucursal-tag-20260530013645", "batch-sucursal-tag-20260530013808", "fix-miza-canal-fuente-20260530015547"]
snapshots: []
status: resuelto
memorias: ["[[createdby_only_in_individual_get]]", "[[super_script_fix_branch_user_origin]]", "[[n8n_realtime_replication]]", "[[ghl_tags_api]]", "[[custom_fields_picklist_alignment]]", "[[feedback_dry_run_protocol]]"]
playbooks: []
---
## TRIGGERS
- `tag sucursal y facebook-ads juntos`, `conflicto facebook-ads sucursal`, `falsa atribución digital`
- `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`
- `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`
- `n8n no re-añade tag sucursal`, `tag se queda quitado tras 60s`
- `Miza Olguin Puebla`, `contacto Canal SUCURSAL pero opp FORMULARIO`, `Fuente PROSPECCIÓN no sobrescribir`
## SÍNTOMA
Export de 87 contactos de Marca con tags `facebook-ads` + `sucursal` simultáneos, `CANAL DE ORIGEN=FACEBOOK`, `Fuente de Prospecto=LEAD DIGITAL`. El usuario sospechaba que algunos eran **creados a mano por staff de sucursal** que pusieron apariencia digital falsa (tag facebook-ads + Fuente "redes sociales/LEAD DIGITAL").
## DIAGNÓSTICO
La columna `source` de SQLite guarda el `source` NATIVO ("Facebook"/"Formulario"), NO `createdBy.source` ([db.py:300](../../db.py#L300)). Y es poco confiable (un contacto de sucursal tenía `source=null` mientras Marca decía "Formulario"). `createdBy.source` SOLO viene en `GET /contacts/{id}` individual ([[createdby_only_in_individual_get]]).
Como los contactos del export son de **Marca** (réplica n8n → su createdBy siempre es INTEGRATION), el origen real está en el **contacto de la sucursal**. Resolución Marca→Sucursal por CF `id_contacto_sucursal` (id Marca `E6lI9ykWhqpj7Pmi7Qd3`) → buscar la location en SQLite (`SELECT location_id FROM contacts WHERE id=?`) → GET en vivo en esa sucursal.
Script: `python scripts/audit_origin_for_contact_list.py --csv generated/reports/origin_audit/_input_ids.csv` (read-only). Resultado: **87/87 = INTEGRATION (REAL_DIGITAL)**, 0 manuales. Verificación cruda: `createdBy = {"source":"INTEGRATION","channel":"OAUTH",...}`. El detector SÍ distingue manuales (un contacto de control de otra cuenta salió `WEB_USER`).
**Callejón descartado:** la nota de agente + Canal=SUCURSAL de Miza NO prueban creación manual; su contacto de sucursal es INTEGRATION (entró digital, un agente la reetiquetó/anotó después).
## CAUSA RAÍZ
1. Los 87 son leads de **origen digital** (FB Ads/formulario → integración). El tag `facebook-ads` y `LEAD DIGITAL` son **correctos**. La hipótesis de "fake digital" quedó **refutada**.
2. La etiqueta que sobra y genera el conflicto es **`sucursal`** (en la taxonomía MP `sucursal` y `facebook-ads` son tags de ORIGEN mutuamente excluyentes — ver `fix_branch_user_origin.py`).
3. Caso aislado: **Miza Olguin** tenía CANAL/Fuente del CONTACTO = SUCURSAL, pero su **opp** ya estaba en Canal=FORMULARIO / Fuente=PROSPECCIÓN. Su origen real es **formulario** (no facebook-ads).
## ACCIÓN
Dry-run → piloto (1 contacto) → batch, protocolo [[feedback_dry_run_protocol]].
1. **Quitar tag `sucursal`** de los 87 en **ambos lados** (Marca + sucursal), `scripts/fix_remove_sucursal_tag_digital.py` (mutador, dry-run default, bucle quitar→esperar 60s→verificar para vencer la carrera n8n):
- Piloto: `--only iR4fS0f2fOGB75vtVZMP --apply --run-id pilot-sucursal-tag-20260530013645` → Ronda 2 = 0 con tag (n8n NO lo re-añade).
- Batch: `--apply --run-id batch-sucursal-tag-20260530013808` → 172 lados limpiados, Ronda 2 = 0.
2. **Miza** (inline, `run_id fix-miza-canal-fuente-20260530015547`): contacto Canal SUCURSAL→**FORMULARIO**, Fuente SUCURSAL→**LEAD DIGITAL** en Marca + Puebla. **La opp NO se tocó** (ya correcta: FORMULARIO/PROSPECCIÓN). Cero opps creadas/modificadas.
## VERIFICACIÓN
Sweep final independiente (174 lados, GET vivo): **0 con tag `sucursal`**, **0 perdieron `facebook-ads`**. Miza verificada: Canal=FORMULARIO, Fuente=LEAD DIGITAL en ambos lados tras 60s. Resultado: cambio 100% efectivo.
## EDGE-CASES / TRAMPAS
- **No basta el `source` nativo ni la caché**: usar `createdBy.source` en vivo del contacto de **sucursal** (no de Marca).
- **`facebook-ads` era CORRECTO**: la instrucción inicial era quitar facebook-ads; los datos dijeron quitar `sucursal`. Confirmar dirección con el usuario antes de mutar evitó corromper 87 leads legítimos.
- **Opp vs contacto pueden divergir**: Miza tenía contacto=SUCURSAL pero opp=FORMULARIO. Investigar la opp ANTES de elegir el valor del contacto (puse FORMULARIO, no FACEBOOK, para no inyectar inconsistencia contacto-vs-opp).
- **No sobrescribir Fuente de opp** = PROSPECCIÓN (valor de negocio).
- **n8n NO re-añade el tag `sucursal`** a contactos INTEGRATION (piloto lo probó). Quitar un tag no crea opps → cero riesgo de duplicados de opp.
- `DELETE /contacts/{id}/tags` con body `{"tags":[...]}` (el path `/tags/{name}` da 404) — ya manejado por `remove_contact_tag` ([[ghl_tags_api]]).
## REUTILIZABLE
```bash
# Auditar origen real de una lista de contactos de Marca (read-only)
python scripts/audit_origin_for_contact_list.py --ids "ID1,ID2,..." # o --csv <ruta>
# Quitar tag sucursal de los REAL_DIGITAL (dry-run -> piloto -> batch)
python scripts/fix_remove_sucursal_tag_digital.py # dry-run
python scripts/fix_remove_sucursal_tag_digital.py --only <marca_id> --apply --run-id pilot-...
python scripts/fix_remove_sucursal_tag_digital.py --apply --run-id batch-...
```
## PENDIENTES
- Investigar **por qué** estos digitales recibieron el tag `sucursal` históricamente (qué workflow/migración lo puso). No se re-añade hoy, pero conviene cerrar la fuente para que no reaparezca en leads futuros.
## ENLACES
- Scripts: `scripts/audit_origin_for_contact_list.py`, `scripts/fix_remove_sucursal_tag_digital.py`
- Artefactos: `generated/reports/origin_audit/origin_audit_20260530_012600.csv` (+ `_input_ids.csv`)
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[n8n_realtime_replication]], [[ghl_tags_api]]
@@ -0,0 +1,80 @@
---
id: CASE-2026-05-30-verificador-tipo-de-tienda-colapso
fecha: 2026-05-30
categoria: config_location | cascada_n8n | custom_field
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares = hub digital Toluca/Metepec/Lerma)", "NSDniGzjxotVDNa5YxqW (85937 Metepec, VACÍA)", "jE41bVhhnb5T505BFm4F (85964 Morelia 1)"]
run_ids: []
snapshots: ["generated/migrations/baserow_verificador_pre_tipo_tienda_750_20260530_005359.json", "generated/migrations/baserow_verificador_pre_restore_nodigital_suc_750_20260530_011528.json", "n8n/backup_add_tipo_filter_*_20260530_0115*.json (3 workflows)"]
status: resuelto
memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
playbooks: []
---
## TRIGGERS
- `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `PARCIAL`, `FULL AUTOS`, `Verificador 750 columna tipo de tienda`
- `colapsar filas digitales`, `1 fila canónica por location`, `SUCURSAL contains más de un resultado`
- `Toluca 3 filas Pilares`, `hub digital Pilares`, `Metepec 85937 vacío`, `Metepec routing Pilares`
- `no-digital 0 contactos`, `premisa formulario no-digital`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row`
## SÍNTOMA
Leads "Formulario - Sitio Web" de zonas multi-tienda (Toluca/Metepec/Lerma) con TIENDA inconsistente/vacía (ver caso 2026-05-29). El owner agregó columna `TIPO DE TIENDA` a Baserow 750 y propuso que n8n excluya `NO DIGITAL`. Pidió estudiar implicaciones e iterar hasta confirmar funcionamiento en vivo.
## DIAGNÓSTICO (read-only)
1. **3 workflows** leen 750 (no 2): `ddUEORBEtZLzsQF2` [2004] (escribe `contact.tienda` por SC BUCEFALO `equal` limit 1), `4UMRwxJdHFfOGHBp` [1604] Marca→Sucursal (por SUCURSAL `contains` limit 3, toma row[0]), `EuPdIkCORyh0skoB` [SUCURSAL] (2 nodos 750). `x4DqZ5FtSc43tdzB` y `Cfgwp0bOtDW8zuKW` NO tocan 750.
2. **Premisa validada en vivo** (`get_all_contacts`): 85938/85939/85940/85941/85965 = **0 contactos**. Y **85937 Metepec = 0**. Pilares 85935 = 87 contactos, TIENDA consenso `PILARES` (85/87). Morelia 1 = 60, `MORELIA 1`.
3. **Contradicción CSV vs datos:** el CSV editado enrutaba Metepec→85937, pero 85937 está vacío; los leads de la zona caen en Pilares. Decisión del owner: **Metepec→Pilares** (modelo hub).
4. Estado live 750 (52 filas): Toluca = 3 filas →Pilares (GRAND PLAZA/ISIDRO FABELA/INDEPENDENCIA, TIPO vacío) = **ambiguo**. Metepec = 1 digital + 2 NO DIGITAL dup. Morelia/otros = OK. Fila 258 = junk (TIPO literal="TIPO DE TIENDA"). Filas NO DIGITAL de Toluca (Isidro/Grand/Indep) ya existían huérfanas (sin SUCURSAL).
## CAUSA RAÍZ
Ambigüedad estructural en Baserow 750: **varias filas por el mismo texto SUCURSAL** (sin colapsar). El filtro NO DIGITAL solo, NO basta para Toluca (las 3 filas eran todas digitales →Pilares con TIENDA distinta). Hace falta **colapsar a 1 fila canónica por location digital**.
## ACCIÓN (Baserow 750, reversible vía backup)
Script `scripts/fix_baserow_tipo_de_tienda.py` (dry-run→apply, `backup_table` previo). Se agregó `delete_row()` a `scripts/baserow_client.py`.
1. **Routing (piloto):** fila 241 Toluca→`TIENDA=PILARES, TIPO=FULL AUTOS`; **borrar** 242, 243 (dups Toluca). fila 244 Lerma→`PILARES/FULL AUTOS`. **borrar** 254 (dup Metepec) y 258 (junk).
2. **Labels (batch):** `TIPO DE TIENDA=PARCIAL` en 31 filas single-store vacías.
3. **Consistencia:** blanquear `SUCURSAL` de la fila 240 (Metepec NO DIGITAL) → patrón referencia-only.
### ACCIÓN 2 — Filtro en n8n (2026-05-30, a pedido del owner)
Tras el fix de Baserow, el owner pidió **implementar el filtro explícito** y dejar las filas NO DIGITAL CON su SUCURSAL para que el filtro actúe de verdad (load-bearing). Script `n8n/add_tipo_de_tienda_filter.py` (backup por workflow + dry-run + apply + activate + verify, idempotente).
1. **Operador confirmado empíricamente:** query directa Baserow REST → `not_equal` OK (excluye las 6 NO DIGITAL, 0 fugas); `is_not``ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST` (la suposición del agente era falsa); `contains_not` también sirve pero `not_equal` es el semántico.
2. Se agregó `{field:7279, operator:"not_equal", value:"NO DIGITAL"}` a `additionalOptions.filters.fields` (AND con el filtro existente) en: `4UMRwxJdHFfOGHBp` "Obtener Info de cuenta objetivo - SUCURSAL" (**load-bearing**), `ddUEORBEtZLzsQF2` "Buscar Sucursal en Verificador de Sucursales" (defensa), `EuPdIkCORyh0skoB` "Buscar Cuenta Sucursal Bucefalo" (defensa). Los 3 quedaron `active=true`, verificado post-PUT.
3. Se **restauró la SUCURSAL** de las 6 filas NO DIGITAL (240/252/253/255/256/257) → ahora el `contains` matchea digital+no-digital y el filtro excluye las no-digital.
## VERIFICACIÓN
- `list_rows(750)` (48 filas tras borrar 4): **las 41 SUCURSAL resuelven a exactamente 1 fila digital** (0 ambiguas). Toluca/Metepec/Lerma `contains=1`→PILARES; Morelia→MORELIA 1.
- SC BUCEFALO `85935 - MP - Pilares` → 3 filas (226/241/244) **todas TIENDA=PILARES** → [2004] limit 1 determinista.
- Pilares live: 64 leads formulario, **0 con TIENDA vacía** (sin rezagados; no requiere backfill nuevo).
- **Filtro (con/sin) por query directa:** Toluca 4→1, Metepec 2→1, Lerma 2→1, Morelia 2→1 → en todas la fila que queda es la digital correcta. Prueba que el filtro es load-bearing y funciona.
- **E2E REAL en vivo:** POST al webhook de [1604] con lead `qa-test` Sucursal=Metepec → ejecución `52776` success, nodo Baserow devolvió **1 fila PILARES** (excluyó la NO DIGITAL de Metepec), réplica creada en Pilares (`DjOIZgekf3Sy2B49AWKp`) con **TIENDA=PILARES** (escrita por [2004]). Contactos de prueba (Marca `h9VXb9jy6ix9v0e5KHaY` + réplica) **borrados y verificados** (GET→error). El nodo "Envio a tienda" mandó 1 correo a la tienda (autorizado por el owner).
## EDGE-CASES / TRAMPAS
- El store **Metepec 85937 está vacío en vivo**; NO es el target digital de Metepec (lo es Pilares). El backfill de Jorge Enrique→Pilares (caso 2026-05-29) era correcto; la edición del CSV→85937 se descartó.
- Las filas NO DIGITAL se dejan **sin SUCURSAL** a propósito (referencia-only) para que el `contains` no las matchee → fix sin depender de un filtro en el workflow.
- **Operador Baserow para "≠" = `not_equal`** (NO `is_not`, que no existe). Validar SIEMPRE el operador con query directa antes de PUTear el nodo.
- El nodo Baserow n8n pasa `operator` tal cual al filter type de Baserow; el E2E confirmó que `not_equal` funciona en el nodo en vivo.
- E2E real del [1604] dispara "Envio a tienda" → **1 correo a la tienda** por cada lead de prueba. Usar `qa-test` y nombre "QA TEST" visible, y borrar Marca+réplica al terminar.
- `baserow_client` no tenía `delete_row` (se agregó). Mutaciones reversibles vía `backup_table`/`backup_workflow`.
## REUTILIZABLE
```python
# Unicidad de resolución por SUCURSAL (lo que ve el workflow)
from collections import defaultdict
from scripts.baserow_client import BaserowClient
rows = BaserowClient.from_credentials().list_rows(750)
by = defaultdict(list)
for r in rows:
s = str(r.get("SUCURSAL") or "").strip()
if s and s != "-": by[s].append(r)
amb = {s: rs for s, rs in by.items()
if len([r for r in rs if str(r.get("TIPO DE TIENDA") or "").upper() != "NO DIGITAL"]) != 1}
# amb vacío => toda SUCURSAL resuelve a 1 fila digital
```
## PENDIENTES
- Ninguno. Filtro desplegado en los 3 workflows + E2E en vivo OK. (Si en el futuro se agregan filas, recordar: NO DIGITAL nunca recibe formulario; el filtro `not_equal` las excluye.)
- Rollback disponible: `n8n/backup_add_tipo_filter_*` (workflows) y `generated/migrations/baserow_verificador_pre_*` (Baserow).
## ENLACES
- Memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
- Scripts: `scripts/fix_baserow_tipo_de_tienda.py`, `scripts/baserow_client.py` (+`delete_row`)
- Caso previo: [2026-05-29-tienda-vacia-formulario-sitio-web.md](2026-05-29-tienda-vacia-formulario-sitio-web.md)
+47
View File
@@ -0,0 +1,47 @@
# Registro de casos — MP Manager
> **Qué es esto:** bitácora cronológica de operaciones e investigaciones reales sobre Bucéfalo (GHL).
> Optimizada para **recall del agente**, no para humanos. Cada caso = síntoma → diagnóstico (con
> comandos exactos) → causa raíz → acción (run_ids) → verificación → edge-cases → snippets reutilizables.
>
> **Cómo encaja con el resto:**
> - `docs/PLAYBOOK_*.md` = **teoría atemporal** (metodología, taxonomía). No cambian por caso.
> - `memory/*.md` = **hechos atómicos** recuperables (reglas, gotchas, bugs). Indexados en `MEMORY.md`.
> - **Este registro** = **narrativa investigable** de cada operación, con comandos y artefactos. Liga todo.
>
> No dupliques: enlaza al playbook para la teoría y a la memoria para los hechos; aquí va lo específico del caso.
## Cuándo registrar un caso (disparador)
Crea una entrada **siempre que**:
1. **Mutes Bucéfalo** (cualquier escritura a GHL con `run_id`/snapshot), o
2. **Cierres una investigación no trivial** que llegue a una **causa raíz** (aunque no haya mutación).
No registres: lecturas triviales, consultas de un solo dato, o trabajo puramente de código del repo (eso va a git).
## Cómo registrar
1. Copia [`_PLANTILLA.md`](_PLANTILLA.md) a `docs/casos/YYYY-MM-DD-<slug>.md` y rellena (denso, comandos exactos, ids literales).
2. Agrega la fila a la tabla de abajo (más reciente arriba).
3. Crea/actualiza la(s) **memoria** relacionada(s) y enlázala(s) con `[[slug]]` desde el caso.
4. Si el caso revela teoría nueva reutilizable, considera además actualizar el `PLAYBOOK_*` correspondiente.
## Categorías
`descuadre` · `enlace_opp` · `duplicado` · `fantasma` · `cascada_n8n` · `custom_field` · `config_location` · `playwright` · `otro`
## Casos (cronológico inverso)
| Fecha | ID | Categoría | Triggers (keywords para grep) | Status | Enlace |
|---|---|---|---|---|---|
| 2026-05-30 | CASE-2026-05-30-comparativa-auditoria-completa-buckets | descuadre, cascada_n8n, config_location | `Comparativa Marca vs Sucursales auditar todos los buckets`, `opps +1 opportunities_in_brand_duplicate_link`, `present_in_other_branch_not_assigned 84 falsos positivos`, `TIENDA=METEPEC vive en Pilares 85935`, `DIGITAL_HUB_BY_SHELL hub-map en código`, `audit lee Verificador CSV no Baserow`, `PATRICIA PARRA zzBzWC4adBrdTA8WhQph réplica abandoned $0`, `redirect Windows cp1252 no utf-8`, `re-sync Marca antes de re-auditar` | resuelto | [caso](2026-05-30-comparativa-auditoria-completa-buckets.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-deadlink | descuadre, cascada_n8n | `DIFERENCIA OPORTUNIDADES +2`, `descuadre positivo todos los buckets de opps en 0`, `opp Marca link muerto GET 400 Opportunity doesn't exist or is deleted`, `réplica obsoleta n8n no borró id rotado`, `contacto 2 opps Marca 1 sola viva sucursal`, `cleanup scripts no atrapan dead-link único`, `re-enlazar vs borrar opp 1:1` | resuelto | [caso](2026-05-30-descuadre-opp-deadlink.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-replica-duplicada-marca | descuadre, cascada_n8n, duplicado | `DIFERENCIA OPORTUNIDADES +10`, `descuadre positivo Marca > Sucursales`, `réplicas duplicadas mismo ID Oportunidad Sucursal`, `dos opps Marca misma opp sucursal`, `opportunities_in_brand_duplicate_link`, `Cfgwp0bOtDW8zuKW CREATE en vez de UPDATE`, `Decidir Match Create vs Update`, `cleanup_brand_orphan cero huérfanas pero descuadre`, `antelmo anselmo mismo teléfono distinto contacto`, `cleanup_brand_duplicate_replica_opps.py` | parcial | [caso](2026-05-30-descuadre-opp-replica-duplicada-marca.md) |
| 2026-05-30 | CASE-2026-05-30-sucursal-tag-en-leads-digitales | custom_field, cascada_n8n | `tag sucursal y facebook-ads juntos`, `falsa atribución digital`, `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`, `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`, `n8n no re-añade tag sucursal`, `Miza Olguin contacto SUCURSAL opp FORMULARIO`, `no sobrescribir Fuente PROSPECCIÓN` | resuelto | [caso](2026-05-30-sucursal-tag-en-leads-digitales.md) |
| 2026-05-30 | CASE-2026-05-30-verificador-tipo-de-tienda-colapso | config_location, cascada_n8n, custom_field | `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `colapsar filas digitales`, `1 fila canónica por location`, `hub digital Pilares`, `Metepec 85937 vacío`, `premisa no-digital 0 contactos`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row` | resuelto | [caso](2026-05-30-verificador-tipo-de-tienda-colapso.md) |
| 2026-05-29 | CASE-2026-05-29-tienda-vacia-formulario-sitio-web | custom_field, cascada_n8n, config_location | `TIENDA vacía`, `Formulario - Sitio Web TIENDA`, `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `Toluca Metepec 3 filas`, `SUCURSAL contains ambiguo`, `createdBy INTEGRATION`, `delete Guco Villahermosa` | parcial | [caso](2026-05-29-tienda-vacia-formulario-sitio-web.md) |
| 2026-05-29 | CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca | custom_field, cascada_n8n | `contacto Marca sin datos de vehículo`, `customFields count 0 en Marca`, `réplica Sucursal→Marca sin custom fields`, `Sincronización Sucursal CFs vacíos`, `nombres CF idénticos descarta name-mismatch`, `Temixco`, `Cristhian Hugo`, `cache stale synced_at anterior` | parcial | [caso](2026-05-29-backfill-cf-vehiculo-temixco-marca.md) |
| 2026-05-29 | CASE-2026-05-29-corrector-baserow-verificador | config_location | `Baserow`, `tabla 749 750`, `SC BUCEFALO`, `Verificador desactualizado`, `n8n no encuentra sucursal`, `PLAZA EL SALADO 85932 85956`, `cuentas_oficiales.csv`, `corrector baserow` | resuelto | [caso](2026-05-29-corrector-baserow-verificador.md) |
| 2026-05-29 | CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real | cascada_n8n | `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `Canal de Origen tiempo real`, `Verificador 750 devuelve 0`, `Eugenia no está en Baserow 750`, `esUsuario IF n8n`, `n8n executions includeData` | resuelto | [caso](2026-05-29-n8n-2004-canal-origen-tiempo-real.md) |
| 2026-05-29 | CASE-2026-05-29-origen-sucursal-contactos-usuario | custom_field | `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `createdBy no viene en el listado`, `fix_web_user_branch_contacts roto`, `origen sucursal`, `no sobrescribir Fuente de Prospecto`, `ALIANZA` | resuelto | [caso](2026-05-29-origen-sucursal-contactos-usuario.md) |
| 2026-05-29 | CASE-2026-05-29-descuadre-opp-multiempeno | descuadre, config_location, duplicado | `descuadre +1 opp`, `Can not create duplicate opportunity`, `allowDuplicateOpportunity`, `multi-empeño no replica`, `link muerto`, `réplica obsoleta` | resuelto (5 mislinks pendientes) | [caso](2026-05-29-descuadre-opp-multiempeno.md) |
+54
View File
@@ -0,0 +1,54 @@
---
id: CASE-YYYY-MM-DD-<slug>
fecha: YYYY-MM-DD
categoria: descuadre | enlace_opp | duplicado | fantasma | cascada_n8n | custom_field | config_location | playwright | otro
location_ids: [] # cuentas tocadas (incluye nombre al lado del id, ver [[name_account_with_location_id]])
run_ids: [] # script_audit run_ids generados (rollback)
snapshots: [] # rutas generated/migrations/*.json
status: resuelto | parcial | pendiente | escalado
memorias: [] # [[slug]] de memorias relacionadas
playbooks: [] # docs/PLAYBOOK_*.md relevantes
---
<!--
COMO USAR ESTA PLANTILLA (para mi, el agente):
- Copia este archivo a docs/casos/YYYY-MM-DD-<slug>.md y rellena.
- Escribe DENSO y para MI uso: comandos exactos copiables, ids literales, errores literales.
- TRIGGERS es lo mas importante: pon las frases que un grep futuro buscaria.
- Documenta tambien las atribuciones EQUIVOCADAS y por que se descartaron — ahorran horas.
- Al terminar: agrega la fila a INDEX.md y enlaza/actualiza la(s) memoria(s).
-->
## TRIGGERS
<!-- Frases/keywords/errores LITERALES que en un caso futuro me harian buscar este caso.
Incluye: sintoma del dashboard, mensajes de error de GHL, nombres de flags/campos, numeros. -->
- `...`
## SÍNTOMA
<!-- El punto de entrada: que se observo, donde, con que numero. -->
## DIAGNÓSTICO
<!-- Pasos READ-ONLY con comandos EXACTOS y que revelo cada uno.
Incluye los callejones sin salida y por que se descartaron. -->
## CAUSA RAÍZ
<!-- La causa CONFIRMADA (no la aparente). Una o varias, numeradas. -->
## ACCIÓN
<!-- Que se muto. Orden dry-run -> piloto -> lote. Comandos exactos, run_ids, snapshots.
Si no se muto (solo investigacion): "Ninguna mutacion; solo diagnostico." -->
## VERIFICACIÓN
<!-- Antes -> despues con numeros. El comando que lo confirma. -->
## EDGE-CASES / TRAMPAS
<!-- Lo que casi sale mal. Falsos positivos. Por que NO hacer X. -->
## REUTILIZABLE
<!-- Snippets/comandos directamente copiables para el proximo caso similar. -->
## PENDIENTES
<!-- Lo que quedo abierto y como retomarlo. -->
## ENLACES
<!-- Memorias [[slug]], playbooks docs/..., scripts scripts/..., artefactos generated/... -->