#!/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()