Files
MP-Manager/n8n/_add_canal_origen_branch.py
T
2026-05-30 14:31:19 -06:00

269 lines
10 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Complementa el workflow n8n [2004] (ddUEORBEtZLzsQF2) para que, al crear un
contacto en sucursal, además de rellenar contact.sucursal / contact.tienda,
escriba "Canal de Origen" = SUCURSAL y deje el tag de origen correcto, PERO solo
cuando el contacto fue creado por un usuario (createdBy.source ∈ {WEB_USER,
MOBILE_USER}). Los creados por integración (INTEGRATION) no se tocan.
Replica en TIEMPO REAL el criterio del batch scripts/fix_branch_user_origin.py.
Cambios (solo AÑADE, preserva el flujo actual):
1. Extiende el Code node "Buscar contact.sucursal y contact.tienda" para
resolver también el field "Canal de Origen" (por fieldKey con fallback a
nombre) y exponer createdBySource / esUsuario (leído del GET del contacto).
2. Tras el PUT actual de sucursal/tienda añade:
IF "Creado por usuario" -> [true] PUT Canal de Origen = SUCURSAL
-> Tag+ sucursal -> Tag- formulario -> Tag- facebook-ads
[false] (fin: no se toca).
Uso:
python n8n/_add_canal_origen_branch.py # dry-run (dumpea payload)
python n8n/_add_canal_origen_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 ( # noqa: E402
AlreadyExistsError,
N8NClient,
load_credentials,
)
WID = "ddUEORBEtZLzsQF2"
# Nodos existentes (referencias).
CODE_NODE = 'Buscar "contact.sucursal" y "contact.tienda"'
GET_CONTACT_NODE = "Obtener Contacto Cuenta Origen - SUCURSAL"
PUT_SUCURSAL_NODE = "Actualizar Contacto Cuenta Objetivo - SUCURSAL"
VERIFICADOR_NODE = "Buscar Sucursal en Verificador de Sucursales"
# Nodos nuevos.
IF_NODE = "Creado por usuario (Canal de Origen)"
PUT_CANAL_NODE = "PUT Canal de Origen = SUCURSAL"
TAG_ADD_NODE = "Tag+ sucursal"
TAG_RM_FORM_NODE = "Tag- formulario"
TAG_RM_FB_NODE = "Tag- facebook-ads"
# Expresiones reutilizables (referencian nodos upstream por nombre).
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 sucursal/tienda EXACTO y añade canal + esUsuario.
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"]);
// 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";
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 ?? [],
},
createdBySource: createdBySource,
esUsuario: esUsuario,
},
},
];"""
def http_node(name, position, *, method, body_obj_expr, on_error=False):
"""Construye un nodo httpRequest (typeVersion 4.2) con auth Bearer en header."""
params = {
"method": method,
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR + ("/tags" if method in ("POST", "DELETE") else ""),
"sendHeaders": True,
"headerParameters": {"parameters": [dict(h) for h in HEADERS]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": body_obj_expr,
"options": {"redirect": {"redirect": {}}},
}
node = {
"parameters": params,
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": position,
"name": name,
}
if on_error:
# Un DELETE de un tag inexistente puede no aplicar; no debe romper el flujo.
node["onError"] = "continueRegularOutput"
return node
def build_nodes():
# PUT Canal de Origen = SUCURSAL (solo este CF; NO toca Fuente de Prospecto).
canal_body = (
"={\n"
' "customFields": [\n'
" {\n"
' "id": "{{ ' + CODE_REF + '.canal.id }}",\n'
' "key": "{{ ' + CODE_REF + '.canal.fieldKey }}",\n'
' "field_value": "SUCURSAL"\n'
" }\n"
" ]\n"
"}"
)
put_canal = {
"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": canal_body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [940, -120],
"name": PUT_CANAL_NODE,
}
if_node = {
"parameters": {
"conditions": {
"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 3},
"conditions": [
{
"id": "canal-origen-esusuario",
"leftValue": "={{ " + CODE_REF + ".esUsuario }}",
"rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [720, -120],
"name": IF_NODE,
}
tag_add = http_node(TAG_ADD_NODE, [1160, -120], method="POST",
body_obj_expr='={\n "tags": ["sucursal"]\n}')
tag_rm_form = http_node(TAG_RM_FORM_NODE, [1380, -120], method="DELETE",
body_obj_expr='={\n "tags": ["formulario"]\n}', on_error=True)
tag_rm_fb = http_node(TAG_RM_FB_NODE, [1600, -120], method="DELETE",
body_obj_expr='={\n "tags": ["facebook-ads"]\n}', on_error=True)
return if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb
def main():
parser = argparse.ArgumentParser(description="Añade la rama Canal de Origen al workflow n8n [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="canal_origen")
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: si ya están los nodos nuevos, no re-aplicar.
for nm in (IF_NODE, PUT_CANAL_NODE, TAG_ADD_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.")
# 1. Extender el Code node.
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 (canal + esUsuario).")
# 2. Añadir nodos nuevos.
if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb = build_nodes()
for n in (if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb):
client.assert_idempotent(wf, n["name"])
client.add_node(wf, n)
print(" Nodos añadidos: IF + PUT canal + 3 de tags.")
# 3. Conexiones: PUT sucursal -> IF -> [true] PUT canal -> tag+ -> tag- form -> tag- fb.
client.set_connection(wf, PUT_SUCURSAL_NODE, IF_NODE) # main:0
client.set_connection(wf, IF_NODE, PUT_CANAL_NODE, output_index=0) # rama true
# rama false (index 1): sin destino (fin).
client.set_connection(wf, PUT_CANAL_NODE, TAG_ADD_NODE)
client.set_connection(wf, TAG_ADD_NODE, TAG_RM_FORM_NODE)
client.set_connection(wf, TAG_RM_FORM_NODE, TAG_RM_FB_NODE)
print(" Conexiones cableadas.")
expected = [IF_NODE, PUT_CANAL_NODE, TAG_ADD_NODE, TAG_RM_FORM_NODE, TAG_RM_FB_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()