234 lines
10 KiB
Python
234 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Garantiza que la opp sincronizada a Marca traiga Sucursal / TIENDA / Canal de
|
|
Origen, derivándolos del CONTACTO de sucursal cuando la opp de origen no los
|
|
trae (workflow `Cfgwp0bOtDW8zuKW` "Sincronizar Oportunidad - Nodos Nuevos").
|
|
|
|
Contexto: los nodos `Armar Body - CREATE/UPDATE` ya copian TODOS los custom
|
|
fields de la opp de sucursal → Marca por `fieldKey`. Pero si la opp de origen
|
|
llega sin esos CF (carrera de tiempo / creación fuera de flujo), la opp de Marca
|
|
queda vacía (caso `8HIT…` de Miguel Temixco). El CONTACTO siempre los tiene
|
|
(poblados por [1604]/[2004]) y usa el MISMO `fieldKey` canónico que la opp:
|
|
contact.sucursal -> opportunity.sucursal
|
|
contact.tienda -> opportunity.tienda
|
|
contact.fuente_de_posible_cliente-> opportunity.fuente_de_posible_cliente (CANAL DE ORIGEN)
|
|
|
|
Cambios (solo AÑADE / enriquece, preserva el flujo actual):
|
|
1. Nodo nuevo `Obtener Contacto - SUCURSAL` (GET /contacts/{id}) insertado
|
|
entre `Obtener info de Oportunidad - SUCURSAL` y `Obtener Pipelines - SUCURSAL`.
|
|
2. Extiende el Code node `Mapeo completo oportunidad origen - SUCURSAL` para,
|
|
tras enriquecer los CF de la opp, hacer upsert de Sucursal/TIENDA/Canal con
|
|
prioridad (a) valor de la opp → (b) valor del contacto → (c) webhook.
|
|
El loop genérico de `Armar Body` los propaga a Marca por `fieldKey`.
|
|
No depende de leer-después-de-escribir (sin race).
|
|
|
|
Uso:
|
|
python n8n/_add_contact_to_opp_mapping.py # dry-run (dumpea payload)
|
|
python n8n/_add_contact_to_opp_mapping.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 = "Cfgwp0bOtDW8zuKW"
|
|
|
|
# Nodos existentes (referencias).
|
|
MAPEO_NODE = "Mapeo completo oportunidad origen - SUCURSAL"
|
|
OBTENER_INFO_NODE = "Obtener info de Oportunidad - SUCURSAL"
|
|
OBTENER_PIPELINES_NODE = "Obtener Pipelines - SUCURSAL"
|
|
DATOS_API_SUCURSAL = "DATOS API - SUCURSAL"
|
|
CF_DEFS_NODE = "Conseguir Custom Fields - Opportunity - SUCURSAL"
|
|
|
|
# Nodo nuevo.
|
|
GET_CONTACT_NODE = "Obtener Contacto - SUCURSAL"
|
|
|
|
# Marca de idempotencia dentro del jsCode extendido.
|
|
ENRICH_MARKER = "CONTACT->OPP ENRICH"
|
|
|
|
CONTACT_ID_EXPR = "{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}"
|
|
TOKEN_EXPR = "{{ $('" + DATOS_API_SUCURSAL + "').item.json['Token/API'] }}"
|
|
|
|
# Bloque que se inyecta en el Code node, justo antes de `const stageInfo`.
|
|
ENRICH_BLOCK = r"""
|
|
// ── CONTACT->OPP ENRICH: garantizar Sucursal / TIENDA / Canal de Origen ──
|
|
// Respaldo cuando la opp de sucursal no trae el CF: tomar el valor del CONTACTO
|
|
// (siempre poblado por [1604]/[2004]) y, en ultimo caso, del webhook. El fieldKey
|
|
// canonico (sufijo) es identico en contact y opportunity.
|
|
const contactResp = $('Obtener Contacto - SUCURSAL').first().json;
|
|
const contact = (contactResp && contactResp.contact) ? contactResp.contact : (contactResp || {});
|
|
const webhookBody = ($('Webhook').first().json && $('Webhook').first().json.body) || {};
|
|
const norm2 = (s) => (s == null ? '' : String(s)).trim();
|
|
|
|
// Valores del contacto por fieldKey (contacts traen {id, value}).
|
|
const contactValueByFieldKey = {};
|
|
for (const cf of (contact.customFields || [])) {
|
|
const fk = fieldMap[cf.id] ? fieldMap[cf.id].fieldKey : null;
|
|
if (fk) contactValueByFieldKey[fk] = cf.value;
|
|
}
|
|
|
|
const ENRICH_TARGETS = [
|
|
{ oppKey: 'opportunity.sucursal', contactKey: 'contact.sucursal', webhookKey: 'Sucursal' },
|
|
{ oppKey: 'opportunity.tienda', contactKey: 'contact.tienda', webhookKey: 'TIENDA' },
|
|
{ oppKey: 'opportunity.fuente_de_posible_cliente', contactKey: 'contact.fuente_de_posible_cliente', webhookKey: 'CANAL DE ORIGEN' },
|
|
];
|
|
|
|
for (const t of ENRICH_TARGETS) {
|
|
const existing = enrichedCustomFields.find(cf => cf.fieldKey === t.oppKey);
|
|
if (existing && norm2(existing.fieldValue) !== '') continue; // (a) ya viene de la opp
|
|
let value = norm2(contactValueByFieldKey[t.contactKey]); // (b) contacto
|
|
if (value === '') value = norm2(webhookBody[t.webhookKey]); // (c) webhook
|
|
if (value === '') continue;
|
|
if (existing) {
|
|
existing.fieldValue = value;
|
|
} else {
|
|
const def = customFieldsDefs.find(d => d.fieldKey === t.oppKey);
|
|
enrichedCustomFields.push({ id: def ? def.id : null, name: def ? def.name : null, fieldKey: t.oppKey, fieldValue: value });
|
|
}
|
|
}
|
|
// ── fin CONTACT->OPP ENRICH ──
|
|
"""
|
|
|
|
|
|
def ensure_model_all(cf_node):
|
|
"""Asegura que `Conseguir Custom Fields…` pase ?model=all (sin esto el
|
|
endpoint /locations/{id}/customFields solo devuelve campos de CONTACTO, y el
|
|
mapeo genérico descarta TODOS los CF nativos de la opp). Devuelve True si
|
|
cambió algo."""
|
|
p = cf_node.setdefault("parameters", {})
|
|
p["sendQuery"] = True
|
|
qp = p.setdefault("queryParameters", {})
|
|
params = qp.setdefault("parameters", [])
|
|
for entry in params:
|
|
if entry.get("name") == "model":
|
|
if entry.get("value") == "all":
|
|
return False
|
|
entry["value"] = "all"
|
|
return True
|
|
params.append({"name": "model", "value": "all"})
|
|
return True
|
|
|
|
|
|
def build_get_contact_node():
|
|
return {
|
|
"parameters": {
|
|
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR,
|
|
"sendHeaders": True,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{"name": "Accept", "value": "application/json"},
|
|
{"name": "Version", "value": "2021-07-28"},
|
|
{"name": "Authorization", "value": "=Bearer " + TOKEN_EXPR},
|
|
]
|
|
},
|
|
"options": {"redirect": {"redirect": {}}},
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [1750, -32],
|
|
"name": GET_CONTACT_NODE,
|
|
"onError": "continueRegularOutput",
|
|
"notes": "Trae el contacto de sucursal para derivar Sucursal/TIENDA/Canal de Origen de la opp cuando la opp de origen no los trae. onError=continue para no romper el sync.",
|
|
}
|
|
|
|
|
|
def inject_enrich(js_code):
|
|
"""Inserta ENRICH_BLOCK antes de `const stageInfo` (anchor estable)."""
|
|
anchor_candidates = [
|
|
"// Resolve pipeline and stage info",
|
|
"const stageInfo =",
|
|
]
|
|
for anchor in anchor_candidates:
|
|
idx = js_code.find(anchor)
|
|
if idx != -1:
|
|
return js_code[:idx] + ENRICH_BLOCK + "\n" + js_code[idx:]
|
|
raise SystemExit(
|
|
"No se encontró un anchor para inyectar el bloque de enriquecimiento "
|
|
"en el jsCode del nodo Mapeo. Revisar el Code node manualmente."
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Mapea Sucursal/TIENDA/Canal de Origen del contacto a la opp en el workflow Cfgwp0bOtDW8zuKW."
|
|
)
|
|
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="contact_to_opp_mapping")
|
|
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}")
|
|
|
|
# Validar que existen los nodos de referencia.
|
|
for nm in (MAPEO_NODE, OBTENER_INFO_NODE, OBTENER_PIPELINES_NODE, DATOS_API_SUCURSAL, CF_DEFS_NODE):
|
|
if client.find_node(wf, nm) is None:
|
|
raise SystemExit(f"No se encontró el nodo esperado {nm!r}.")
|
|
|
|
# Idempotencia.
|
|
code_node = client.find_node(wf, MAPEO_NODE)
|
|
cf_node = client.find_node(wf, CF_DEFS_NODE)
|
|
already_node = client.find_node(wf, GET_CONTACT_NODE) is not None
|
|
already_code = ENRICH_MARKER in (code_node["parameters"].get("jsCode") or "")
|
|
cf_params = (cf_node.get("parameters") or {}).get("queryParameters", {}).get("parameters", [])
|
|
already_query = any(e.get("name") == "model" and e.get("value") == "all" for e in cf_params)
|
|
if already_node and already_code and already_query:
|
|
raise SystemExit("El complemento ya fue aplicado (nodo + jsCode + model=all). Nada que hacer.")
|
|
|
|
# 0. Asegurar ?model=all en el fetch de custom fields (trae defs de opp).
|
|
if ensure_model_all(cf_node):
|
|
print(f" Nodo {CF_DEFS_NODE!r}: query 'model=all' añadido (ahora trae defs de opportunity).")
|
|
else:
|
|
print(f" Nodo {CF_DEFS_NODE!r} ya tenía 'model=all'.")
|
|
|
|
# 1. Nodo nuevo GET contacto + cableado.
|
|
if not already_node:
|
|
client.add_node(wf, build_get_contact_node())
|
|
client.insert_between(wf, OBTENER_INFO_NODE, GET_CONTACT_NODE, OBTENER_PIPELINES_NODE)
|
|
print(f" Nodo {GET_CONTACT_NODE!r} insertado entre "
|
|
f"{OBTENER_INFO_NODE!r} y {OBTENER_PIPELINES_NODE!r}.")
|
|
else:
|
|
print(f" Nodo {GET_CONTACT_NODE!r} ya existía (no se re-inserta).")
|
|
|
|
# 2. Enriquecer el Code node.
|
|
if not already_code:
|
|
code_node["parameters"]["jsCode"] = inject_enrich(code_node["parameters"]["jsCode"])
|
|
print(f" Code node {MAPEO_NODE!r}: bloque de enriquecimiento inyectado.")
|
|
else:
|
|
print(f" Code node {MAPEO_NODE!r} ya tenía el bloque (no se re-inyecta).")
|
|
|
|
expected = [GET_CONTACT_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()
|