#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Fase 1 — SUCURSAL → MARCA: match primario por id_contacto_sucursal. Insertar entre `Datos API Cuenta objetivo - MARCA` y `Buscar Contacto Objetivo - MARCA (phone)`: 1) HTTP search por CF 2) IF que decide CF match único - true → Capturar ID Match (reutilizado) - false → Buscar Contacto Objetivo - MARCA (phone) (cascada existente) Modificar los code nodes de CREATE y UPDATE para que inyecten el CF `contact.id_contacto_sucursal` con el id del contacto origen, garantizando enlace incluso si el match cayó a la cascada. Uso: python n8n/_apply_phase1.py # dry-run python n8n/_apply_phase1.py --apply """ import argparse import copy import json import os import re import sys import uuid ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) sys.path.insert(0, os.path.join(ROOT, "scripts")) from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402 WID = "x4DqZ5FtSc43tdzB" CF_ID_BRAND = "E6lI9ykWhqpj7Pmi7Qd3" NEW_HTTP_NAME = "Buscar Contacto Objetivo - MARCA (id_contacto_sucursal)" NEW_IF_NAME = "¿id_contacto_sucursal match único?" def http_node_search_by_cf(): return { "parameters": { "method": "POST", "url": "https://services.leadconnectorhq.com/contacts/search", "sendHeaders": True, "headerParameters": { "parameters": [ {"name": "Version", "value": "2021-07-28"}, {"name": "Authorization", "value": "=Bearer {{ $('Datos API Cuenta objetivo - MARCA').item.json['Token/API'] }}"}, ] }, "sendBody": True, "specifyBody": "json", "jsonBody": ( "={\n" " \"locationId\": \"{{ $('Datos API Cuenta objetivo - MARCA').item.json['Location ID'] }}\",\n" " \"pageLimit\": 5,\n" " \"filters\": [\n" " {\n" " \"group\": \"AND\",\n" " \"filters\": [\n" " {\n" " \"field\": \"customFields." + CF_ID_BRAND + "\",\n" " \"operator\": \"eq\",\n" " \"value\": \"{{ $('Crear Contacto').item.json.body.contact_id }}\"\n" " }\n" " ]\n" " }\n" " ]\n" "}" ), "options": {"redirect": {"redirect": {}}}, }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2832, -496], "id": str(uuid.uuid4()), "name": NEW_HTTP_NAME, "onError": "continueRegularOutput", "alwaysOutputData": True, "notes": ( "FASE 1 — Match primario por contact.id_contacto_sucursal. " "Si total==1, el siguiente IF deriva a Capturar ID Match. " "Si no, sigue a la cascada phone→email→nombre." ), } def if_node_cf_match(): return { "parameters": { "conditions": { "options": { "caseSensitive": True, "leftValue": "", "typeValidation": "strict", "version": 3, }, "conditions": [ { "id": str(uuid.uuid4()), "leftValue": "={{ $json.total }}", "rightValue": 1, "operator": {"type": "number", "operation": "equals"}, } ], "combinator": "and", }, "options": {}, }, "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [3040, -496], "id": str(uuid.uuid4()), "name": NEW_IF_NAME, "notes": "FASE 1. true → Capturar ID Match. false → cascada phone.", } # ---- Patch helpers para code nodes ---- INJECT_BLOCK_UPDATE = ( "\n// === FASE 1: inyectar CF id_contacto_sucursal (defensive) ===\n" "var __linkId = '" + CF_ID_BRAND + "';\n" "customFields = customFields.filter(function(c){ return c.id !== __linkId; });\n" "if (origenData && origenData.id) {\n" " customFields.push({ id: __linkId, key: 'contact.id_contacto_sucursal', field_value: origenData.id });\n" "}\n" ) INJECT_BLOCK_CREATE_FK = ( "\n// === FASE 1: garantizar CF contact.id_contacto_sucursal ===\n" "(function(){\n" " var origenId = ($('Obtener Contacto Cuenta Origen - SUCURSAL').first().json.contact || {}).id;\n" " if (!origenId) return;\n" " // Encontrar el def en Marca por fieldKey.\n" " var defLink = marcaCustomDefs.find(function(d){ return d.fieldKey === 'contact.id_contacto_sucursal'; });\n" " if (!defLink) return;\n" " customFields = customFields.filter(function(c){ return c.id !== defLink.id; });\n" " customFields.push({ id: defLink.id, field_value: origenId });\n" "})();\n" ) def patch_update_code(wf, client): """Modifica `Obtener Body para Actualizar Contacto Objetivo - MARCA`.""" node = client.find_node(wf, "Obtener Body para Actualizar Contacto Objetivo - MARCA") if not node: raise RuntimeError("nodo update no encontrado") code = node["parameters"].get("jsCode") or "" if "FASE 1: inyectar CF id_contacto_sucursal" in code: print(" [skip] code update ya tiene el bloque FASE 1") return False # Insertar antes de `var body = {` marker = "var body = {" idx = code.rfind(marker) if idx < 0: raise RuntimeError("marker 'var body = {' no encontrado en code update") new_code = code[:idx] + INJECT_BLOCK_UPDATE + code[idx:] node["parameters"]["jsCode"] = new_code return True def patch_create_code(wf, client): """Modifica `Obtener el body para crear Contacto - MARCA`.""" node = client.find_node(wf, "Obtener el body para crear Contacto - MARCA") if not node: raise RuntimeError("nodo create no encontrado") code = node["parameters"].get("jsCode") or "" if "FASE 1: garantizar CF contact.id_contacto_sucursal" in code: print(" [skip] code create ya tiene el bloque FASE 1") return False marker = "var body = {" idx = code.rfind(marker) if idx < 0: raise RuntimeError("marker 'var body = {' no encontrado en code create") new_code = code[:idx] + INJECT_BLOCK_CREATE_FK + code[idx:] node["parameters"]["jsCode"] = new_code return True # ---- Main ---- def main(): parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true", help="Aplica al workflow. Sin esto: dry-run.") parser.add_argument("--activate", action="store_true", help="Tras apply OK, activar el workflow.") args = parser.parse_args() if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") client = N8NClient(*load_credentials()) print(f"[fase 1] GET workflow {WID}") wf, backup_path = client.backup_workflow(WID, label="fase1_pre") prev_version = wf.get("versionId") print(f" backup: {backup_path}") print(f" versionId: {prev_version} active: {wf.get('active')}") print(f" nodes pre: {len(wf['nodes'])}") # Trabajar sobre copia wfm = copy.deepcopy(wf) # Idempotencia try: client.assert_idempotent(wfm, NEW_HTTP_NAME) client.assert_idempotent(wfm, NEW_IF_NAME) except Exception as e: raise SystemExit(f"Aborto: {e}") # Insertar nodos print("[fase 1] insertando nodos nuevos...") http_node = http_node_search_by_cf() if_node = if_node_cf_match() client.add_node(wfm, http_node) client.add_node(wfm, if_node) # Reconectar: # antes: Datos API Cuenta objetivo - MARCA → Buscar Contacto Objetivo - MARCA (phone) # ahora: Datos API → HTTP nuevo → IF nuevo # IF true → Capturar ID Match # IF false → Buscar Contacto Objetivo - MARCA (phone) client.set_connection(wfm, "Datos API Cuenta objetivo - MARCA", NEW_HTTP_NAME) client.set_connection(wfm, NEW_HTTP_NAME, NEW_IF_NAME) client.branch_if(wfm, NEW_IF_NAME, true_target="Capturar ID Match", false_target="Buscar Contacto Objetivo - MARCA (phone)") # Modificar code nodes print("[fase 1] modificando code UPDATE...") patch_update_code(wfm, client) print("[fase 1] modificando code CREATE...") patch_create_code(wfm, client) print(f"[fase 1] nodes post: {len(wfm['nodes'])} (+{len(wfm['nodes'])-len(wf['nodes'])})") # Dry-run print("[fase 1] dry-run PUT (dump a archivo)...") res = client.put_workflow(WID, wfm, dry_run=True) print(f" dry-run path: {res['path']}") if not args.apply: print("\nDRY-RUN. Para aplicar: --apply [--activate]") return # Apply real print("[fase 1] PUT real...") client.put_workflow(WID, wfm, dry_run=False) print(" PUT OK. Verificando...") wf_post = client.verify_post( WID, expected_node_names=[NEW_HTTP_NAME, NEW_IF_NAME], prev_version_id=prev_version, ) print(f" versionId nuevo: {wf_post.get('versionId')}") print(f" active post-PUT: {wf_post.get('active')}") if args.activate or not wf_post.get("active"): print("[fase 1] activando workflow...") client.activate(WID) wf_post2 = client.get_workflow(WID) print(f" active final: {wf_post2.get('active')}") print("\n[fase 1] aplicado y activado.") print("Siguiente paso: python scripts/n8n_e2e_test.py --scenario all-phase1") if __name__ == "__main__": main()