1 Commits

Author SHA1 Message Date
urieljareth fb20cf8bd5 descripción 2026-05-30 20:16:12 -06:00
34 changed files with 32414 additions and 527 deletions
-3
View File
@@ -6,9 +6,6 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
# Entorno virtual de macOS/Linux (lo crea setup_mac.command)
.venv/
# Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py) # Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py)
generated/ generated/
-1
View File
@@ -6,7 +6,6 @@
- Install deps with `python -m pip install -r requirements.txt`. - Install deps with `python -m pip install -r requirements.txt`.
- Run the local app with `python main.py`; it serves FastAPI/Uvicorn at `http://127.0.0.1:8000` with reload enabled. - Run the local app with `python main.py`; it serves FastAPI/Uvicorn at `http://127.0.0.1:8000` with reload enabled.
- On Windows, `start.bat` runs `python main.py` in a new window and opens the browser; `stop.bat` kills any process using port `8000`. - On Windows, `start.bat` runs `python main.py` in a new window and opens the browser; `stop.bat` kills any process using port `8000`.
- On macOS/Linux, the double-clickable `.command` files mirror the `.bat`s: run `setup_mac.command` once to bootstrap a `.venv` (Python 3.10+) and deps, then `start.command` / `stop.command` / `restart.command` / `start_persistent_profile.command`. They delegate the safe preflight/stop logic to the now cross-platform `runtime_control.py`.
- There is no test, lint, or formatter config in this repo. For a syntax-only check, run `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py` and add specific scripts as needed. - There is no test, lint, or formatter config in this repo. For a syntax-only check, run `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py` and add specific scripts as needed.
- Run focused utility scripts directly, for example `python scripts\mp_contact_search.py <query>` or `python scripts\mp_opportunity_search.py <query-or-status>`. - Run focused utility scripts directly, for example `python scripts\mp_contact_search.py <query>` or `python scripts\mp_opportunity_search.py <query-or-status>`.
- Global dashboard sync parallelism is controlled with `SYNC_ENGINE_MAX_WORKERS`; default is `12`, hard maximum is `20`. It affects the `Sincronizar Todo` button and processes multiple GHL locations in parallel. - Global dashboard sync parallelism is controlled with `SYNC_ENGINE_MAX_WORKERS`; default is `12`, hard maximum is `20`. It affects the `Sincronizar Todo` button and processes multiple GHL locations in parallel.
-4
View File
@@ -9,10 +9,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `python -m pip install -r requirements.txt` — instalar dependencias. - `python -m pip install -r requirements.txt` — instalar dependencias.
- `python main.py` — levanta FastAPI/Uvicorn en `http://127.0.0.1:8000` con reload. - `python main.py` — levanta FastAPI/Uvicorn en `http://127.0.0.1:8000` con reload.
- `start.bat` / `stop.bat` / `restart.bat` (Windows) — lanzan/matan/reinician la app y liberan el puerto 8000. `restart.bat` detecta automáticamente si estaba en modo normal o perfil persistente (lee `generated/runtime/last_mode`), mata Chromium zombies y limpia batch files huérfanos en `generated/runtime/batch/`. - `start.bat` / `stop.bat` / `restart.bat` (Windows) — lanzan/matan/reinician la app y liberan el puerto 8000. `restart.bat` detecta automáticamente si estaba en modo normal o perfil persistente (lee `generated/runtime/last_mode`), mata Chromium zombies y limpia batch files huérfanos en `generated/runtime/batch/`.
- **macOS / Linux** — equivalentes doble-clickeables en Finder con extensión `.command`:
- `setup_mac.command` — inicialización con un click (NO tiene equivalente Windows): detecta Python 3.10+, crea el venv `.venv`, instala `requirements.txt` y el Chromium de Playwright, y copia `.env.example` a `.env`. Idempotente.
- `start.command` / `stop.command` / `restart.command` / `start_persistent_profile.command` — espejo de los `.bat`. Lanzan el server con `nohup` en segundo plano (logs en `generated/logs/server.out`) usando el python del `.venv`. `stop.command` acepta `--force` igual que el `.bat`.
- `mp_common.sh` — helper compartido (sourced, no se ejecuta solo): resuelve la raíz del proyecto, localiza el python del venv y define los banners/utilidades. La lógica segura de preflight/stop la delegan todos a `runtime_control.py`, que ahora es cross-platform (`lsof`/`ps`/`pgrep`/`kill` en POSIX, `netstat`/PowerShell/`taskkill` en Windows; ver `IS_WINDOWS`).
- `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py` — único "lint" disponible (no hay test/lint/format configurado). - `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py` — único "lint" disponible (no hay test/lint/format configurado).
- Scripts de utilidad se corren directo: `python scripts\<nombre>.py [args]`. La mayoría leen `generated/data/mp_manager.sqlite` (vía `paths.DB_PATH`) y asumen una sync previa. - Scripts de utilidad se corren directo: `python scripts\<nombre>.py [args]`. La mayoría leen `generated/data/mp_manager.sqlite` (vía `paths.DB_PATH`) y asumen una sync previa.
- Variables de entorno: - Variables de entorno:
+1 -1
View File
@@ -31,7 +31,7 @@ Los scripts soportan dos modos de persistencia. Se elige con la variable de ento
### Modo 2 — Perfil de Chrome persistente ### 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) (Windows) o [start_persistent_profile.command](../start_persistent_profile.command) (macOS/Linux). - **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. - **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. - **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. - **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.
@@ -89,9 +89,15 @@ python -c "import sync_engine as se; a=next(x for x in se.parse_accounts_csv() i
- 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. - 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). - `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).
## ACCIÓN (3ª tanda — reasignación opp por homónimos)
Al ir a "crear la opp de miguel en Temixco" se descubrió que **NO procedía**: el lead digital $80,200 es de OTRO "Miguel Angel" (Eugenia `+525530454950`, contacto Marca `hE9U9Q`), distinto del Miguel-Temixco (`+527775114949`, contacto `RwxMQr0`). Ese lead ya estaba replicado (Eugenia `kGda02`↔Marca `1A3P5b`), pero `1A3P5b` colgaba del contacto EQUIVOCADO (`RwxMQr0`) porque la réplica n8n matcheó por nombre. `hE9U9Q` (su dueño real) tenía 0 opps.
- **Fix:** `PUT /opportunities/1A3P5b` con `contactId=hE9U9Q` (+ name/pipeline/stage/mv). **GHL v2 SÍ acepta cambiar contactId vía PUT** (status 200) — el gotcha de `build_brand_opp_payload` no aplicó. run_id `reassign-miguel-opp-20260530`, snapshot `reassign_miguel_opp_20260530_155526.json`. Verificado: RwxMQr0 solo $56,671, hE9U9Q solo $80,200.
## PENDIENTES ## 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`. - **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice.
- **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. - **Causa raíz n8n homónimos**: la réplica Sucursal→Marca debe matchear por teléfono/`id_contacto_sucursal` antes que por nombre (si no, vuelve a colgar opps de homónimos en el contacto equivocado). Ver [[matching_rules]], [[n8n_workflows_v2_hardened]].
- ~~Leads digitales nuevos~~ **RESUELTO**: `David Arturo Vega` (Satélite) y `PER,LA MARIA VILLA` (La Viga) aparecieron ~15:56 (actividad viva) como descuadre +2. NO eran leads sin cascar: sus contactos+opps YA estaban en sucursal; el +2 eran **réplicas duplicadas en Marca sin link** (mismo patrón isai/Patricia, pero sin link compartido → caían en `missing_id_field`, no `duplicate_link`). Fix: (1) TIENDA llenada (`leads-tienda-20260530`); (2) borradas las 2 duplicadas sin link `LJPKIsd`+`OyRtrlGb`, conservadas las enlazadas `Mt9Dafz3`+`aZf3pzuc` (run_id `delete-dup-leads-opps-20260530`, snapshot `delete_dup_leads_opps_20260530_160744.json`). → opps 1342=1342, diff 0.
- Patrón a vigilar: el n8n de opps sigue generando réplicas duplicadas en Marca (Patricia, isai, PER,LA, David — 4 en un día). Unas con link compartido, otras sin link. La causa raíz (idempotencia) está en [[n8n_opp_idempotency_baserow_mapping]]; revisar por qué siguen apareciendo.
## ENLACES ## 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]] - 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]]
@@ -0,0 +1,94 @@
---
id: CASE-2026-05-30-incoherencia-canal-fuente-lead-digital
fecha: 2026-05-30
categoria: custom_field, cascada_n8n, descuadre
location_ids: [GbKkBpCmKu2QmloKFHy3 (Monte Providencia/Marca), KEZ7dAhgwzK4uZfMvZuj (Puebla), uJEn2iuUficuml9zxAnt (Cancún), uZnMH5bO6MXTHcgHeyq9 (Pilares), nF1uEaYB3mCK5em9bPn2 (Eugenia), jE41bVhhnb5T505BFm4F (Morelia 1), WCHyow6KpjLFYriQWdbJ (Tampico)]
run_ids: [origen-fuente-3c50e43ec6a9, "n8n ddUEORBEtZLzsQF2 versionId 11c7184b→e8f6f33f (Patrón A)→37f780e1 (Patrón B)"]
snapshots: [n8n/backup_fuente_reconcile_ddUEORBEtZLzsQF2_20260530_190108.json, n8n/backup_redes_sociales_ddUEORBEtZLzsQF2_20260530_192343.json]
status: resuelto
memorias: [[incoherencia_canal_fuente_lead_digital]], [[super_script_fix_branch_user_origin]], [[n8n_2004_canal_origen_complemento]], [[createdby_only_in_individual_get]]
playbooks: []
---
## TRIGGERS
- `LEAD DIGITAL no cuadra con FORMULARIO + FACEBOOK`, `Recuento de Contactos por CANAL DE ORIGEN vs Fuente de prospecto`
- `929 vs 901`, `descuadre 28 LEAD DIGITAL`, `Canal=SUCURSAL pero Fuente=LEAD DIGITAL`, viola AGENTS Cap.3
- `REDES SOCIALES` fuente de prospecto (valor no canónico), Tampico 7 contactos
- `fix_branch_user_origin.py NO toca Fuente de Prospecto`, n8n `[2004]` pone Canal=SUCURSAL a WEB_USER sin reconciliar Fuente
- `createdBy.source WEB_USER MOBILE_USER` = captura manual sucursal; `source` de Marca (Formulario/Sucursal) lo estampa la replicación n8n y NO es fiable
- scripts: `audit_origen_fuente_incoherencia.py`, `fix_origen_fuente_incoherencia.py`
## SÍNTOMA
Panel de Marca (GbKkBpCmKu2QmloKFHy3, 1328 contactos). Dos widgets descuadran:
- `Recuento por CANAL DE ORIGEN`: FORMULARIO 612, SUCURSAL 426, FACEBOOK 289 (digital = 901).
- `Fuente de prospecto`: LEAD DIGITAL 929, SUCURSAL 374, PROSPECCIÓN 11, REDES SOCIALES 7, CLIENTE CONOCIDO 3, ALIANZA 2, GALLARDETES 1.
Regla: LEAD DIGITAL debería = FORMULARIO + FACEBOOK. 929 ≠ 901 → descuadre +28. Además REDES SOCIALES sospechoso (canal digital mal clasificado).
## DIAGNÓSTICO
Cross-tab Canal × Fuente desde la cache (`generated/data/mp_manager.sqlite`, `contacts.custom_fields_json` + `object_schemas`):
- FUENTE=LEAD DIGITAL (929) → 612 FORMULARIO + 289 FACEBOOK + **28 SUCURSAL** ← el descuadre.
- FUENTE=REDES SOCIALES (7) → 7 canal=SUCURSAL.
- PROSPECCIÓN/CLIENTE CONOCIDO/ALIANZA/GALLARDETES → todos canal=SUCURSAL (OK, fuentes de prospección manual válidas).
Global (todas las cuentas): los 28 incoherentes de Marca = 16 Puebla + 4 Cancún + 3 Pilares + 3 Eugenia + 2 Morelia (suma 28 → replica 1:1 a Marca). Los 7 REDES SOCIALES = todos Tampico. **La incoherencia nace en sucursal, no es artefacto de replicación.**
Callejón descartado: el `source` de los contactos en Marca decía 19 "Sucursal" / 9 "Formulario", sugiriendo partir los 28 en dos. PERO el `source` del lado **sucursal** era 26 null + 2 Formulario (no coincide). Verificación en vivo con `createdBy.source` (GET individual, `audit_origen_fuente_incoherencia.py --all`): **los 28 son WEB_USER/MOBILE_USER** (captura manual sucursal), incluidos los 9 que en Marca figuraban "Formulario". El `source` de Marca lo estampa la replicación n8n y NO es fiable; `createdBy.source` es el gold standard.
## CAUSA RAÍZ
1. Workflow n8n `[2004]` (ddUEORBEtZLzsQF2) y su gemelo Python `fix_branch_user_origin.py` ponen `Canal de Origen=SUCURSAL` a los contactos creados por empleado (WEB_USER/MOBILE_USER) **pero deliberadamente NO tocan "Fuente de Prospecto"** ([fix_branch_user_origin.py:27-29,234](../../scripts/fix_branch_user_origin.py#L234)) para no pisar ALIANZA/PROSPECCIÓN. Efecto colateral: cuando voltean canal a SUCURSAL dejan el `LEAD DIGITAL` viejo huérfano → los 28 (Patrón A).
2. En Tampico se usó el valor no canónico `REDES SOCIALES` como Fuente de Prospecto en 7 contactos digitales (social), que quedaron con canal=SUCURSAL (Patrón B).
## ACCIÓN
Decisión del usuario (2 rondas AskUserQuestion): Patrón A → todos a SUCURSAL (createdBy manda sobre source de Marca); Patrón B → FACEBOOK + LEAD DIGITAL.
Script nuevo `scripts/fix_origen_fuente_incoherencia.py` (idempotente, detecta por estado actual, dry-run default, `script_audit` reversible, resuelve field ids por FIELD_ALIASES). Plan por patrón:
- A: contacto Fuente→SUCURSAL; opps: Canal de Origen de la Oportunidad→Sucursal, Fuente→SUCURSAL.
- B: contacto Canal→FACEBOOK + Fuente→LEAD DIGITAL; opps: Canal Opp→Facebook, Tipo de Lead→Lead digital, Fuente→LEAD DIGITAL.
Secuencia (RUN_ID `origen-fuente-3c50e43ec6a9`):
```
# dry-run (las 7 cuentas) -> A=28, B=7, 35 opps
# piloto Tampico (Patrón B)
python scripts/fix_origen_fuente_incoherencia.py --location WCHyow6KpjLFYriQWdbJ --apply --run-id origen-fuente-3c50e43ec6a9
# verificación live 7/7 OK -> batch resto:
for LOC in GbKkBpCmKu2QmloKFHy3 KEZ7dAhgwzK4uZfMvZuj uJEn2iuUficuml9zxAnt uZnMH5bO6MXTHcgHeyq9 nF1uEaYB3mCK5em9bPn2 jE41bVhhnb5T505BFm4F; do
python scripts/fix_origen_fuente_incoherencia.py --location $LOC --apply --run-id origen-fuente-3c50e43ec6a9; done
```
Marca en el batch reportó B=1 (no 7): los otros 6 REDES SOCIALES de Marca ya se habían corregido solos por replicación n8n desde Tampico. 0 errores en todas.
## VERIFICACIÓN
Cross-tab **en vivo** en Marca (re-GET de 1328+ contactos vía API, no cache):
- LEAD DIGITAL = 908 = FORMULARIO 612 + FACEBOOK 296 → **COHERENTE** (antes 929 vs 901).
- canal=SUCURSAL & fuente=LEAD DIGITAL: **0** (antes 28).
- REDES SOCIALES: **0** (antes 7).
- Canal SUCURSAL 419 (antes 426; 7 que pasaron a FACEBOOK).
Piloto Tampico verificado 7/7 (canal=FACEBOOK, fuente=LEAD DIGITAL sostenido; n8n [2004] no revirtió en el momento).
## EDGE-CASES / TRAMPAS
- **NO confiar en `contact.source` de Marca** para clasificar origen: la replicación n8n lo estampa (Formulario/Sucursal) y diverge del lado sucursal. Usar `createdBy.source` en vivo (GET individual).
- Tampico tiene los campos con capitalización distinta (`CANAL DE ORIGEN`): resolver field ids **case-insensitive** / por FIELD_ALIASES, nunca por nombre exacto.
- Falso positivo en verificación: match difuso de nombre ("edgar") atrapó "edgar alejandro pozos" y "edgard radiadores" (PROSPECCIÓN/SUCURSAL, no eran target). Filtrar por id, no por substring de nombre.
- Riesgo de reversión del Patrón B: los 7 de Tampico son WEB_USER y n8n [2004] re-pone canal=SUCURSAL a WEB_USER. Si se re-editan, podrían revertir. Pendiente endurecer [2004].
## REUTILIZABLE
- `python scripts/audit_origen_fuente_incoherencia.py --all` → clasifica incoherentes con createdBy en vivo (read-only).
- `python scripts/fix_origen_fuente_incoherencia.py --all` (dry-run) / `--apply --run-id <uuid>` → corrige + propaga a opps. Idempotente: re-correr no re-toca.
- Rollback: dashboard → run `origen-fuente-3c50e43ec6a9`.
## CAUSA RAÍZ — CIERRE EN TIEMPO REAL (2026-05-30)
Endurecido el workflow n8n **[2004]** (ddUEORBEtZLzsQF2) con `n8n/_add_fuente_reconcile.py` (backup→dry-run→apply→verify→E2E). En la rama "Creado por usuario" (esUsuario=true), tras `Tag- facebook-ads` se añadió: IF `fuenteEsLeadDigital` → PUT Fuente de Prospecto=SUCURSAL. El Code node se extendió para resolver el field `contact.fuente_de_prospecto` y leer su VALOR ACTUAL del GET del contacto (`contact.customFields[].value`), exponiendo `fuente`/`fuenteActual`/`fuenteEsLeadDigital`. Solo reconcilia si vale exactamente LEAD DIGITAL → preserva ALIANZA/PROSPECCIÓN/etc. versionId `11c7184b``e8f6f33f`, 19→21 nodos. Backup `n8n/backup_fuente_reconcile_ddUEORBEtZLzsQF2_20260530_190108.json`.
**E2E en vivo (Cancún, esau sotelo do3ClHt57hfHj0hw4tKk, WEB_USER):** (1) ensuciado FORMULARIO/LEAD DIGITAL → webhook 8d574598 → **SUCURSAL/SUCURSAL** ✓. (2) ensuciado FORMULARIO/PROSPECCIÓN → webhook → **SUCURSAL/PROSPECCIÓN intacta** ✓ (preservación). Contacto restaurado a su estado real SUCURSAL/SUCURSAL.
## CAUSA RAÍZ PATRÓN B — CIERRE EN TIEMPO REAL (2026-05-30)
Cerrado con `n8n/_add_redes_sociales_branch.py`. En la rama esUsuario, ANTES del PUT canal=SUCURSAL, se intercaló IF "Fuente es REDES SOCIALES": [true]→PUT Canal=FACEBOOK→PUT Fuente=LEAD DIGITAL (camino digital, igual que el fix de datos Patrón B); [false]→PUT Canal=SUCURSAL (camino existente intacto). Code node expone `fuenteEsRedesSociales`. versionId `e8f6f33f``37f780e1`, 21→24 nodos. Backup `n8n/backup_redes_sociales_ddUEORBEtZLzsQF2_20260530_192343.json`.
**E2E vivo (Cancún, esau):** T1 SUCURSAL/REDES SOCIALES→**FACEBOOK/LEAD DIGITAL** ✓; T2 regresión FORMULARIO/LEAD DIGITAL→**SUCURSAL/SUCURSAL** ✓ (la rama Patrón A sigue intacta por el false branch). Restaurado.
## PENDIENTES
1. [HECHO 2026-05-30] Causa raíz Patrón A cerrada en [2004] (`_add_fuente_reconcile.py`). Nota: `fix_branch_user_origin.py` (batch) sigue sin reconciliar Fuente, pero `fix_origen_fuente_incoherencia.py` cubre el backlog y [2004] cubre lo nuevo en tiempo real.
2. [HECHO 2026-05-30] Causa raíz Patrón B (REDES SOCIALES) cerrada en [2004] (`_add_redes_sociales_branch.py`, ver sección arriba).
3. Re-sync de la cache local de las 7 cuentas (el panel GHL ya está bien; la cache SQLite quedó stale para los 35 contactos).
## ENLACES
- Memorias: [[incoherencia_canal_fuente_lead_digital]], [[super_script_fix_branch_user_origin]], [[n8n_2004_canal_origen_complemento]], [[createdby_only_in_individual_get]], [[sucursal_tag_on_digital_leads]]
- Scripts: `scripts/fix_origen_fuente_incoherencia.py`, `scripts/audit_origen_fuente_incoherencia.py`, `n8n/_add_fuente_reconcile.py` (cierre raíz Patrón A [2004]), `n8n/_add_redes_sociales_branch.py` (cierre raíz Patrón B [2004]), `n8n/_add_canal_origen_branch.py` (rama previa), `scripts/fix_branch_user_origin.py`, `scripts/fuente_prospecto_workflow.py`, `scripts/tag_canal_origen_workflow.py`, `scripts/canal_origen_resolver.py`
@@ -0,0 +1,63 @@
---
id: CASE-2026-05-30-opp-marca-cf-vacios-mapeo-contacto
fecha: 2026-05-30
categoria: cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "yjqKxoO02rsdwdJZSPmD (85950 - MP - Temixco)"]
run_ids: ["backfill-opp-cf-20260530-164821"]
snapshots: ["generated/migrations/backfill_opp_cf_8HITkGkOn3gN23Tl8LBr_backfill-opp-cf-20260530-164821.json"]
status: resuelto
memorias: ["[[n8n_opp_sync_match]]", "[[opp_multiplicity_replication_gap]]", "[[sucursal_to_marca_cf_drop_on_create]]", "[[sucursal_datatype_divergence_intentional]]"]
playbooks: ["docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- opp de Marca con `Sucursal` / `TIENDA` / `Canal de Origen` (CANAL DE ORIGEN) **vacíos**
- "desde sucursal no se están trayendo los datos de las oportunidades"
- opp de Marca con **solo** `opportunity.id_oportunidad_sucursal` y ningún otro CF (opp "solo-link")
- workflow `Cfgwp0bOtDW8zuKW` "Sincronizar Oportunidad - Nodos Nuevos (Create/Update)"
- `8HITkGkOn3gN23Tl8LBr` (opp Marca), `OQBrOQN9mNlybjlzB8Jk` (opp Temixco), Miguel Angel `mahernandez2282@gmail.com`
- `opportunity.fuente_de_posible_cliente` == CANAL DE ORIGEN (opp y contacto comparten el sufijo de fieldKey)
## SÍNTOMA
La opp de Marca `8HITkGkOn3gN23Tl8LBr` (Miguel Angel, viene de Temixco) tenía vacíos Sucursal, TIENDA y Canal de Origen. Hipótesis del owner: la réplica n8n no mapea esos campos.
## DIAGNÓSTICO (read-only)
- `get_opportunities(GbKkBpCmKu2QmloKFHy3)` agregado por CF: de **1342** opps de Marca, `opportunity.sucursal` poblado en **1341**, `tienda` 1295, `fuente_de_posible_cliente` 711. → el mapeo genérico SÍ funciona; `8HIT` era la **única** opp "solo-link".
- Los nodos `Armar Body - CREATE` y `Armar Body - UPDATE (v2)` ya copian *todos* los CF de la opp de sucursal → Marca por `fieldKey` (fallback `name`), enriquecidos por `Mapeo completo oportunidad origen - SUCURSAL`.
- La opp de sucursal `OQBrOQN9…` SÍ tiene `opportunity.sucursal`="Temixco, Morelos", `tienda`="TEMIXCO", pero **NO** tiene `fuente_de_posible_cliente` (CANAL DE ORIGEN). El **contacto** de Temixco (`eE6374FcwI7zlmQmTgGO`) sí: `contact.fuente_de_posible_cliente`="SUCURSAL".
- Schema en vivo: el `fieldKey` canónico (sufijo) es **idéntico** contact↔opportunity: `*.sucursal`, `*.tienda`, `*.fuente_de_posible_cliente`. `contact.sucursal` es SINGLE_OPTIONS pero su label = el texto-ciudad que `opportunity.sucursal` (TEXT) espera ([[sucursal_datatype_divergence_intentional]]).
## CAUSA RAÍZ
1. **Caso puntual `8HIT`**: opp creada fuera del flujo normal (creación manual + `backfill_opp_sucursal_link.py`, que solo escribe el link) — por eso quedó solo-link. No es bug del mapeo genérico.
2. **Gap general**: cuando la opp de sucursal NO trae un CF (p.ej. CANAL DE ORIGEN, que vive solo en el contacto, o por carrera de tiempo intermitente [[sucursal_to_marca_cf_drop_on_create]]), Marca queda sin ese campo. El contacto es la fuente estable que faltaba consultar.
## ACCIÓN
1. **Workflow `Cfgwp0bOtDW8zuKW`** (script `n8n/_add_contact_to_opp_mapping.py`, dry-run → confirmación → `--apply`):
- Nodo nuevo `Obtener Contacto - SUCURSAL` (GET `/contacts/{{ Datos de Lead.Cliente['Contact ID'] }}`, token sucursal, `onError=continue`) insertado entre `Obtener info de Oportunidad - SUCURSAL` y `Obtener Pipelines - SUCURSAL`.
- Code `Mapeo completo oportunidad origen - SUCURSAL` extendido: upsert de `opportunity.sucursal` / `tienda` / `fuente_de_posible_cliente` con prioridad **(a)** opp sucursal → **(b)** contacto → **(c)** webhook. El loop genérico de `Armar Body` lo propaga a Marca por `fieldKey` (sin race lectura/escritura).
- Aplicado y verificado (`verify_post`), workflow reactivado. Backups en `n8n/backup_contact_to_opp_mapping_Cfgwp0bOtDW8zuKW_*.json`.
2. **Backfill `8HIT`** (`scripts/backfill_brand_opp_cf_from_source.py --brand-opp-id 8HITkGkOn3gN23Tl8LBr --branch-location-id yjqKxoO02rsdwdJZSPmD --apply`): rellenó 5 CF vacíos desde la opp de sucursal + contacto. `run_id=backfill-opp-cf-20260530-164821` (reversible desde dashboard).
## VERIFICACIÓN
`GET /opportunities/8HITkGkOn3gN23Tl8LBr` (Marca) — después:
- Sucursal = `Temixco, Morelos`, TIENDA = `TEMIXCO`, CANAL DE ORIGEN = `SUCURSAL` (del contacto), Fuente de Prospecto = `LEAD DIGITAL`, Vehículo = `March 2014`, link intacto.
## EDGE-CASES / TRAMPAS
- **No confundir** `opportunity.fuente_de_prospecto` (LEAD DIGITAL / REFERIDO / REDES SOCIALES) con `opportunity.fuente_de_posible_cliente` (= CANAL DE ORIGEN: SUCURSAL / FACEBOOK / FORMULARIO / WHATSAPP). Mismo nombre "Fuente…" en GHL, fieldKeys distintos.
- **No** sourcear `8HIT` del contacto de **Marca** (`RwxMQr0`): por el lío de homónimos su `contact.sucursal`="Narvarte Oriente…" (incorrecto para Temixco). La opp de **sucursal** es la fuente limpia.
- El valor de CANAL DE ORIGEN debe ser opción válida del picklist de Marca (`SUCURSAL` lo es). [[custom_fields_picklist_alignment]]
- El backfill solo rellena campos **vacíos** (no sobreescribe).
## REUTILIZABLE
- Agregado por CF para detectar opps "solo-link" en Marca: cargar el dump de `get_opportunities` y contar ids de CF (1342 vs 1341 reveló el outlier).
- `scripts/backfill_brand_opp_cf_from_source.py`: backfill genérico de CF descriptivos de una opp de Marca desde su opp de sucursal enlazada (+ contacto como respaldo), dry-run/snapshot/audit.
## PENDIENTES
- Validar E2E el cambio del workflow en la **próxima sincronización real** de una opp de Temixco (o construir un E2E para `Cfgwp0bOtDW8zuKW`, hoy el harness `scripts/n8n_e2e_test.py` solo cubre los workflows de contactos).
- (Opcional 2ª iteración) Persistir también los 3 campos en la opp de **sucursal** vía el PUT de `Mapear ID Oportunidad Sucursal - SUCURSAL`.
- Re-sync de Marca para refrescar el cache SQLite del dashboard.
## ENLACES
- [[n8n_opp_sync_match]] · [[opp_multiplicity_replication_gap]] · [[sucursal_to_marca_cf_drop_on_create]] · [[sucursal_datatype_divergence_intentional]] · [[custom_fields_picklist_alignment]]
- `n8n/_add_contact_to_opp_mapping.py` · `scripts/backfill_brand_opp_cf_from_source.py`
- docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
+2
View File
@@ -34,6 +34,8 @@ No registres: lecturas triviales, consultas de un solo dato, o trabajo puramente
| Fecha | ID | Categoría | Triggers (keywords para grep) | Status | Enlace | | Fecha | ID | Categoría | Triggers (keywords para grep) | Status | Enlace |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 2026-05-30 | CASE-2026-05-30-incoherencia-canal-fuente-lead-digital | custom_field, cascada_n8n, descuadre | `LEAD DIGITAL no cuadra con FORMULARIO + FACEBOOK`, `929 vs 901 descuadre 28`, `Canal=SUCURSAL pero Fuente=LEAD DIGITAL`, `REDES SOCIALES fuente no canónica Tampico`, `fix_branch_user_origin NO toca Fuente`, `[2004] pone Canal=SUCURSAL a WEB_USER sin reconciliar Fuente`, `source de Marca lo estampa n8n no es fiable usar createdBy.source`, `fix_origen_fuente_incoherencia.py`, `audit_origen_fuente_incoherencia.py`, `run origen-fuente-3c50e43ec6a9` | resuelto | [caso](2026-05-30-incoherencia-canal-fuente-lead-digital.md) |
| 2026-05-30 | CASE-2026-05-30-opp-marca-cf-vacios-mapeo-contacto | cascada_n8n, custom_field | `opp Marca Sucursal TIENDA Canal de Origen vacíos`, `desde sucursal no se traen datos de oportunidades`, `opp solo-link solo id_oportunidad_sucursal`, `Cfgwp0bOtDW8zuKW mapear campos del contacto a la opp`, `Obtener Contacto - SUCURSAL nodo nuevo`, `Mapeo completo oportunidad origen - SUCURSAL`, `8HITkGkOn3gN23Tl8LBr OQBrOQN9mNlybjlzB8Jk Miguel mahernandez2282`, `fuente_de_posible_cliente CANAL DE ORIGEN del contacto`, `backfill_brand_opp_cf_from_source.py` | resuelto | [caso](2026-05-30-opp-marca-cf-vacios-mapeo-contacto.md) |
| 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-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-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-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) |
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# mp_common.sh — utilidades compartidas por los launchers de macOS/Linux
# (start.command, stop.command, restart.command, start_persistent_profile.command
# y setup_mac.command).
#
# No se ejecuta directamente: los demás scripts lo cargan con `source`.
# Equivale a la lógica común que en Windows vive embebida en los .bat.
# ---------------------------------------------------------------------------
# Raíz del proyecto = carpeta donde vive este archivo. Resuelve symlinks para
# que funcione aunque el .command se invoque desde otra ruta.
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
PROJECT_DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
cd "$PROJECT_DIR" || exit 1
VENV_DIR="$PROJECT_DIR/.venv"
VENV_PY="$VENV_DIR/bin/python"
# Versión mínima de Python que exige el proyecto (3.10+).
PY_MIN_MAJOR=3
PY_MIN_MINOR=10
# Colorea solo si la salida es una terminal interactiva.
if [ -t 1 ]; then
C_RESET="\033[0m"; C_BOLD="\033[1m"; C_RED="\033[31m"; C_YEL="\033[33m"; C_GRN="\033[32m"
else
C_RESET=""; C_BOLD=""; C_RED=""; C_YEL=""; C_GRN=""
fi
info() { printf "${C_BOLD}[SISTEMA]${C_RESET} %s\n" "$1"; }
ok() { printf "${C_GRN}[OK]${C_RESET} %s\n" "$1"; }
warn() { printf "${C_YEL}[ADVERTENCIA]${C_RESET} %s\n" "$1"; }
err() { printf "${C_RED}[ERROR]${C_RESET} %s\n" "$1" >&2; }
# Imprime un encabezado tipo banner como los .bat de Windows.
banner() {
echo "==================================================="
echo " $1"
echo "==================================================="
echo
}
# True si "$1 --version" reporta >= PY_MIN.
_py_is_recent_enough() {
local cmd="$1"
"$cmd" -c "import sys; sys.exit(0 if sys.version_info[:2] >= ($PY_MIN_MAJOR, $PY_MIN_MINOR) else 1)" 2>/dev/null
}
# Localiza un intérprete del sistema 3.10+ para crear el venv. Prueba nombres
# versionados explícitos (más fiables que 'python3' a secas en macOS, donde el
# 'python3' del sistema suele ser viejo). Imprime la ruta o vacío.
find_system_python() {
local candidates=(python3.13 python3.12 python3.11 python3.10 python3 python)
for c in "${candidates[@]}"; do
if command -v "$c" >/dev/null 2>&1 && _py_is_recent_enough "$c"; then
command -v "$c"
return 0
fi
done
return 1
}
# Devuelve el python del venv si existe; si no, vacío. Los launchers que
# requieren dependencias instaladas deben llamar a require_venv en su lugar.
venv_python() {
if [ -x "$VENV_PY" ]; then
echo "$VENV_PY"
return 0
fi
return 1
}
# Garantiza que el venv exista; si no, da instrucciones y aborta. Imprime la
# ruta del intérprete del venv por stdout (para capturarla con $(...)).
require_venv() {
if [ ! -x "$VENV_PY" ]; then
err "No se encontró el entorno virtual (.venv)."
err "Ejecuta primero (doble clic): setup_mac.command"
return 1
fi
echo "$VENV_PY"
}
# Pausa al final cuando el script se abrió con doble clic en Finder, para que la
# ventana de Terminal no se cierre de golpe y el usuario pueda leer el resultado.
# Si se ejecutó desde una terminal interactiva (uso avanzado), no estorba.
hold_window() {
echo
read -r -p "Presiona ENTER para cerrar esta ventana..." _ || true
}
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Garantiza que la opp sincronizada a Marca traiga Sucursal / TIENDA / Canal de
Origen, derivándolos del CONTACTO de sucursal cuando la opp de origen no los
trae (workflow `Cfgwp0bOtDW8zuKW` "Sincronizar Oportunidad - Nodos Nuevos").
Contexto: los nodos `Armar Body - CREATE/UPDATE` ya copian TODOS los custom
fields de la opp de sucursal → Marca por `fieldKey`. Pero si la opp de origen
llega sin esos CF (carrera de tiempo / creación fuera de flujo), la opp de Marca
queda vacía (caso `8HIT…` de Miguel Temixco). El CONTACTO siempre los tiene
(poblados por [1604]/[2004]) y usa el MISMO `fieldKey` canónico que la opp:
contact.sucursal -> opportunity.sucursal
contact.tienda -> opportunity.tienda
contact.fuente_de_posible_cliente-> opportunity.fuente_de_posible_cliente (CANAL DE ORIGEN)
Cambios (solo AÑADE / enriquece, preserva el flujo actual):
1. Nodo nuevo `Obtener Contacto - SUCURSAL` (GET /contacts/{id}) insertado
entre `Obtener info de Oportunidad - SUCURSAL` y `Obtener Pipelines - SUCURSAL`.
2. Extiende el Code node `Mapeo completo oportunidad origen - SUCURSAL` para,
tras enriquecer los CF de la opp, hacer upsert de Sucursal/TIENDA/Canal con
prioridad (a) valor de la opp → (b) valor del contacto → (c) webhook.
El loop genérico de `Armar Body` los propaga a Marca por `fieldKey`.
No depende de leer-después-de-escribir (sin race).
Uso:
python n8n/_add_contact_to_opp_mapping.py # dry-run (dumpea payload)
python n8n/_add_contact_to_opp_mapping.py --apply # aplica + reactiva
"""
import argparse
import os
import sys
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)
from scripts.n8n_workflow_lib import N8NClient, load_credentials # noqa: E402
WID = "Cfgwp0bOtDW8zuKW"
# Nodos existentes (referencias).
MAPEO_NODE = "Mapeo completo oportunidad origen - SUCURSAL"
OBTENER_INFO_NODE = "Obtener info de Oportunidad - SUCURSAL"
OBTENER_PIPELINES_NODE = "Obtener Pipelines - SUCURSAL"
DATOS_API_SUCURSAL = "DATOS API - SUCURSAL"
CF_DEFS_NODE = "Conseguir Custom Fields - Opportunity - SUCURSAL"
# Nodo nuevo.
GET_CONTACT_NODE = "Obtener Contacto - SUCURSAL"
# Marca de idempotencia dentro del jsCode extendido.
ENRICH_MARKER = "CONTACT->OPP ENRICH"
CONTACT_ID_EXPR = "{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}"
TOKEN_EXPR = "{{ $('" + DATOS_API_SUCURSAL + "').item.json['Token/API'] }}"
# Bloque que se inyecta en el Code node, justo antes de `const stageInfo`.
ENRICH_BLOCK = r"""
// ── CONTACT->OPP ENRICH: garantizar Sucursal / TIENDA / Canal de Origen ──
// Respaldo cuando la opp de sucursal no trae el CF: tomar el valor del CONTACTO
// (siempre poblado por [1604]/[2004]) y, en ultimo caso, del webhook. El fieldKey
// canonico (sufijo) es identico en contact y opportunity.
const contactResp = $('Obtener Contacto - SUCURSAL').first().json;
const contact = (contactResp && contactResp.contact) ? contactResp.contact : (contactResp || {});
const webhookBody = ($('Webhook').first().json && $('Webhook').first().json.body) || {};
const norm2 = (s) => (s == null ? '' : String(s)).trim();
// Valores del contacto por fieldKey (contacts traen {id, value}).
const contactValueByFieldKey = {};
for (const cf of (contact.customFields || [])) {
const fk = fieldMap[cf.id] ? fieldMap[cf.id].fieldKey : null;
if (fk) contactValueByFieldKey[fk] = cf.value;
}
const ENRICH_TARGETS = [
{ oppKey: 'opportunity.sucursal', contactKey: 'contact.sucursal', webhookKey: 'Sucursal' },
{ oppKey: 'opportunity.tienda', contactKey: 'contact.tienda', webhookKey: 'TIENDA' },
{ oppKey: 'opportunity.fuente_de_posible_cliente', contactKey: 'contact.fuente_de_posible_cliente', webhookKey: 'CANAL DE ORIGEN' },
];
for (const t of ENRICH_TARGETS) {
const existing = enrichedCustomFields.find(cf => cf.fieldKey === t.oppKey);
if (existing && norm2(existing.fieldValue) !== '') continue; // (a) ya viene de la opp
let value = norm2(contactValueByFieldKey[t.contactKey]); // (b) contacto
if (value === '') value = norm2(webhookBody[t.webhookKey]); // (c) webhook
if (value === '') continue;
if (existing) {
existing.fieldValue = value;
} else {
const def = customFieldsDefs.find(d => d.fieldKey === t.oppKey);
enrichedCustomFields.push({ id: def ? def.id : null, name: def ? def.name : null, fieldKey: t.oppKey, fieldValue: value });
}
}
// ── fin CONTACT->OPP ENRICH ──
"""
def ensure_model_all(cf_node):
"""Asegura que `Conseguir Custom Fields…` pase ?model=all (sin esto el
endpoint /locations/{id}/customFields solo devuelve campos de CONTACTO, y el
mapeo genérico descarta TODOS los CF nativos de la opp). Devuelve True si
cambió algo."""
p = cf_node.setdefault("parameters", {})
p["sendQuery"] = True
qp = p.setdefault("queryParameters", {})
params = qp.setdefault("parameters", [])
for entry in params:
if entry.get("name") == "model":
if entry.get("value") == "all":
return False
entry["value"] = "all"
return True
params.append({"name": "model", "value": "all"})
return True
def build_get_contact_node():
return {
"parameters": {
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR,
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization", "value": "=Bearer " + TOKEN_EXPR},
]
},
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1750, -32],
"name": GET_CONTACT_NODE,
"onError": "continueRegularOutput",
"notes": "Trae el contacto de sucursal para derivar Sucursal/TIENDA/Canal de Origen de la opp cuando la opp de origen no los trae. onError=continue para no romper el sync.",
}
def inject_enrich(js_code):
"""Inserta ENRICH_BLOCK antes de `const stageInfo` (anchor estable)."""
anchor_candidates = [
"// Resolve pipeline and stage info",
"const stageInfo =",
]
for anchor in anchor_candidates:
idx = js_code.find(anchor)
if idx != -1:
return js_code[:idx] + ENRICH_BLOCK + "\n" + js_code[idx:]
raise SystemExit(
"No se encontró un anchor para inyectar el bloque de enriquecimiento "
"en el jsCode del nodo Mapeo. Revisar el Code node manualmente."
)
def main():
parser = argparse.ArgumentParser(
description="Mapea Sucursal/TIENDA/Canal de Origen del contacto a la opp en el workflow Cfgwp0bOtDW8zuKW."
)
parser.add_argument("--apply", action="store_true", help="Aplica el PUT real (sin esto: dry-run).")
args = parser.parse_args()
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label="contact_to_opp_mapping")
prev_version = wf.get("versionId")
print(f"Workflow: {wf.get('name')}")
print(f" active={wf.get('active')} nodes={len(wf.get('nodes') or [])} versionId={prev_version}")
print(f" backup -> {backup_path}")
# Validar que existen los nodos de referencia.
for nm in (MAPEO_NODE, OBTENER_INFO_NODE, OBTENER_PIPELINES_NODE, DATOS_API_SUCURSAL, CF_DEFS_NODE):
if client.find_node(wf, nm) is None:
raise SystemExit(f"No se encontró el nodo esperado {nm!r}.")
# Idempotencia.
code_node = client.find_node(wf, MAPEO_NODE)
cf_node = client.find_node(wf, CF_DEFS_NODE)
already_node = client.find_node(wf, GET_CONTACT_NODE) is not None
already_code = ENRICH_MARKER in (code_node["parameters"].get("jsCode") or "")
cf_params = (cf_node.get("parameters") or {}).get("queryParameters", {}).get("parameters", [])
already_query = any(e.get("name") == "model" and e.get("value") == "all" for e in cf_params)
if already_node and already_code and already_query:
raise SystemExit("El complemento ya fue aplicado (nodo + jsCode + model=all). Nada que hacer.")
# 0. Asegurar ?model=all en el fetch de custom fields (trae defs de opp).
if ensure_model_all(cf_node):
print(f" Nodo {CF_DEFS_NODE!r}: query 'model=all' añadido (ahora trae defs de opportunity).")
else:
print(f" Nodo {CF_DEFS_NODE!r} ya tenía 'model=all'.")
# 1. Nodo nuevo GET contacto + cableado.
if not already_node:
client.add_node(wf, build_get_contact_node())
client.insert_between(wf, OBTENER_INFO_NODE, GET_CONTACT_NODE, OBTENER_PIPELINES_NODE)
print(f" Nodo {GET_CONTACT_NODE!r} insertado entre "
f"{OBTENER_INFO_NODE!r} y {OBTENER_PIPELINES_NODE!r}.")
else:
print(f" Nodo {GET_CONTACT_NODE!r} ya existía (no se re-inserta).")
# 2. Enriquecer el Code node.
if not already_code:
code_node["parameters"]["jsCode"] = inject_enrich(code_node["parameters"]["jsCode"])
print(f" Code node {MAPEO_NODE!r}: bloque de enriquecimiento inyectado.")
else:
print(f" Code node {MAPEO_NODE!r} ya tenía el bloque (no se re-inyecta).")
expected = [GET_CONTACT_NODE]
if not args.apply:
res = client.put_workflow(WID, wf, dry_run=True)
print(f"\nDRY-RUN. Payload -> {res['path']} ({res['node_count']} nodos).")
print("Revisa el JSON y vuelve a correr con --apply para aplicar.")
return
was_active = bool(wf.get("active"))
if was_active:
try:
client.deactivate(WID)
print(" Workflow desactivado para PUT estructural.")
except Exception as exc:
print(f" WARN al desactivar: {exc}")
client.put_workflow(WID, wf, dry_run=False)
print(" PUT aplicado.")
if was_active:
client.activate(WID)
print(" Workflow reactivado.")
client.verify_post(WID, expected_node_names=expected, prev_version_id=prev_version)
print("\nOK: complemento aplicado y verificado. Backup en:", backup_path)
if __name__ == "__main__":
main()
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Endurece el workflow n8n [2004] (ddUEORBEtZLzsQF2) para CERRAR LA CAUSA RAÍZ
del Patrón A (Canal=SUCURSAL & Fuente=LEAD DIGITAL).
Contexto: la rama "Creado por usuario" (añadida por `_add_canal_origen_branch.py`)
pone Canal de Origen=SUCURSAL a los contactos WEB_USER/MOBILE_USER pero NO toca
Fuente de Prospecto, dejando huérfano un `LEAD DIGITAL` previo -> incoherencia.
Este complemento (solo AÑADE, preserva el flujo) reconcilia la Fuente SOLO cuando
vale exactamente "LEAD DIGITAL" (preserva ALIANZA / PROSPECCIÓN / CLIENTE CONOCIDO
/ etc., que con Canal=SUCURSAL son válidas):
1. Extiende el Code node para resolver el field "Fuente de Prospecto"
(fieldKey `contact.fuente_de_prospecto`, fallback por nombre) y leer su VALOR
ACTUAL del GET individual del contacto -> expone `fuente`, `fuenteActual`,
`fuenteEsLeadDigital`.
2. Tras `Tag- facebook-ads` añade:
IF "Fuente = LEAD DIGITAL (reconciliar)" -> [true] PUT Fuente=SUCURSAL
[false] (fin: no se toca).
Solo corre dentro de la rama esUsuario=true (ya gateada aguas arriba por el IF
"Creado por usuario"). El [2004] dispara al CREAR contacto, así que no revierte
ediciones manuales posteriores.
Uso:
python n8n/_add_fuente_reconcile.py # dry-run (dumpea payload)
python n8n/_add_fuente_reconcile.py --apply # aplica + reactiva
"""
import argparse
import os
import sys
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)
from scripts.n8n_workflow_lib import N8NClient, load_credentials # noqa: E402
WID = "ddUEORBEtZLzsQF2"
# Nodos existentes (referencias).
CODE_NODE = 'Buscar "contact.sucursal" y "contact.tienda"'
GET_CONTACT_NODE = "Obtener Contacto Cuenta Origen - SUCURSAL"
VERIFICADOR_NODE = "Buscar Sucursal en Verificador de Sucursales"
TAG_RM_FB_NODE = "Tag- facebook-ads" # último nodo de la rama canal de origen
# Nodos nuevos.
IF_FUENTE_NODE = "Fuente = LEAD DIGITAL (reconciliar)"
PUT_FUENTE_NODE = "PUT Fuente de Prospecto = SUCURSAL"
CONTACT_ID_EXPR = "{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}"
TOKEN_EXPR = "{{ $('" + VERIFICADOR_NODE + "').item.json['SC TOKEN BUCEFALO'] }}"
CODE_REF = "$('" + CODE_NODE + "').item.json"
HEADERS = [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization", "value": "=Bearer " + TOKEN_EXPR},
]
# Code node extendido: preserva VERBATIM la lógica sucursal/tienda/canal/esUsuario
# y AÑADE la resolución de Fuente + su valor actual + flag fuenteEsLeadDigital.
NEW_JSCODE = r"""const customFields = $input.first().json.customFields;
function findField(key, names) {
let f = customFields.find(x => x.fieldKey === key);
if (!f) {
const wanted = names.map(n => n.toLowerCase().trim());
f = customFields.find(x => wanted.includes((x.name || "").toLowerCase().trim()));
}
return f || null;
}
const sucursalField = findField("contact.sucursal", ["Sucursal"]);
const tiendaField = findField("contact.tienda", ["TIENDA", "Tienda"]);
const canalField = findField("contact.fuente_de_posible_cliente", ["CANAL DE ORIGEN", "Canal de Origen"]);
const fuenteField = findField("contact.fuente_de_prospecto", ["Fuente de Prospecto", "FUENTE DE PROSPECTO"]);
// createdBy.source SOLO viene del GET individual del contacto.
const contactResp = $('Obtener Contacto Cuenta Origen - SUCURSAL').item.json;
const createdBySource =
(contactResp && contactResp.contact && contactResp.contact.createdBy && contactResp.contact.createdBy.source) ||
(contactResp && contactResp.createdBy && contactResp.createdBy.source) ||
null;
const esUsuario = createdBySource === "WEB_USER" || createdBySource === "MOBILE_USER";
// Valor ACTUAL de "Fuente de Prospecto" en el contacto (para reconciliar SOLO si = LEAD DIGITAL).
const contactCFs =
(contactResp && contactResp.contact && contactResp.contact.customFields) ||
(contactResp && contactResp.customFields) ||
[];
let fuenteActual = null;
if (fuenteField && fuenteField.id) {
const hit = contactCFs.find(cf => cf.id === fuenteField.id);
fuenteActual = hit ? (hit.value != null ? hit.value : (hit.fieldValue != null ? hit.fieldValue : null)) : null;
}
const fuenteEsLeadDigital = String(fuenteActual || "").trim().toUpperCase() === "LEAD DIGITAL";
return [
{
json: {
sucursal: {
id: sucursalField?.id ?? null,
name: sucursalField?.name ?? null,
fieldKey: sucursalField?.fieldKey ?? null,
picklistOptions: sucursalField?.picklistOptions ?? [],
},
tienda: {
id: tiendaField?.id ?? null,
name: tiendaField?.name ?? null,
fieldKey: tiendaField?.fieldKey ?? null,
picklistOptions: tiendaField?.picklistOptions ?? [],
},
canal: {
id: canalField?.id ?? null,
name: canalField?.name ?? null,
fieldKey: canalField?.fieldKey ?? null,
picklistOptions: canalField?.picklistOptions ?? [],
},
fuente: {
id: fuenteField?.id ?? null,
name: fuenteField?.name ?? null,
fieldKey: fuenteField?.fieldKey ?? null,
picklistOptions: fuenteField?.picklistOptions ?? [],
},
createdBySource: createdBySource,
esUsuario: esUsuario,
fuenteActual: fuenteActual,
fuenteEsLeadDigital: fuenteEsLeadDigital,
},
},
];"""
def build_nodes():
if_node = {
"parameters": {
"conditions": {
"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 3},
"conditions": [
{
"id": "fuente-es-lead-digital",
"leftValue": "={{ " + CODE_REF + ".fuenteEsLeadDigital }}",
"rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [1820, -120],
"name": IF_FUENTE_NODE,
}
fuente_body = (
"={\n"
' "customFields": [\n'
" {\n"
' "id": "{{ ' + CODE_REF + '.fuente.id }}",\n'
' "key": "{{ ' + CODE_REF + '.fuente.fieldKey }}",\n'
' "field_value": "SUCURSAL"\n'
" }\n"
" ]\n"
"}"
)
put_fuente = {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR,
"sendHeaders": True,
"headerParameters": {"parameters": [dict(h) for h in HEADERS]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": fuente_body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [2040, -120],
"name": PUT_FUENTE_NODE,
}
return if_node, put_fuente
def main():
parser = argparse.ArgumentParser(description="Reconcilia Fuente=LEAD DIGITAL->SUCURSAL en [2004].")
parser.add_argument("--apply", action="store_true", help="Aplica el PUT real (sin esto: dry-run).")
args = parser.parse_args()
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label="fuente_reconcile")
prev_version = wf.get("versionId")
print(f"Workflow: {wf.get('name')}")
print(f" active={wf.get('active')} nodes={len(wf.get('nodes') or [])} versionId={prev_version}")
print(f" backup -> {backup_path}")
# Idempotencia.
for nm in (IF_FUENTE_NODE, PUT_FUENTE_NODE):
if client.find_node(wf, nm) is not None:
raise SystemExit(f"El nodo {nm!r} ya existe; el complemento ya fue aplicado. Nada que hacer.")
# Pre-requisito: la rama canal de origen debe existir (TAG_RM_FB_NODE es su cola).
if client.find_node(wf, TAG_RM_FB_NODE) is None:
raise SystemExit(f"No existe {TAG_RM_FB_NODE!r}; corre antes _add_canal_origen_branch.py.")
# 1. Extender el Code node (preserva lógica previa, añade fuente).
code_node = client.find_node(wf, CODE_NODE)
if code_node is None:
raise SystemExit(f"No se encontró el Code node {CODE_NODE!r}.")
code_node["parameters"]["jsCode"] = NEW_JSCODE
print(f" Code node {CODE_NODE!r}: jsCode extendido (fuente + fuenteEsLeadDigital).")
# 2. Añadir nodos.
if_node, put_fuente = build_nodes()
for n in (if_node, put_fuente):
client.assert_idempotent(wf, n["name"])
client.add_node(wf, n)
print(" Nodos añadidos: IF Fuente + PUT Fuente=SUCURSAL.")
# 3. Conexiones: Tag- facebook-ads -> IF -> [true] PUT Fuente. [false] fin.
client.set_connection(wf, TAG_RM_FB_NODE, IF_FUENTE_NODE)
client.set_connection(wf, IF_FUENTE_NODE, PUT_FUENTE_NODE, output_index=0)
print(" Conexiones cableadas.")
expected = [IF_FUENTE_NODE, PUT_FUENTE_NODE]
if not args.apply:
res = client.put_workflow(WID, wf, dry_run=True)
print(f"\nDRY-RUN. Payload -> {res['path']} ({res['node_count']} nodos).")
print("Revisa el JSON y vuelve a correr con --apply para aplicar.")
return
was_active = bool(wf.get("active"))
if was_active:
try:
client.deactivate(WID)
print(" Workflow desactivado para PUT estructural.")
except Exception as exc:
print(f" WARN al desactivar: {exc}")
client.put_workflow(WID, wf, dry_run=False)
print(" PUT aplicado.")
if was_active:
client.activate(WID)
print(" Workflow reactivado.")
client.verify_post(WID, expected_node_names=expected, prev_version_id=prev_version)
print("\nOK: complemento aplicado y verificado. Backup en:", backup_path)
if __name__ == "__main__":
main()
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Cierra el Patrón B (Fuente=REDES SOCIALES) en el workflow n8n [2004]
(ddUEORBEtZLzsQF2).
Contexto: la rama "Creado por usuario" pone Canal=SUCURSAL a los WEB_USER/
MOBILE_USER. Para un contacto con Fuente=REDES SOCIALES eso es incoherente
(redes sociales = canal digital). Decisión de negocio (2026-05-30): tratarlos
como digital -> Canal=FACEBOOK + Fuente=LEAD DIGITAL (igual que hizo el fix de
datos `scripts/fix_origen_fuente_incoherencia.py` Patrón B).
Cambio (solo AÑADE / re-cablea, preserva el camino SUCURSAL existente):
1. Extiende el Code node para exponer `fuenteEsRedesSociales`.
2. Intercala, en la rama true del IF "Creado por usuario", un IF
"Fuente es REDES SOCIALES":
[true] -> PUT Canal=FACEBOOK -> PUT Fuente=LEAD DIGITAL (camino digital)
[false] -> PUT Canal de Origen = SUCURSAL (camino existente intacto)
Antes: Creado por usuario --true--> [PUT Canal=SUCURSAL -> tags -> IF LEAD DIGITAL -> PUT Fuente=SUCURSAL]
Ahora: Creado por usuario --true--> IF REDES SOCIALES
--true--> PUT Canal=FACEBOOK -> PUT Fuente=LEAD DIGITAL
--false-> [PUT Canal=SUCURSAL -> ... (igual que antes)]
Solo toca custom fields del contacto (no tags ni opps), igual que el fix de datos.
[2004] dispara al CREAR contacto.
Uso:
python n8n/_add_redes_sociales_branch.py # dry-run (dumpea payload)
python n8n/_add_redes_sociales_branch.py --apply # aplica + reactiva
"""
import argparse
import os
import sys
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)
from scripts.n8n_workflow_lib import N8NClient, load_credentials # noqa: E402
WID = "ddUEORBEtZLzsQF2"
# Nodos existentes.
CODE_NODE = 'Buscar "contact.sucursal" y "contact.tienda"'
VERIFICADOR_NODE = "Buscar Sucursal en Verificador de Sucursales"
IF_USUARIO_NODE = "Creado por usuario (Canal de Origen)"
PUT_CANAL_SUCURSAL_NODE = "PUT Canal de Origen = SUCURSAL"
# Nodos nuevos.
IF_REDES_NODE = "Fuente es REDES SOCIALES"
PUT_CANAL_FB_NODE = "PUT Canal de Origen = FACEBOOK"
PUT_FUENTE_LD_NODE = "PUT Fuente de Prospecto = LEAD DIGITAL"
CONTACT_ID_EXPR = "{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}"
TOKEN_EXPR = "{{ $('" + VERIFICADOR_NODE + "').item.json['SC TOKEN BUCEFALO'] }}"
CODE_REF = "$('" + CODE_NODE + "').item.json"
HEADERS = [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization", "value": "=Bearer " + TOKEN_EXPR},
]
# Code node: versión con fuente (de _add_fuente_reconcile.py) + flag fuenteEsRedesSociales.
NEW_JSCODE = r"""const customFields = $input.first().json.customFields;
function findField(key, names) {
let f = customFields.find(x => x.fieldKey === key);
if (!f) {
const wanted = names.map(n => n.toLowerCase().trim());
f = customFields.find(x => wanted.includes((x.name || "").toLowerCase().trim()));
}
return f || null;
}
const sucursalField = findField("contact.sucursal", ["Sucursal"]);
const tiendaField = findField("contact.tienda", ["TIENDA", "Tienda"]);
const canalField = findField("contact.fuente_de_posible_cliente", ["CANAL DE ORIGEN", "Canal de Origen"]);
const fuenteField = findField("contact.fuente_de_prospecto", ["Fuente de Prospecto", "FUENTE DE PROSPECTO"]);
// createdBy.source SOLO viene del GET individual del contacto.
const contactResp = $('Obtener Contacto Cuenta Origen - SUCURSAL').item.json;
const createdBySource =
(contactResp && contactResp.contact && contactResp.contact.createdBy && contactResp.contact.createdBy.source) ||
(contactResp && contactResp.createdBy && contactResp.createdBy.source) ||
null;
const esUsuario = createdBySource === "WEB_USER" || createdBySource === "MOBILE_USER";
// Valor ACTUAL de "Fuente de Prospecto" en el contacto.
const contactCFs =
(contactResp && contactResp.contact && contactResp.contact.customFields) ||
(contactResp && contactResp.customFields) ||
[];
let fuenteActual = null;
if (fuenteField && fuenteField.id) {
const hit = contactCFs.find(cf => cf.id === fuenteField.id);
fuenteActual = hit ? (hit.value != null ? hit.value : (hit.fieldValue != null ? hit.fieldValue : null)) : null;
}
const fuenteNorm = String(fuenteActual || "").trim().toUpperCase();
const fuenteEsLeadDigital = fuenteNorm === "LEAD DIGITAL";
const fuenteEsRedesSociales = fuenteNorm === "REDES SOCIALES";
return [
{
json: {
sucursal: {
id: sucursalField?.id ?? null,
name: sucursalField?.name ?? null,
fieldKey: sucursalField?.fieldKey ?? null,
picklistOptions: sucursalField?.picklistOptions ?? [],
},
tienda: {
id: tiendaField?.id ?? null,
name: tiendaField?.name ?? null,
fieldKey: tiendaField?.fieldKey ?? null,
picklistOptions: tiendaField?.picklistOptions ?? [],
},
canal: {
id: canalField?.id ?? null,
name: canalField?.name ?? null,
fieldKey: canalField?.fieldKey ?? null,
picklistOptions: canalField?.picklistOptions ?? [],
},
fuente: {
id: fuenteField?.id ?? null,
name: fuenteField?.name ?? null,
fieldKey: fuenteField?.fieldKey ?? null,
picklistOptions: fuenteField?.picklistOptions ?? [],
},
createdBySource: createdBySource,
esUsuario: esUsuario,
fuenteActual: fuenteActual,
fuenteEsLeadDigital: fuenteEsLeadDigital,
fuenteEsRedesSociales: fuenteEsRedesSociales,
},
},
];"""
def put_cf_node(name, position, *, code_field, value):
"""Nodo httpRequest PUT /contacts/{id} que setea un custom field por id+key."""
body = (
"={\n"
' "customFields": [\n'
" {\n"
' "id": "{{ ' + CODE_REF + "." + code_field + '.id }}",\n'
' "key": "{{ ' + CODE_REF + "." + code_field + '.fieldKey }}",\n'
' "field_value": "' + value + '"\n'
" }\n"
" ]\n"
"}"
)
return {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR,
"sendHeaders": True,
"headerParameters": {"parameters": [dict(h) for h in HEADERS]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": position,
"name": name,
}
def build_nodes():
if_redes = {
"parameters": {
"conditions": {
"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 3},
"conditions": [
{
"id": "fuente-es-redes-sociales",
"leftValue": "={{ " + CODE_REF + ".fuenteEsRedesSociales }}",
"rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [820, -320],
"name": IF_REDES_NODE,
}
put_canal_fb = put_cf_node(PUT_CANAL_FB_NODE, [1040, -320], code_field="canal", value="FACEBOOK")
put_fuente_ld = put_cf_node(PUT_FUENTE_LD_NODE, [1260, -320], code_field="fuente", value="LEAD DIGITAL")
return if_redes, put_canal_fb, put_fuente_ld
def main():
parser = argparse.ArgumentParser(description="Cierra Patrón B (REDES SOCIALES) en [2004].")
parser.add_argument("--apply", action="store_true", help="Aplica el PUT real (sin esto: dry-run).")
args = parser.parse_args()
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label="redes_sociales")
prev_version = wf.get("versionId")
print(f"Workflow: {wf.get('name')}")
print(f" active={wf.get('active')} nodes={len(wf.get('nodes') or [])} versionId={prev_version}")
print(f" backup -> {backup_path}")
# Idempotencia.
for nm in (IF_REDES_NODE, PUT_CANAL_FB_NODE, PUT_FUENTE_LD_NODE):
if client.find_node(wf, nm) is not None:
raise SystemExit(f"El nodo {nm!r} ya existe; ya fue aplicado. Nada que hacer.")
# Pre-requisitos.
for nm in (IF_USUARIO_NODE, PUT_CANAL_SUCURSAL_NODE):
if client.find_node(wf, nm) is None:
raise SystemExit(f"No existe {nm!r}; corre antes _add_canal_origen_branch.py.")
# Verifica que el IF "Creado por usuario" hoy conecta su rama true a PUT Canal=SUCURSAL.
cur = client.get_connection_targets(wf, IF_USUARIO_NODE, output_index=0)
cur_names = [t.get("node") for t in cur]
if cur_names != [PUT_CANAL_SUCURSAL_NODE]:
raise SystemExit(f"Topología inesperada: {IF_USUARIO_NODE!r} true -> {cur_names} (esperaba [{PUT_CANAL_SUCURSAL_NODE!r}]).")
# 1. Extender Code node.
code_node = client.find_node(wf, CODE_NODE)
code_node["parameters"]["jsCode"] = NEW_JSCODE
print(f" Code node {CODE_NODE!r}: jsCode extendido (fuenteEsRedesSociales).")
# 2. Añadir nodos.
if_redes, put_canal_fb, put_fuente_ld = build_nodes()
for n in (if_redes, put_canal_fb, put_fuente_ld):
client.assert_idempotent(wf, n["name"])
client.add_node(wf, n)
print(" Nodos añadidos: IF REDES + PUT Canal=FACEBOOK + PUT Fuente=LEAD DIGITAL.")
# 3. Re-cablear: Creado por usuario[true] -> IF REDES;
# IF REDES[true] -> PUT Canal=FACEBOOK -> PUT Fuente=LEAD DIGITAL;
# IF REDES[false] -> PUT Canal=SUCURSAL (camino existente).
client.set_connection(wf, IF_USUARIO_NODE, IF_REDES_NODE, output_index=0)
client.set_connection(wf, IF_REDES_NODE, PUT_CANAL_FB_NODE, output_index=0)
client.set_connection(wf, IF_REDES_NODE, PUT_CANAL_SUCURSAL_NODE, output_index=1)
client.set_connection(wf, PUT_CANAL_FB_NODE, PUT_FUENTE_LD_NODE)
print(" Conexiones re-cableadas.")
expected = [IF_REDES_NODE, PUT_CANAL_FB_NODE, PUT_FUENTE_LD_NODE]
if not args.apply:
res = client.put_workflow(WID, wf, dry_run=True)
print(f"\nDRY-RUN. Payload -> {res['path']} ({res['node_count']} nodos).")
print("Revisa el JSON y vuelve a correr con --apply para aplicar.")
return
was_active = bool(wf.get("active"))
if was_active:
try:
client.deactivate(WID)
print(" Workflow desactivado para PUT estructural.")
except Exception as exc:
print(f" WARN al desactivar: {exc}")
client.put_workflow(WID, wf, dry_run=False)
print(" PUT aplicado.")
if was_active:
client.activate(WID)
print(" Workflow reactivado.")
client.verify_post(WID, expected_node_names=expected, prev_version_id=prev_version)
print("\nOK: Patrón B cerrado y verificado. Backup en:", backup_path)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,955 @@
{
"name": "[2004][Monte Providencia] Actualizar \"contact.sucursal\", \"contact.tienda\" al crear contacto",
"nodes": [
{
"parameters": {
"content": "# De Sucursal a Marca",
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"position": [
-2288,
-320
],
"typeVersion": 1,
"id": "d91325e1-1763-486c-a2c9-8cfffddd57b0",
"name": "Sticky Note3"
},
{
"parameters": {
"databaseId": 63,
"tableId": 749,
"additionalOptions": {
"filters": {
"fields": [
{
"field": 7235,
"value": "={{ $('Datos de Lead').item.json.Sucursal['Cuenta Bucéfalo'] }}"
}
]
}
}
},
"type": "n8n-nodes-base.baserow",
"typeVersion": 1,
"position": [
-912,
-320
],
"id": "50da84c1-c2d3-4a36-91d3-e3745edccdc6",
"name": "Obtener Info de cuenta origen - SUCURSAL",
"credentials": {
"baserowApi": {
"id": "LZztQ3WMpzXjSTIH",
"name": "Baserow account"
}
}
},
{
"parameters": {
"url": "=https://services.leadconnectorhq.com/locations/{{ $('Datos API Cuenta Origen - SUCURSAL').item.json['Location ID'] }}/customFields",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Datos API Cuenta Origen - SUCURSAL').item.json['Token/API'] }}"
}
]
},
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-224,
-320
],
"id": "a0c27c4c-0f20-4bab-bf6b-bcdd8281a92b",
"name": "Conseguir Custom Cuenta Origen- SUCURSAL"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "44d54b9e-d192-4b54-bf0c-156b79afc6e2",
"leftValue": "={{ $json.Cliente.Email }}",
"rightValue": "@ezcorp.com",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "64f2add6-506c-4950-8026-c04c9547aeeb",
"leftValue": "",
"rightValue": "",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-1392,
-304
],
"id": "d68558b5-52b8-46b1-a359-fd956c7edc09",
"name": "Omitir @ezcorp.com"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "8a998fd4-2de6-4895-ab3d-e052e823d1b8",
"name": "Cliente.Fuente Posible Cliente",
"value": "={{ $json.body['Fuente de Posible cliente'] }}",
"type": "string"
},
{
"id": "938c6fec-ae16-4e7a-ba2a-f450794fa40d",
"name": "Cliente.Fecha de creación",
"value": "={{ $json.body.date_created }}",
"type": "string"
},
{
"id": "b56a1939-2608-47c8-85ad-b60b557d2a27",
"name": "Cliente.Sucursal.Sucursal",
"value": "={{ $json.body.Sucursal }}",
"type": "string"
},
{
"id": "0d07b9c9-4450-497b-ab81-3baa441787fb",
"name": "Vehiculo.Versión.Versión",
"value": "={{ $json.body['Version del Vehiculo'] }}",
"type": "string"
},
{
"id": "75e3f337-00d1-429d-8d5b-85ec18e6c5e4",
"name": "Vehiculo.Marca.Marca",
"value": "={{ $json.body['Marca del Vehiculo'] }}",
"type": "string"
},
{
"id": "cb09c536-fe84-4598-aaae-aa79f2eda61b",
"name": "Vehiculo.Marca.fieldKey",
"value": "=contact.marca_del_vehiculo",
"type": "string"
},
{
"id": "17d36409-4c54-48dd-8100-f7f667fd2415",
"name": "Vehiculo.Año.Año",
"value": "={{ $json.body['Año del Vehículo'] }}",
"type": "string"
},
{
"id": "a1886afc-b0af-4950-9752-f8bfff594896",
"name": "Vehiculo.Modalidad.Modalidad",
"value": "={{ $json.body['¿Qué modalidad prefieres?'] }}",
"type": "string"
},
{
"id": "33b2c28a-1ad3-4c74-917f-3cd718a3a709",
"name": "Cliente.Nombre",
"value": "={{ $json.body['Información Adicional'] }}",
"type": "string"
},
{
"id": "b36131ac-2d88-41f8-8f0f-7640cdb02b57",
"name": "Cliente.Apellido",
"value": "={{ $json.body.first_name }}",
"type": "string"
},
{
"id": "ae252c8f-f0a1-41d9-a21d-04ca949f01c8",
"name": "Cliente.Nombre Completo",
"value": "={{ $json.body.full_name }}",
"type": "string"
},
{
"id": "342a9377-0ded-4f23-b93b-1f76e57c0cbd",
"name": "Cliente.Email",
"value": "={{ $json.body.email }}",
"type": "string"
},
{
"id": "cf1b7058-96c2-4c73-9250-719a88b68673",
"name": "Cliente.Telefono",
"value": "={{ $json.body.phone }}",
"type": "string"
},
{
"id": "0b916193-15e8-4a91-9ff2-0a6f262b3c38",
"name": "Cliente.Contact ID",
"value": "={{ $json.body.contact_id }}",
"type": "string"
},
{
"id": "67eeaa9b-f703-4521-82af-9d2797131edc",
"name": "Cliente.Sucursal.fieldKey",
"value": "contact.sucursal",
"type": "string"
},
{
"id": "b05ea7bb-bbaa-467b-8247-eabb162ff029",
"name": "Vehiculo.Versión.fieldKey",
"value": "contact.version_del_vehiculo",
"type": "string"
},
{
"id": "9891b919-ef4c-46bd-8414-6d916565d896",
"name": "Vehiculo.Año.fieldKey",
"value": "contact.ano_del_vehiculo",
"type": "string"
},
{
"id": "0b6bf582-49c1-41de-9c2e-6ae85c4e41e8",
"name": "Vehiculo.Modalidad.fieldKey",
"value": "contact.que_modalidad_prefieres",
"type": "string"
},
{
"id": "57199999-2d9b-41c7-8269-5a8183ca8132",
"name": "Cliente.Cuándo necesitas el dinero",
"value": "={{ $json.body[\"¿Cuándo necesitas el dinero?\"] }}",
"type": "string"
},
{
"id": "8060d06e-b1da-4a65-8a0a-3c21d237d77e",
"name": "Sucursal.Cuenta Bucéfalo",
"value": "={{ $json.body.location.name }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
-1568,
-304
],
"id": "d0e455ac-7ccc-4c55-bda2-1cd22092e2d2",
"name": "Datos de Lead"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "55ff7d12-17b9-4bec-a324-e633020b131d",
"name": "Name Location",
"value": "={{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.Nombre }}",
"type": "string"
},
{
"id": "d877c8cd-db32-4c16-96dd-4eeb2dc48efe",
"name": "Location ID",
"value": "={{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.Location_ID }}",
"type": "string"
},
{
"id": "7698f395-5db8-415b-919e-3ad61c6566f8",
"name": "Token/API",
"value": "={{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.API_token }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
-720,
-320
],
"id": "f0cff3ec-8464-45d4-9e64-713c36e247c6",
"name": "Datos API Cuenta Origen - SUCURSAL",
"notes": "Esta en modo prueba forzada para Queretaro"
},
{
"parameters": {
"content": "# CUENTA ORIGEN",
"height": 240,
"width": 1744
},
"type": "n8n-nodes-base.stickyNote",
"position": [
-976,
-384
],
"typeVersion": 1,
"id": "16a74d85-1e6c-4fc7-b854-580a2d3827a0",
"name": "Sticky Note"
},
{
"parameters": {
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Webhook').item.json.body.contact_id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $json['Token/API'] }}"
}
]
},
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
-400,
-320
],
"id": "92f33c6e-ee64-409d-8c60-9fbfe48b3265",
"name": "Obtener Contacto Cuenta Origen - SUCURSAL"
},
{
"parameters": {
"content": "",
"height": 176,
"width": 608,
"color": 7
},
"type": "n8n-nodes-base.stickyNote",
"position": [
-448,
-336
],
"typeVersion": 1,
"id": "768a4001-5109-493f-b12e-e8ba5d30ec2f",
"name": "Sticky Note1"
},
{
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"customFields\": [\n {\n \"id\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.sucursal.id }}\",\n \"key\": \"contact.sucursal\",\n \"field_value\": \"{{ $json.SUCURSAL }}\"\n },\n {\n \"id\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.tienda.id }}\",\n \"key\": \"contact.tienda\",\n \"field_value\": \"{{ $json.TIENDA }}\"\n }\n ]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
512,
-320
],
"id": "794ce5bb-dea0-4720-958a-1f0940c79e6d",
"name": "Actualizar Contacto Cuenta Objetivo - SUCURSAL"
},
{
"parameters": {
"httpMethod": "POST",
"path": "8d574598-d977-4052-823a-26def39c6a64",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-1776,
-304
],
"id": "d4312610-e978-424c-a8a0-426026c4d4f6",
"name": "Webhook",
"webhookId": "8d574598-d977-4052-823a-26def39c6a64"
},
{
"parameters": {
"jsCode": "const customFields = $input.first().json.customFields;\n\nfunction findField(key, names) {\n let f = customFields.find(x => x.fieldKey === key);\n if (!f) {\n const wanted = names.map(n => n.toLowerCase().trim());\n f = customFields.find(x => wanted.includes((x.name || \"\").toLowerCase().trim()));\n }\n return f || null;\n}\n\nconst sucursalField = findField(\"contact.sucursal\", [\"Sucursal\"]);\nconst tiendaField = findField(\"contact.tienda\", [\"TIENDA\", \"Tienda\"]);\nconst canalField = findField(\"contact.fuente_de_posible_cliente\", [\"CANAL DE ORIGEN\", \"Canal de Origen\"]);\nconst fuenteField = findField(\"contact.fuente_de_prospecto\", [\"Fuente de Prospecto\", \"FUENTE DE PROSPECTO\"]);\n\n// createdBy.source SOLO viene del GET individual del contacto.\nconst contactResp = $('Obtener Contacto Cuenta Origen - SUCURSAL').item.json;\nconst createdBySource =\n (contactResp && contactResp.contact && contactResp.contact.createdBy && contactResp.contact.createdBy.source) ||\n (contactResp && contactResp.createdBy && contactResp.createdBy.source) ||\n null;\nconst esUsuario = createdBySource === \"WEB_USER\" || createdBySource === \"MOBILE_USER\";\n\n// Valor ACTUAL de \"Fuente de Prospecto\" en el contacto (para reconciliar SOLO si = LEAD DIGITAL).\nconst contactCFs =\n (contactResp && contactResp.contact && contactResp.contact.customFields) ||\n (contactResp && contactResp.customFields) ||\n [];\nlet fuenteActual = null;\nif (fuenteField && fuenteField.id) {\n const hit = contactCFs.find(cf => cf.id === fuenteField.id);\n fuenteActual = hit ? (hit.value != null ? hit.value : (hit.fieldValue != null ? hit.fieldValue : null)) : null;\n}\nconst fuenteEsLeadDigital = String(fuenteActual || \"\").trim().toUpperCase() === \"LEAD DIGITAL\";\n\nreturn [\n {\n json: {\n sucursal: {\n id: sucursalField?.id ?? null,\n name: sucursalField?.name ?? null,\n fieldKey: sucursalField?.fieldKey ?? null,\n picklistOptions: sucursalField?.picklistOptions ?? [],\n },\n tienda: {\n id: tiendaField?.id ?? null,\n name: tiendaField?.name ?? null,\n fieldKey: tiendaField?.fieldKey ?? null,\n picklistOptions: tiendaField?.picklistOptions ?? [],\n },\n canal: {\n id: canalField?.id ?? null,\n name: canalField?.name ?? null,\n fieldKey: canalField?.fieldKey ?? null,\n picklistOptions: canalField?.picklistOptions ?? [],\n },\n fuente: {\n id: fuenteField?.id ?? null,\n name: fuenteField?.name ?? null,\n fieldKey: fuenteField?.fieldKey ?? null,\n picklistOptions: fuenteField?.picklistOptions ?? [],\n },\n createdBySource: createdBySource,\n esUsuario: esUsuario,\n fuenteActual: fuenteActual,\n fuenteEsLeadDigital: fuenteEsLeadDigital,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
32,
-320
],
"id": "5a1cfe47-862c-4d28-a4ca-57a9f8c54a7c",
"name": "Buscar \"contact.sucursal\" y \"contact.tienda\""
},
{
"parameters": {
"databaseId": 63,
"tableId": 750,
"limit": 1,
"additionalOptions": {
"filters": {
"fields": [
{
"field": 7247,
"value": "={{ $('Datos API Cuenta Origen - SUCURSAL').item.json['Name Location'] }}"
},
{
"field": 7279,
"operator": "not_equal",
"value": "NO DIGITAL"
}
]
}
}
},
"type": "n8n-nodes-base.baserow",
"typeVersion": 1.1,
"position": [
256,
-320
],
"id": "a912fac3-25d4-492a-9648-8c472098b9ca",
"name": "Buscar Sucursal en Verificador de Sucursales",
"credentials": {
"baserowApi": {
"id": "LZztQ3WMpzXjSTIH",
"name": "Baserow account"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://services.leadconnectorhq.com/opportunities/search",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2023-02-21"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.API_token }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"locationId\": \"{{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.Location_ID }}\",\n \"query\": \"{{ $json.contact.email }}\",\n \"limit\": 20,\n \"page\": 1\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1152,
-320
],
"id": "4b899380-fe4a-40ad-80bb-21ef504b30ac",
"name": "Actualizar Contacto Cuenta Objetivo - SUCURSAL1"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"id": "canal-origen-esusuario",
"leftValue": "={{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.esUsuario }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
720,
-120
],
"name": "Creado por usuario (Canal de Origen)",
"id": "49879f5e-7ce8-4ced-b1a6-96df44ac2e0a"
},
{
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Buscar Sucursal en Verificador de Sucursales').item.json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"customFields\": [\n {\n \"id\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.canal.id }}\",\n \"key\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.canal.fieldKey }}\",\n \"field_value\": \"SUCURSAL\"\n }\n ]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
940,
-120
],
"name": "PUT Canal de Origen = SUCURSAL",
"id": "d0597a9c-aca0-40bb-98d8-bb584d2a2c3e"
},
{
"parameters": {
"method": "POST",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}/tags",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Buscar Sucursal en Verificador de Sucursales').item.json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"tags\": [\"sucursal\"]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1160,
-120
],
"name": "Tag+ sucursal",
"id": "86bfddcb-c402-413f-9d32-c55050dc470d"
},
{
"parameters": {
"method": "DELETE",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}/tags",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Buscar Sucursal en Verificador de Sucursales').item.json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"tags\": [\"formulario\"]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1380,
-120
],
"name": "Tag- formulario",
"onError": "continueRegularOutput",
"id": "cbd3cfb2-28d0-44e1-9567-a269382497ae"
},
{
"parameters": {
"method": "DELETE",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}/tags",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Buscar Sucursal en Verificador de Sucursales').item.json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"tags\": [\"facebook-ads\"]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1600,
-120
],
"name": "Tag- facebook-ads",
"onError": "continueRegularOutput",
"id": "19bdc2b2-9345-4294-8e59-7f35963f261d"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 3
},
"conditions": [
{
"id": "fuente-es-lead-digital",
"leftValue": "={{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.fuenteEsLeadDigital }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1820,
-120
],
"name": "Fuente = LEAD DIGITAL (reconciliar)",
"id": "d4ba3c4b-c220-43ab-a9a7-e4f4da407647"
},
{
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Authorization",
"value": "=Bearer {{ $('Buscar Sucursal en Verificador de Sucursales').item.json['SC TOKEN BUCEFALO'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"customFields\": [\n {\n \"id\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.fuente.id }}\",\n \"key\": \"{{ $('Buscar \"contact.sucursal\" y \"contact.tienda\"').item.json.fuente.fieldKey }}\",\n \"field_value\": \"SUCURSAL\"\n }\n ]\n}",
"options": {
"redirect": {
"redirect": {}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2040,
-120
],
"name": "PUT Fuente de Prospecto = SUCURSAL",
"id": "1eafae90-261d-40d9-aada-01678027d915"
}
],
"connections": {
"Obtener Info de cuenta origen - SUCURSAL": {
"main": [
[
{
"node": "Datos API Cuenta Origen - SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"Conseguir Custom Cuenta Origen- SUCURSAL": {
"main": [
[
{
"node": "Buscar \"contact.sucursal\" y \"contact.tienda\"",
"type": "main",
"index": 0
}
]
]
},
"Omitir @ezcorp.com": {
"main": [
[
{
"node": "Obtener Info de cuenta origen - SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"Datos de Lead": {
"main": [
[
{
"node": "Omitir @ezcorp.com",
"type": "main",
"index": 0
}
]
]
},
"Datos API Cuenta Origen - SUCURSAL": {
"main": [
[
{
"node": "Obtener Contacto Cuenta Origen - SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"Obtener Contacto Cuenta Origen - SUCURSAL": {
"main": [
[
{
"node": "Conseguir Custom Cuenta Origen- SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Datos de Lead",
"type": "main",
"index": 0
}
]
]
},
"Buscar Sucursal en Verificador de Sucursales": {
"main": [
[
{
"node": "Actualizar Contacto Cuenta Objetivo - SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"Buscar \"contact.sucursal\" y \"contact.tienda\"": {
"main": [
[
{
"node": "Buscar Sucursal en Verificador de Sucursales",
"type": "main",
"index": 0
}
]
]
},
"Actualizar Contacto Cuenta Objetivo - SUCURSAL": {
"main": [
[
{
"node": "Creado por usuario (Canal de Origen)",
"type": "main",
"index": 0
}
]
]
},
"Creado por usuario (Canal de Origen)": {
"main": [
[
{
"node": "PUT Canal de Origen = SUCURSAL",
"type": "main",
"index": 0
}
]
]
},
"PUT Canal de Origen = SUCURSAL": {
"main": [
[
{
"node": "Tag+ sucursal",
"type": "main",
"index": 0
}
]
]
},
"Tag+ sucursal": {
"main": [
[
{
"node": "Tag- formulario",
"type": "main",
"index": 0
}
]
]
},
"Tag- formulario": {
"main": [
[
{
"node": "Tag- facebook-ads",
"type": "main",
"index": 0
}
]
]
},
"Tag- facebook-ads": {
"main": [
[
{
"node": "Fuente = LEAD DIGITAL (reconciliar)",
"type": "main",
"index": 0
}
]
]
},
"Fuente = LEAD DIGITAL (reconciliar)": {
"main": [
[
{
"node": "PUT Fuente de Prospecto = SUCURSAL",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because it is too large Load Diff
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# restart.command — Equivalente macOS/Linux de restart.bat.
# Reinicia el servidor detectando automáticamente si estaba en modo normal o
# perfil persistente (lee generated/runtime/last_mode), mata Chromium zombies
# de Playwright y limpia batch files huérfanos.
# ---------------------------------------------------------------------------
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
banner "MP Manager - Reiniciando Servidor"
PY="$(require_venv)" || { hold_window; exit 1; }
# --- 1. Determinar modo activo (normal / persistent) ----------------------
MODE="normal"
LAST_MODE_FILE="generated/runtime/last_mode"
if [ -f "$LAST_MODE_FILE" ]; then
MODE="$(tr -d '[:space:]' < "$LAST_MODE_FILE")"
fi
if [ "$MODE" != "normal" ] && [ "$MODE" != "persistent" ]; then
MODE="normal"
fi
info "Modo detectado: $MODE"
# --- 2. Detener el server actual de forma segura --------------------------
set +e
"$PY" runtime_control.py stop --force
set -e
# --- 3. Limpiar Chromium headless huérfanos de Playwright -----------------
# Solo matamos procesos cuya línea de comando apunta al Chromium de Playwright
# (ms-playwright / chrome-headless-shell), NUNCA el Google Chrome del usuario.
info "Limpiando zombies de Chromium headless de Playwright si los hay..."
pkill -f "ms-playwright.*[Cc]hromium" 2>/dev/null || true
pkill -f "chrome-headless-shell" 2>/dev/null || true
# --- 4. Limpiar batch files interrumpidos del runtime ---------------------
if compgen -G "generated/runtime/batch/_bulk_batch_*.json" >/dev/null; then
info "Limpiando batch files huérfanos..."
rm -f generated/runtime/batch/_bulk_batch_*.json
fi
rm -f generated/runtime/batch/_test_batch.json 2>/dev/null || true
# --- 5. Pequeña espera para liberar el puerto -----------------------------
info "Esperando 2 segundos para liberar puerto..."
sleep 2
# --- 6. Relanzar en el modo correcto --------------------------------------
mkdir -p generated/runtime generated/logs
LOG="$PROJECT_DIR/generated/logs/server.out"
if [ "$MODE" = "persistent" ]; then
export GHL_BROWSER_PROFILE_DIR="$PROJECT_DIR/generated/browser/profile"
info "Relanzando en modo PERFIL PERSISTENTE."
info "GHL_BROWSER_PROFILE_DIR=$GHL_BROWSER_PROFILE_DIR"
if [ ! -d "$GHL_BROWSER_PROFILE_DIR" ]; then
echo
warn "El perfil persistente aún no existe."
warn 'Cuando arranque, usa "Renovar sesión Bucéfalo" en el dashboard.'
echo
fi
echo "persistent" > "$LAST_MODE_FILE"
else
info "Relanzando en modo NORMAL."
echo "normal" > "$LAST_MODE_FILE"
fi
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
disown || true
echo
echo "==================================================="
echo " Servidor reiniciado en modo $MODE."
echo " Logs: generated/logs/server.out"
echo " Para detener: stop.command"
echo "==================================================="
hold_window
+8 -101
View File
@@ -7,10 +7,9 @@ Centraliza la lógica de:
* apagar el servidor de forma segura (sin matar procesos ajenos), * apagar el servidor de forma segura (sin matar procesos ajenos),
* esperar a que el server termine de arrancar (server_info.json escrito). * esperar a que el server termine de arrancar (server_info.json escrito).
Pensado para que los launchers (Windows: `start.bat`/`stop.bat`/`restart.bat`; Pensado para que los batch files (`start.bat`, `stop.bat`, `restart.bat`) lo
macOS/Linux: `start.command`/`stop.command`/`restart.command`) lo invoquen como invoquen como CLI sin tener que hacer parsing de JSON o validaciones complejas
CLI sin tener que hacer parsing de JSON o validaciones complejas en shell. La en lenguaje batch.
lógica de OS (puertos, PIDs, kill) es cross-platform: ver `IS_WINDOWS`.
Uso CLI: Uso CLI:
python runtime_control.py status # imprime estado actual python runtime_control.py status # imprime estado actual
@@ -23,7 +22,6 @@ from __future__ import annotations
import json import json
import os import os
import signal
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -37,11 +35,6 @@ import paths
# proyecto Python). Lo buscamos en la línea de comando del proceso. # proyecto Python). Lo buscamos en la línea de comando del proceso.
PROJECT_MARKER = os.path.normcase(paths.BASE_DIR) PROJECT_MARKER = os.path.normcase(paths.BASE_DIR)
# Las primitivas de OS (descubrir quién ocupa un puerto, leer la línea de comando
# de un PID, matarlo) difieren entre Windows y POSIX (macOS/Linux). El resto del
# módulo es agnóstico: dispatcha según esta bandera.
IS_WINDOWS = os.name == "nt"
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool: def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -54,12 +47,6 @@ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
def get_pid_listening_on(port: int) -> Optional[int]: def get_pid_listening_on(port: int) -> Optional[int]:
"""Devuelve el PID que escucha en `port` (LISTENING), o None si nadie.""" """Devuelve el PID que escucha en `port` (LISTENING), o None si nadie."""
if IS_WINDOWS:
return _get_pid_listening_on_windows(port)
return _get_pid_listening_on_posix(port)
def _get_pid_listening_on_windows(port: int) -> Optional[int]:
try: try:
out = subprocess.check_output( out = subprocess.check_output(
["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL ["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL
@@ -78,27 +65,6 @@ def _get_pid_listening_on_windows(port: int) -> Optional[int]:
return None return None
def _get_pid_listening_on_posix(port: int) -> Optional[int]:
"""macOS/Linux: `lsof` lista el/los PID que escuchan en el puerto.
`-sTCP:LISTEN` acota a sockets en estado LISTEN, `-t` devuelve solo PIDs.
Si hay varios (p.ej. el reloader de uvicorn y su worker comparten socket),
devolvemos el primero; `_kill_tree` se encarga del árbol completo.
"""
try:
out = subprocess.check_output(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
text=True,
stderr=subprocess.DEVNULL,
)
except Exception:
return None
for line in out.split():
if line.strip().isdigit():
return int(line.strip())
return None
def _run_powershell(ps_command: str) -> Optional[str]: def _run_powershell(ps_command: str) -> Optional[str]:
"""Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla. """Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla.
@@ -119,43 +85,18 @@ def _run_powershell(ps_command: str) -> Optional[str]:
def get_process_cmdline(pid: int) -> Optional[str]: def get_process_cmdline(pid: int) -> Optional[str]:
"""Devuelve la línea de comando completa de un PID, o None si no existe.""" """Devuelve la línea de comando de un PID en Windows, o None si no existe."""
if IS_WINDOWS:
return _run_powershell( return _run_powershell(
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine" f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine"
) )
# POSIX (macOS/Linux): `ps -o command=` imprime la línea de comando completa
# sin encabezado. En macOS el límite por defecto es amplio; suficiente para
# contener la ruta absoluta a main.py que es lo que buscamos.
try:
out = subprocess.check_output(
["ps", "-p", str(pid), "-o", "command="],
text=True,
stderr=subprocess.DEVNULL,
)
except Exception:
return None
return out.strip() or None
def pid_is_alive(pid: int) -> bool: def pid_is_alive(pid: int) -> bool:
"""True si el PID existe.""" """True si el PID existe. Más confiable que parsear tasklist."""
if IS_WINDOWS:
result = _run_powershell( result = _run_powershell(
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}" f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
) )
return result == "yes" return result == "yes"
# POSIX: señal 0 no mata; solo verifica existencia/permisos del proceso.
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
# Existe pero es de otro usuario; para nuestros fines, está vivo.
return True
except OSError:
return False
def pid_belongs_to_mp_manager(pid: int) -> bool: def pid_belongs_to_mp_manager(pid: int) -> bool:
@@ -165,7 +106,7 @@ def pid_belongs_to_mp_manager(pid: int) -> bool:
registró este PID. Como ese archivo SOLO lo escribe `main.py` de este proyecto, registró este PID. Como ese archivo SOLO lo escribe `main.py` de este proyecto,
el match es prueba inequívoca de identidad. el match es prueba inequívoca de identidad.
Fuente secundaria (cmdline): si los launchers lanzan `python "<ruta absoluta>/main.py"`, Fuente secundaria (cmdline): si los .bat lanzan `python "<ruta absoluta>/main.py"`,
el path del proyecto queda embebido en la línea de comando del proceso y podemos el path del proyecto queda embebido en la línea de comando del proceso y podemos
reconocerlo aunque el server_info.json esté ausente o stale. reconocerlo aunque el server_info.json esté ausente o stale.
""" """
@@ -189,17 +130,12 @@ def get_process_cwd(pid: int) -> Optional[str]:
informativo es la línea de comando completa, así que esta función es informativo es la línea de comando completa, así que esta función es
secundaria. secundaria.
""" """
if IS_WINDOWS:
out = _run_powershell( out = _run_powershell(
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath" f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath"
) )
if out: if out:
return os.path.dirname(out) return os.path.dirname(out)
return None return None
# POSIX: no hay un cwd fiable sin /proc (Linux) y macOS no lo expone vía ps.
# La línea de comando completa es la pista útil para nosotros, así que esta
# función queda como best-effort y devuelve None.
return None
def read_server_info() -> Optional[dict]: def read_server_info() -> Optional[dict]:
@@ -303,40 +239,11 @@ def stop_server(force: bool = False) -> int:
def _kill_tree(pid: int) -> None: def _kill_tree(pid: int) -> None:
if IS_WINDOWS:
subprocess.run( subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)], ["taskkill", "/F", "/T", "/PID", str(pid)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return
_kill_tree_posix(pid)
def _kill_tree_posix(pid: int) -> None:
"""Mata recursivamente el proceso y sus hijos en macOS/Linux.
uvicorn arranca con `reload=True`, lo que crea un proceso reloader padre y un
worker hijo. El PID registrado en server_info.json es el del padre, así que
hay que descender por el árbol (`pgrep -P`) para no dejar al worker zombie
aferrado al puerto. Enviamos SIGTERM (apagado limpio); el padre propaga la
señal a uvicorn que cierra el socket ordenadamente.
"""
try:
out = subprocess.check_output(
["pgrep", "-P", str(pid)], text=True, stderr=subprocess.DEVNULL
)
children = [int(c) for c in out.split() if c.strip().isdigit()]
except Exception:
children = []
for child in children:
_kill_tree_posix(child)
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
def wait_ready(timeout_sec: float = 30.0) -> int: def wait_ready(timeout_sec: float = 30.0) -> int:
@@ -364,14 +271,14 @@ def preflight_check() -> int:
if info and info.get("pid") and pid_is_alive(int(info["pid"])): if info and info.get("pid") and pid_is_alive(int(info["pid"])):
port = info.get("port", 8000) port = info.get("port", 8000)
print(f"[INFO] MP Manager ya esta corriendo en puerto {port} (PID {info['pid']}).") print(f"[INFO] MP Manager ya esta corriendo en puerto {port} (PID {info['pid']}).")
print(f" Abre http://127.0.0.1:{port}/ o detén primero (stop.bat / stop.command).") print(f" Abre http://127.0.0.1:{port}/ o ejecuta stop.bat primero.")
return 1 return 1
port_pid = get_pid_listening_on(8000) port_pid = get_pid_listening_on(8000)
if port_pid: if port_pid:
if pid_belongs_to_mp_manager(port_pid): if pid_belongs_to_mp_manager(port_pid):
print(f"[INFO] Hay un MP Manager huerfano en puerto 8000 (PID {port_pid}).") print(f"[INFO] Hay un MP Manager huerfano en puerto 8000 (PID {port_pid}).")
print(f" Detén (stop.bat / stop.command) o usa: python runtime_control.py stop --force") print(f" Ejecuta stop.bat o usa: python runtime_control.py stop --force")
return 1 return 1
cmd = get_process_cmdline(port_pid) or "(desconocido)" cmd = get_process_cmdline(port_pid) or "(desconocido)"
print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):") print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):")
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Investigacion read-only: opp de Marca hNPaauyuqM1Epycwj0wj (ERIKA RUBI CONCHA).
Imprime CF resueltos, contactId, link a sucursal y busca el contacto en sucursales."""
import os, sys
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)
import sync_engine
from scripts import common
gc = sync_engine.ghl_client
BRAND = common.BRAND_LOCATION_ID
OPP_ID = "hNPaauyuqM1Epycwj0wj"
accounts = {a["location_id"]: a for a in common.load_accounts()}
brand_token = accounts[BRAND]["token"]
def keymap(token, loc, obj):
fields = gc.get_object_schema_fields(token, loc, obj)
return {f["id"]: f.get("fieldKey") for f in fields if f.get("id")}
def cfval(cf):
for k in ("fieldValue", "fieldValueString", "value"):
if cf.get(k) is not None:
return cf[k]
return None
opp = (gc.get_opportunity(brand_token, OPP_ID) or {}).get("opportunity") or {}
print("=== OPP MARCA ===")
print("name:", opp.get("name"))
print("status:", opp.get("status"), "value:", opp.get("monetaryValue"))
print("contactId:", opp.get("contactId") or (opp.get("contact") or {}).get("id"))
id2key = keymap(brand_token, BRAND, "opportunity")
print("--- customFields (opp Marca) ---")
link = None
for cf in opp.get("customFields") or []:
k = id2key.get(cf.get("id"))
v = cfval(cf)
print(f" {k} = {v!r}")
if k == "opportunity.id_oportunidad_sucursal":
link = v
print("LINK id_oportunidad_sucursal:", link)
# contacto de Marca
cid = opp.get("contactId") or (opp.get("contact") or {}).get("id")
if cid:
c = (gc._request("GET", f"/contacts/{cid}", brand_token) or {}).get("contact") or {}
print("\n=== CONTACTO MARCA ===")
print("name:", c.get("contactName") or f"{c.get('firstName')} {c.get('lastName')}")
print("phone:", c.get("phone"), "email:", c.get("email"))
ck = keymap(brand_token, BRAND, "contact")
for cf in c.get("customFields") or []:
k = ck.get(cf.get("id"))
if k in ("contact.id_contacto_sucursal", "contact.sucursal", "contact.tienda",
"contact.fuente_de_posible_cliente", "contact.fuente_de_prospecto"):
print(f" {k} = {cf.get('value')!r}")
# buscar la opp link en cada sucursal para identificar la sucursal de origen
if link:
print("\n=== BUSCANDO opp", link, "EN SUCURSALES ===")
for loc, acc in accounts.items():
if loc == BRAND or acc.get("type") == "brand":
continue
try:
bo = (gc.get_opportunity(acc["token"], link) or {}).get("opportunity") or {}
except Exception:
bo = {}
if bo:
print(f" ENCONTRADA en {acc['nombre']} ({loc})")
bk = keymap(acc["token"], loc, "opportunity")
for cf in bo.get("customFields") or []:
k = bk.get(cf.get("id"))
if k in ("opportunity.sucursal", "opportunity.tienda",
"opportunity.fuente_de_posible_cliente", "opportunity.fuente_de_prospecto"):
print(f" {k} = {cfval(cf)!r}")
break
else:
print(" No encontrada por GET directo en ninguna sucursal.")
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read-only: clasifica la incoherencia Canal de Origen / Fuente de Prospecto.
Detecta en SUCURSALES los dos patrones incoherentes y, para cada contacto,
lee ``createdBy.source`` EN VIVO (solo viene en el GET individual) para decidir
la verdad del lead:
Patron A: Canal=SUCURSAL & Fuente=LEAD DIGITAL (viola AGENTS Cap.3)
- createdBy in {WEB_USER, MOBILE_USER} -> manual sucursal -> arreglar FUENTE (->SUCURSAL)
- otro (INTEGRATION/form/etc.) -> lead digital -> arreglar CANAL (->FORMULARIO/FACEBOOK)
Patron B: Fuente=REDES SOCIALES -> lead digital -> Canal=FACEBOOK + Fuente=LEAD DIGITAL
No escribe nada. Imprime la particion y el plan por contacto.
"""
import argparse
import json
import os
import sqlite3
import sys
from collections import Counter
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)
import paths # noqa: E402
from tag_canal_origen_workflow import ( # noqa: E402
MAIN_LOCATION_ID,
contact_display_name,
ghl_request,
load_locations,
)
from canal_origen_resolver import classify_source # noqa: E402
USER_SOURCES = {"WEB_USER", "MOBILE_USER"}
def cfval(custom_fields, fid):
for f in custom_fields or []:
if f.get("id") == fid:
for k in ("value", "fieldValue", "fieldValueString"):
if f.get(k) is not None:
return f[k]
return None
def field_maps(conn, location_id):
m = {}
for r in conn.execute(
"select field_id, field_name from object_schemas where location_id=? and object_key='contact'",
(location_id,),
):
m[r[1].strip().lower()] = r[0]
return m
def incoherent_from_cache(conn, location_id):
"""Devuelve [(contact_id, patron, canal, fuente)] desde la cache."""
fm = field_maps(conn, location_id)
canal_id = fm.get("canal de origen")
fuente_id = fm.get("fuente de prospecto")
out = []
for r in conn.execute(
"select id, custom_fields_json from contacts where location_id=?",
(location_id,),
):
cf = json.loads(r[1] or "[]")
canal = cfval(cf, canal_id)
fuente = cfval(cf, fuente_id)
if canal == "SUCURSAL" and fuente == "LEAD DIGITAL":
out.append((r[0], "A", canal, fuente))
elif fuente == "REDES SOCIALES":
out.append((r[0], "B", canal, fuente))
return out
def get_contact_full(contact_id, token):
data = ghl_request("GET", f"/contacts/{contact_id}", token)
inner = data.get("contact")
return inner if isinstance(inner, dict) else data
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Audit read-only de incoherencia origen/fuente")
parser.add_argument("--all", action="store_true", help="Todas las sucursales productivas")
parser.add_argument("--location", help="Una location especifica")
args = parser.parse_args()
accounts = load_locations(include_main=False)
accounts = [a for a in accounts if a["location_id"] != MAIN_LOCATION_ID]
if args.location:
accounts = [a for a in accounts if a["location_id"] == args.location]
elif not args.all:
raise SystemExit("Usa --all o --location <id>")
conn = sqlite3.connect(paths.DB_PATH)
grand = Counter()
for acc in accounts:
loc = acc["location_id"]
token = acc["token"]
targets = incoherent_from_cache(conn, loc)
if not targets:
continue
print(f"\n{'='*70}\n{acc['nombre']} ({loc}) - {len(targets)} incoherentes\n{'='*70}")
for cid, patron, canal, fuente in targets:
full = get_contact_full(cid, token)
created = (full.get("createdBy") or {}).get("source")
src = full.get("source")
name = contact_display_name(full)
if patron == "B":
plan = "Canal->FACEBOOK + Fuente->LEAD DIGITAL"
bucket = "B_redes->digital"
else:
if created in USER_SOURCES:
plan = "Fuente->SUCURSAL (manual sucursal)"
bucket = "A_manual->fuente"
else:
# createdBy no-usuario => digital; canal segun source
src_tag = classify_source(src)
canal_target = "FACEBOOK" if src_tag == "facebook-ads" else "FORMULARIO"
plan = f"Canal->{canal_target} (digital)"
bucket = f"A_digital->canal({canal_target})"
grand[bucket] += 1
print(f" {name:35.35} | createdBy={created or '-':12} source={src or '-':12} | {plan}")
print(f"\n{'='*70}\nRESUMEN GLOBAL (plan)\n{'='*70}")
for k, v in grand.most_common():
print(f" {v:4} {k}")
if __name__ == "__main__":
main()
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Backfill de custom fields descriptivos en una opp de MARCA que quedó "solo
enlace" (solo `opportunity.id_oportunidad_sucursal`), tomando los valores de la
opp de sucursal enlazada y, como respaldo, del CONTACTO de sucursal.
Caso de origen: la opp de Marca `8HITkGkOn3gN23Tl8LBr` (Miguel Temixco) quedó sin
Sucursal / TIENDA / Canal de Origen / Vehículo. El nodo de réplica las copia por
`fieldKey`, pero esta opp se creó fuera del flujo normal. Mismo `fieldKey`
canónico (sufijo) en contact y opportunity.
Prioridad por campo: (a) valor en la opp de sucursal → (b) valor en el contacto
de sucursal. Solo rellena campos que estén VACÍOS en la opp de Marca (no
sobreescribe). dry-run por defecto; snapshot + script_audit para rollback.
Uso:
python scripts/backfill_brand_opp_cf_from_source.py \\
--brand-opp-id 8HITkGkOn3gN23Tl8LBr --branch-location-id yjqKxoO02rsdwdJZSPmD
# añade --apply para escribir en Bucéfalo
"""
import argparse
import datetime
import json
import os
import sys
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)
import script_audit # noqa: E402
import sync_engine # noqa: E402
from scripts import common # noqa: E402
from paths import MIGRATIONS_DIR # noqa: E402
gc = sync_engine.ghl_client
BRAND_LOCATION_ID = common.BRAND_LOCATION_ID
LINK_KEY = "opportunity.id_oportunidad_sucursal"
# (opp fieldKey canónico, contact fieldKey de respaldo o None).
TARGETS = [
("opportunity.sucursal", "contact.sucursal"),
("opportunity.tienda", "contact.tienda"),
("opportunity.fuente_de_posible_cliente", "contact.fuente_de_posible_cliente"), # CANAL DE ORIGEN
("opportunity.fuente_de_prospecto", "contact.fuente_de_prospecto"),
("opportunity.vehiculo", None), # el contacto guarda marca/versión/año por separado
]
def clean(v):
return "" if v is None else str(v).strip()
def opp_cf_value(cf):
for k in ("fieldValue", "fieldValueString", "value"):
if cf.get(k) is not None:
return cf[k]
return None
def schema_key_by_id(token, location_id, object_key):
"""id -> fieldKey usando el schema dinámico de la location."""
fields = gc.get_object_schema_fields(token, location_id, object_key)
return {f["id"]: f.get("fieldKey") for f in fields if f.get("id")}, \
{f.get("fieldKey"): f for f in fields if f.get("fieldKey")}
def main():
ap = argparse.ArgumentParser(description="Backfill CF descriptivos en opp de Marca desde la opp/contacto de sucursal.")
ap.add_argument("--brand-opp-id", required=True)
ap.add_argument("--branch-location-id", required=True)
ap.add_argument("--brand-location-id", default=BRAND_LOCATION_ID)
ap.add_argument("--apply", action="store_true")
ap.add_argument("--run-id", default=None)
args = ap.parse_args()
accounts = {a["location_id"]: a for a in common.load_accounts()}
brand = accounts.get(args.brand_location_id)
branch = accounts.get(args.branch_location_id)
if not brand or not branch:
raise SystemExit("No se encontró el token de la location de Marca o sucursal en el CSV.")
brand_token, branch_token = brand["token"], branch["token"]
# 1. Opp de Marca (estado actual).
brand_opp = (gc.get_opportunity(brand_token, args.brand_opp_id) or {}).get("opportunity") or {}
if not brand_opp:
raise SystemExit(f"No se pudo leer la opp de Marca {args.brand_opp_id}.")
brand_id2key, brand_key2def = schema_key_by_id(brand_token, args.brand_location_id, "opportunity")
brand_val_by_key = {}
for cf in brand_opp.get("customFields") or []:
k = brand_id2key.get(cf.get("id"))
if k:
brand_val_by_key[k] = opp_cf_value(cf)
# 2. Resolver la opp de sucursal enlazada (del CF link de Marca).
branch_opp_id = clean(brand_val_by_key.get(LINK_KEY))
if not branch_opp_id:
raise SystemExit(f"La opp de Marca no tiene {LINK_KEY}; no se puede resolver el origen.")
branch_opp = (gc.get_opportunity(branch_token, branch_opp_id) or {}).get("opportunity") or {}
if not branch_opp:
raise SystemExit(f"No se pudo leer la opp de sucursal {branch_opp_id}.")
branch_id2key, _ = schema_key_by_id(branch_token, args.branch_location_id, "opportunity")
branch_opp_val_by_key = {}
for cf in branch_opp.get("customFields") or []:
k = branch_id2key.get(cf.get("id"))
if k:
branch_opp_val_by_key[k] = opp_cf_value(cf)
# 3. Contacto de sucursal (respaldo).
branch_contact_val_by_key = {}
cid = branch_opp.get("contactId") or branch_opp.get("contact", {}).get("id")
if cid:
contact = (gc._request("GET", f"/contacts/{cid}", branch_token) or {}).get("contact") or {}
c_id2key, _ = schema_key_by_id(branch_token, args.branch_location_id, "contact")
for cf in contact.get("customFields") or []:
k = c_id2key.get(cf.get("id"))
if k:
branch_contact_val_by_key[k] = cf.get("value")
# 4. Calcular el backfill (solo campos vacíos en Marca).
run_id = args.run_id or f"backfill-opp-cf-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
plan = []
for opp_key, contact_key in TARGETS:
if clean(brand_val_by_key.get(opp_key)) != "":
continue # ya tiene valor, no sobreescribir
value = clean(branch_opp_val_by_key.get(opp_key))
source = "opp_sucursal"
if value == "" and contact_key:
value = clean(branch_contact_val_by_key.get(contact_key))
source = "contacto_sucursal"
if value == "":
continue
bdef = brand_key2def.get(opp_key)
if not bdef:
print(f" WARN: Marca no tiene el campo {opp_key}; se omite.")
continue
plan.append({"opp_key": opp_key, "field_id": bdef["id"], "name": bdef.get("name"),
"value": value, "source": source})
print(f"Opp Marca: {args.brand_opp_id} <- opp sucursal: {branch_opp_id} (contacto {cid})")
if not plan:
print("Nada que rellenar (todos los campos objetivo ya tienen valor o no hay fuente).")
return
print(f"Campos a rellenar ({len(plan)}):")
for p in plan:
print(f" {p['name']:20} [{p['opp_key']}] = {p['value']!r} (fuente: {p['source']})")
if not args.apply:
print("\nDRY-RUN. Vuelve a correr con --apply para escribir en Bucéfalo.")
return
# 5. Snapshot + audit + PUT.
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
snap_path = os.path.join(MIGRATIONS_DIR, f"backfill_opp_cf_{args.brand_opp_id}_{run_id}.json")
with open(snap_path, "w", encoding="utf-8") as fh:
json.dump({"run_id": run_id, "brand_opp_id": args.brand_opp_id, "before": brand_opp, "plan": plan},
fh, ensure_ascii=False, indent=2)
print(f" snapshot -> {snap_path}")
script_audit.create_run(run_id, "backfill_brand_opp_cf_from_source", arguments=" ".join(sys.argv[1:]),
locations=[args.brand_location_id])
change_ids = []
for p in plan:
cidc = script_audit.record_change(run_id, args.brand_location_id, "opportunity", args.brand_opp_id,
p["field_id"], p["name"], None, p["value"])
change_ids.append(cidc)
payload = {"customFields": [{"id": p["field_id"], "key": p["opp_key"], "field_value": p["value"]} for p in plan]}
gc.update_opportunity(brand_token, args.brand_opp_id, payload)
for cidc in change_ids:
script_audit.mark_change(cidc, "applied")
script_audit.update_run_status(run_id, "success")
print(f" PUT aplicado. run_id={run_id} (reversible desde el dashboard).")
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Corrige la incoherencia Canal de Origen / Fuente de Prospecto en contactos
y propaga a TODAS sus oportunidades asociadas.
Detecta por el estado ACTUAL de los campos (idempotente) dos patrones:
Patron A Canal de Origen = SUCURSAL & Fuente de Prospecto = LEAD DIGITAL
(viola AGENTS Cap.3: si Canal=SUCURSAL, Fuente no puede ser LEAD DIGITAL)
-> Contacto: Fuente de Prospecto = SUCURSAL (Canal se conserva)
-> Opps: Canal de Origen de la Oportunidad = Sucursal, Fuente = SUCURSAL
Patron B Fuente de Prospecto = REDES SOCIALES
(REDES SOCIALES es un canal digital mal clasificado como sucursal)
-> Contacto: Canal de Origen = FACEBOOK, Fuente de Prospecto = LEAD DIGITAL
-> Opps: Canal de Origen de la Oportunidad = Facebook,
Tipo de Lead = Lead digital, Fuente de Prospecto = LEAD DIGITAL
Decision validada con el usuario el 2026-05-30 (createdBy.source en vivo confirmo
que los 28 del Patron A son captura manual de sucursal -> WEB_USER/MOBILE_USER).
Dry-run por defecto. Con --apply --run-id registra cada cambio en script_audit
(reversible desde el dashboard). Resuelve los IDs de campo dinamicamente por nombre
(FIELD_ALIASES), nunca hardcodea. Sirve igual para sucursales y para Marca: como
la deteccion es por estado, correrlo en Marca arregla el panel directamente sin
depender de la replicacion.
"""
import argparse
import os
import sys
from collections import Counter
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)
import script_audit # noqa: E402
import sync_engine # noqa: E402
import common # noqa: E402
from tag_canal_origen_workflow import ( # noqa: E402
contact_display_name,
get_all_contacts,
get_all_opportunities_for_contact,
get_custom_field_value,
get_opportunity,
get_schemas,
load_locations,
resolve_opp_field_ids,
safe_update_contact_field,
safe_update_opportunity_field,
)
SCRIPT_NAME = "fix_origen_fuente_incoherencia.py"
# Plan de correccion por patron (alineado a TAG_TO_OPP_UPDATES del workflow canonico).
PLAN = {
"A": {
"contact": {"Fuente de Prospecto": "SUCURSAL"},
"opp": {
"Canal de Origen de la Oportunidad": "Sucursal",
"Fuente de Prospecto": "SUCURSAL",
},
},
"B": {
"contact": {"Canal de Origen": "FACEBOOK", "Fuente de Prospecto": "LEAD DIGITAL"},
"opp": {
"Canal de Origen de la Oportunidad": "Facebook",
"Tipo de Lead": "Lead digital",
"Fuente de Prospecto": "LEAD DIGITAL",
},
},
}
# Mapeo display-name de opp -> alias key de common.FIELD_ALIASES (tolerante a variantes).
OPP_FIELD_ALIAS_KEY = {
"Canal de Origen de la Oportunidad": "canal_origen",
"Tipo de Lead": "tipo_lead",
"Fuente de Prospecto": "fuente_prospecto",
}
def resolve_contact_field_id(contact_schema, display_name):
"""contact_schema es {name: id}. Resuelve por FIELD_ALIASES, tolerante a mayusculas."""
alias_key = {
"Canal de Origen": "canal_origen",
"Fuente de Prospecto": "fuente_prospecto",
}.get(display_name, display_name)
candidates = common.FIELD_ALIASES.get(alias_key, [display_name])
norm_to_id = {common.normalize_name(n): i for n, i in (contact_schema or {}).items()}
for cand in candidates:
fid = norm_to_id.get(common.normalize_name(cand))
if fid:
return fid
return None
def resolve_opp_field_ids_alias(opp_fields_list, display_name):
alias_key = OPP_FIELD_ALIAS_KEY.get(display_name, display_name)
candidates = common.FIELD_ALIASES.get(alias_key, [display_name])
target_norm = {common.normalize_name(n) for n in candidates}
return [
f["id"]
for f in (opp_fields_list or [])
if f.get("name") and f.get("id") and common.normalize_name(f["name"]) in target_norm
]
def classify_patron(canal, fuente):
if canal == "SUCURSAL" and fuente == "LEAD DIGITAL":
return "A"
if fuente == "REDES SOCIALES":
return "B"
return None
def process_location(account, *, dry_run, run_id):
location_id = account["location_id"]
token = account["token"]
name = account["nombre"]
local = Counter()
contact_schema = get_schemas(location_id, token, "contact")["contact"]
canal_id = resolve_contact_field_id(contact_schema, "Canal de Origen")
fuente_id = resolve_contact_field_id(contact_schema, "Fuente de Prospecto")
if not canal_id or not fuente_id:
print(f" SKIP {name}: faltan campos de contacto (Canal={bool(canal_id)}, Fuente={bool(fuente_id)})")
return local
opp_fields_list = sync_engine.ghl_client.get_object_schema_fields(token, location_id, "opportunity")
contacts = get_all_contacts(location_id, token)
targets = []
for c in contacts:
canal = get_custom_field_value(c, canal_id)
fuente = get_custom_field_value(c, fuente_id)
patron = classify_patron(canal, fuente)
if patron:
targets.append((c, patron))
if not targets:
return local
print(f"\n{'='*70}\n{name} ({location_id}) - {len(targets)} contactos a corregir\n{'='*70}")
for contact, patron in targets:
if not script_audit.wait_if_paused_or_stopped(run_id):
print("\n Detencion segura solicitada. Saliendo antes del siguiente contacto.")
break
cid = contact["id"]
display = contact_display_name(contact)
local[f"patron_{patron}"] += 1
# --- Contacto ---
for field_name, value in PLAN[patron]["contact"].items():
fid = canal_id if field_name == "Canal de Origen" else fuente_id
if safe_update_contact_field(run_id, location_id, contact, fid, field_name, value, token, dry_run):
local["contact_fields"] += 1
print(f" [contacto {patron}] {display} | {field_name} -> {value}")
# --- Oportunidades asociadas (todas) ---
for opp_summary in get_all_opportunities_for_contact(location_id, cid, token):
opp_id = opp_summary.get("id")
if not opp_id:
continue
opp = get_opportunity(location_id, opp_id, token) or opp_summary
opp_touched = False
for field_name, value in PLAN[patron]["opp"].items():
for fid in resolve_opp_field_ids_alias(opp_fields_list, field_name) or resolve_opp_field_ids(opp_fields_list, field_name):
if safe_update_opportunity_field(run_id, location_id, opp_id, opp, fid, field_name, value, token, dry_run):
opp_touched = True
if opp_touched:
local["opps"] += 1
print(f" [opp {patron}] {opp_id} | {opp.get('name') or display}")
print(f" -> {name}: A={local['patron_A']} B={local['patron_B']} | "
f"campos contacto={local['contact_fields']}, opps={local['opps']}")
return local
def select_locations(args):
accounts = load_locations(include_main=True)
if args.location:
m = [a for a in accounts if a["location_id"] == args.location]
if not m:
raise SystemExit(f"Location {args.location} no esta en el CSV")
return m
if args.all:
return accounts
raise SystemExit("Especifica --location <id> o --all. Sin --apply corre en dry-run.")
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Corrige incoherencia Canal/Fuente + propaga a opps")
parser.add_argument("--location", help="Location ID especifico (sucursal o Marca)")
parser.add_argument("--all", action="store_true", help="Todas las cuentas del CSV (sucursales + Marca)")
parser.add_argument("--apply", action="store_true", help="Aplica en el CRM. Sin este flag corre en dry-run.")
parser.add_argument("--run-id", help="ID de auditoria (lo da el dashboard; en CLI se crea si se aplica)")
args = parser.parse_args()
dry_run = not args.apply
run_id = args.run_id
accounts = select_locations(args)
print("=" * 70)
print("FIX INCOHERENCIA ORIGEN / FUENTE (+ propagacion a oportunidades)")
print("=" * 70)
print(f"Modo: {'DRY-RUN (sin cambios)' if dry_run else 'APPLY (escribe en el CRM)'}")
print(f"Cuentas objetivo: {len(accounts)}")
if not dry_run and run_id:
script_audit.create_run(run_id, SCRIPT_NAME, arguments=" ".join(sys.argv[1:]),
locations=[a["location_id"] for a in accounts])
grand = Counter()
errors = 0
for account in accounts:
try:
for k, v in process_location(account, dry_run=dry_run, run_id=run_id).items():
grand[k] += v
except Exception as exc: # noqa: BLE001
errors += 1
print(f"\nERROR en {account['nombre']}: {exc}")
print("\n" + "=" * 70)
print(f"RESUMEN: Patron A={grand['patron_A']}, Patron B={grand['patron_B']}, "
f"campos de contacto={grand['contact_fields']}, opps tocadas={grand['opps']}, errores={errors}")
if dry_run:
print("Dry-run terminado. Revisa el plan y vuelve a correr con --apply --run-id <uuid>.")
if not dry_run and run_id:
script_audit.update_run_status(run_id, "failed" if errors else "success",
f"{errors} errores" if errors else None)
if errors:
raise SystemExit(1)
if __name__ == "__main__":
main()
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# setup_mac.command — Inicialización con UN CLICK de MP Manager en macOS/Linux.
#
# Equivalente "de arranque" que NO existe en Windows (allá se asume Python en el
# PATH). En macOS el Python del sistema suele ser 3.9 y está bloqueado para pip
# (PEP 668), así que aquí:
# 1. Detecta un Python 3.10+ del sistema.
# 2. Crea un entorno virtual aislado en .venv (no toca el Python del sistema).
# 3. Instala las dependencias de requirements.txt.
# 4. Instala el navegador Chromium para Playwright.
# 5. Crea el archivo .env a partir de .env.example si no existe.
#
# Es idempotente: puedes volver a ejecutarlo para actualizar dependencias.
# Doble clic en Finder para correrlo.
# ---------------------------------------------------------------------------
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
banner "MP Manager - Configuración inicial (macOS / Linux)"
# --- 1. Buscar Python 3.10+ del sistema -----------------------------------
info "Buscando un Python ${PY_MIN_MAJOR}.${PY_MIN_MINOR}+ en el sistema..."
SYS_PY="$(find_system_python || true)"
if [ -z "$SYS_PY" ]; then
err "No se encontró Python ${PY_MIN_MAJOR}.${PY_MIN_MINOR} o superior."
echo
echo " Instálalo y reintenta. La forma más simple en macOS:"
echo " brew install python@3.12"
echo " (o descárgalo de https://www.python.org/downloads/macos/)"
hold_window
exit 1
fi
ok "Python encontrado: $SYS_PY ($("$SYS_PY" --version 2>&1))"
# --- 2. Crear / reutilizar el entorno virtual ------------------------------
if [ -x "$VENV_PY" ]; then
info "Entorno virtual ya existe en .venv (se reutiliza)."
else
info "Creando entorno virtual en .venv ..."
"$SYS_PY" -m venv "$VENV_DIR"
ok "Entorno virtual creado."
fi
# --- 3. Instalar dependencias ---------------------------------------------
info "Actualizando pip ..."
"$VENV_PY" -m pip install --upgrade pip >/dev/null
info "Instalando dependencias de requirements.txt (puede tardar) ..."
"$VENV_PY" -m pip install -r "$PROJECT_DIR/requirements.txt"
ok "Dependencias de Python instaladas."
# --- 4. Navegador de Playwright -------------------------------------------
# Los scripts ghl_browser_*.py automatizan la UI web con Chromium. Sin esto
# fallarían al lanzar el navegador.
info "Instalando Chromium para Playwright ..."
if "$VENV_PY" -m playwright install chromium; then
ok "Chromium instalado."
else
warn "No se pudo instalar Chromium para Playwright."
warn "El dashboard funcionará; solo los scripts de navegador (workflows) fallarán."
warn "Puedes reintentar luego con: .venv/bin/python -m playwright install chromium"
fi
# --- 5. Archivo .env -------------------------------------------------------
if [ -f "$PROJECT_DIR/.env" ]; then
info "Archivo .env ya existe (no se sobrescribe)."
elif [ -f "$PROJECT_DIR/.env.example" ]; then
cp "$PROJECT_DIR/.env.example" "$PROJECT_DIR/.env"
ok "Creado .env a partir de .env.example — revísalo y completa tus credenciales."
else
warn "No hay .env.example; omito la creación de .env."
fi
echo
banner "Configuración completada"
echo " Para arrancar el servidor: doble clic en start.command"
echo " Para detenerlo: doble clic en stop.command"
echo " Para reiniciarlo: doble clic en restart.command"
echo
echo " La primera vez, macOS puede pedir permiso para abrir un .command"
echo " descargado: clic derecho > Abrir, o Ajustes > Privacidad y seguridad."
hold_window
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# start.command — Equivalente macOS/Linux de start.bat.
# Arranca el servidor MP Manager en modo NORMAL.
# 1. Verifica que exista el venv (creado por setup_mac.command).
# 2. Pre-vuelo: rechaza relanzar si ya hay una instancia; avisa si el puerto
# 8000 lo tiene otro proyecto (main.py salta al siguiente libre).
# 3. Deja huella del modo (last_mode=normal) para que restart.command sepa
# cómo relanzar.
# 4. Lanza FastAPI en segundo plano (nohup) con la ruta ABSOLUTA a main.py
# —para que runtime_control pueda identificar el proceso— y registra logs.
# main.py escribe server_info.json con el puerto real y abre el navegador solo.
# ---------------------------------------------------------------------------
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
banner "MP Manager - Iniciando Servidor Monte Providencia"
PY="$(require_venv)" || { hold_window; exit 1; }
# --- Pre-vuelo (misma lógica que Windows, vía runtime_control.py) ----------
set +e
"$PY" runtime_control.py preflight
PRE=$?
set -e
if [ "$PRE" -eq 1 ]; then
echo
info "No se relanza. Si quieres reiniciar usa restart.command."
hold_window
exit 1
fi
if [ "$PRE" -eq 2 ]; then
echo
info "Continuando: MP Manager buscará el siguiente puerto libre."
echo
fi
# --- Huella de modo activo -------------------------------------------------
mkdir -p generated/runtime
echo "normal" > generated/runtime/last_mode
# --- Lanzar servidor en segundo plano --------------------------------------
mkdir -p generated/logs
LOG="$PROJECT_DIR/generated/logs/server.out"
info "Iniciando servidor FastAPI..."
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
disown || true
echo
echo "==================================================="
echo " El servidor se está iniciando en segundo plano."
echo " El navegador se abrirá solo en el puerto correcto"
echo " (8000 o el siguiente libre)."
echo
echo " Logs: generated/logs/server.out"
echo " Para detener: stop.command"
echo " Reiniciar: restart.command"
echo "==================================================="
hold_window
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# start_persistent_profile.command — Equivalente macOS/Linux de
# start_persistent_profile.bat.
# Arranca el servidor con el PERFIL PERSISTENTE de Chrome para Playwright
# (más estable que la sesión shared). Todos los scripts ghl_browser_*.py usarán
# generated/browser/profile en lugar de generated/browser/session.json.
# ---------------------------------------------------------------------------
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
banner "MP Manager - Modo Perfil Persistente Bucéfalo"
PY="$(require_venv)" || { hold_window; exit 1; }
# --- Pre-vuelo -------------------------------------------------------------
set +e
"$PY" runtime_control.py preflight
PRE=$?
set -e
if [ "$PRE" -eq 1 ]; then
echo
info "No se relanza. Usa restart.command para reiniciar el modo persistente."
hold_window
exit 1
fi
# --- Perfil persistente ----------------------------------------------------
export GHL_BROWSER_PROFILE_DIR="$PROJECT_DIR/generated/browser/profile"
info "GHL_BROWSER_PROFILE_DIR=$GHL_BROWSER_PROFILE_DIR"
if [ ! -d "$GHL_BROWSER_PROFILE_DIR" ]; then
echo
warn "El perfil aún no existe. La primera vez tendrás que generar la sesión:"
echo " 1. Ve a la pestaña Workflows y dale a \"Renovar sesión Bucéfalo\"."
echo " 2. Inicia sesión + MFA en la ventana del navegador."
echo " 3. La sesión se persistirá automáticamente en este directorio."
echo
fi
info "Iniciando servidor FastAPI con perfil persistente activo..."
mkdir -p generated/runtime generated/logs
echo "persistent" > generated/runtime/last_mode
LOG="$PROJECT_DIR/generated/logs/server.out"
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
disown || true
echo
echo "==================================================="
echo " Servidor iniciado en modo perfil persistente."
echo " Logs: generated/logs/server.out"
echo " Para detener: stop.command"
echo
echo " NOTA: en este modo no puedes correr dos scripts de"
echo " Playwright al mismo tiempo contra el mismo perfil."
echo "==================================================="
hold_window
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# stop.command — Equivalente macOS/Linux de stop.bat.
# Delega TODO a runtime_control.py para garantizar que solo se detiene MP
# Manager (nunca otro proyecto Python que escuche en el puerto 8000).
#
# Acepta los mismos flags que el .bat, p.ej.:
# ./stop.command --force (apaga también instancias huérfanas sin server_info)
# ---------------------------------------------------------------------------
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
banner "MP Manager - Deteniendo Servidor Monte Providencia"
PY="$(require_venv)" || { hold_window; exit 1; }
set +e
"$PY" runtime_control.py stop "$@"
RC=$?
set -e
hold_window
exit "$RC"