#!/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()