descripción
This commit is contained in:
@@ -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.
|
||||
- `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
|
||||
- **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice. Sigue en `without_tienda` + `not_in_any_branch`.
|
||||
- **Lead digital de miguel** (opp Marca 1A3P5b $80,200, sin link): es un lead digital creado directo en Marca que nunca bajó a su sucursal (Temixco). Idealmente lo baja la cascada n8n Marca→Sucursal. Queda solo-Marca; el conteo cuadra igual. Revisar si ese lead digital es válido y debe cascar.
|
||||
- **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice.
|
||||
- **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
|
||||
- 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
|
||||
@@ -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 |
|
||||
|---|---|---|---|---|---|
|
||||
| 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-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) |
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user