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