256 lines
9.8 KiB
Python
256 lines
9.8 KiB
Python
#!/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()
|