descripción
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user