Files
MP-Manager/n8n/_add_contact_to_opp_mapping.py
T
2026-05-30 20:16:12 -06:00

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()