202 lines
7.6 KiB
Python
202 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Endurece el workflow de sync de opps (Cfgwp0bOtDW8zuKW) con idempotencia
|
|
GLOBAL via mapeo Baserow, para que NO vuelva a crear replicas duplicadas en
|
|
Marca cuando la identidad del contacto es ambigua.
|
|
|
|
Problema (caso 2026-05-30): el nodo 'Decidir Match (Create vs Update)' busca la
|
|
opp existente SOLO entre las opps del contacto resuelto ese run. Si resuelve un
|
|
contacto distinto al de la opp original (mismo telefono/nombre variante), no la
|
|
encuentra -> CREATE -> duplicado (descuadre positivo Marca>Sucursales).
|
|
|
|
Fix:
|
|
1. Nuevo nodo Baserow 'Buscar Mapeo Opp - Baserow' (getAll, tabla 754,
|
|
filtro id_opp_sucursal == id de la opp de sucursal). alwaysOutputData=true
|
|
+ onError=continueRegularOutput (si falla o no hay match, sigue el flujo).
|
|
2. Se inserta EN SERIE: Set Contact ID Resuelto -> [Buscar Mapeo Opp] ->
|
|
Buscar Oportunidades del Contacto - MARCA -> Decidir Match. Las refs por
|
|
nombre ($('NodoX')) de los nodos siguientes se mantienen intactas.
|
|
3. 'Decidir Match' reescrito: si el mapeo Baserow tiene id_opp_marca -> UPDATE
|
|
esa opp (global, independiente del contacto); si no, cae al match por CF
|
|
entre las opps del contacto (logica previa); si nada -> CREATE.
|
|
|
|
La frescura del mapeo (opps nuevas) la cubre el backfill idempotente agendado
|
|
(scripts/backfill_baserow_opp_mapping.py --table-id 754), no un nodo create
|
|
fragil dentro del flujo de produccion.
|
|
|
|
Dry-run por defecto (dump a n8n/dryrun_*.json); --apply para PUT real.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from scripts.n8n_workflow_lib import load_credentials, N8NClient
|
|
|
|
WID = "Cfgwp0bOtDW8zuKW"
|
|
BASEROW_DB = 63
|
|
MAPPING_TABLE = 754
|
|
FIELD_ID_OPP_SUCURSAL = 7280 # campo "id_opp_sucursal" (primario) en tabla 754
|
|
BASEROW_CRED = {"baserowApi": {"id": "LZztQ3WMpzXjSTIH", "name": "Baserow account"}}
|
|
|
|
NAME_LOOKUP = "Buscar Mapeo Opp - Baserow"
|
|
SRC = "Set Contact ID Resuelto"
|
|
NEXT = "Buscar Oportunidades del Contacto - MARCA"
|
|
DECIDE = "Decidir Match (Create vs Update)"
|
|
|
|
NEW_DECIDE_CODE = """// DECISION (Create vs Update) con idempotencia GLOBAL via mapeo Baserow (tabla 754).
|
|
// (1) Si el mapeo id_opp_sucursal -> id_opp_marca existe -> UPDATE esa opp de Marca,
|
|
// independiente del contacto resuelto (evita duplicados por contacto ambiguo).
|
|
// (2) Si no hay mapeo, match por CF entre las opps del contacto resuelto.
|
|
// (3) Si nada -> CREATE.
|
|
const result = $input.first().json;
|
|
const opportunities = result.opportunities || [];
|
|
const sucursalOppId = $('Datos de Lead').first().json.Oportunidad.opportunity_id;
|
|
|
|
let action = 'CREATE';
|
|
let opportunityId = null;
|
|
let matchReason = '';
|
|
|
|
// (1) Mapeo Baserow global
|
|
let baserowRows = [];
|
|
try { baserowRows = $('Buscar Mapeo Opp - Baserow').all().map(i => i.json); } catch (e) { baserowRows = []; }
|
|
const mapped = baserowRows.find(r =>
|
|
String(r.id_opp_sucursal || '') === String(sucursalOppId) && String(r.id_opp_marca || '').trim()
|
|
);
|
|
|
|
if (mapped) {
|
|
action = 'UPDATE';
|
|
opportunityId = String(mapped.id_opp_marca).trim();
|
|
matchReason = 'Match por mapeo Baserow (global, tabla 754)';
|
|
} else {
|
|
// (2) match por CF entre las opps del contacto resuelto
|
|
const match = opportunities.find(o =>
|
|
(o.customFields || []).some(cf => {
|
|
const v = cf.fieldValueString ?? cf.fieldValue ?? '';
|
|
return String(v) === String(sucursalOppId);
|
|
})
|
|
);
|
|
if (match) {
|
|
action = 'UPDATE';
|
|
opportunityId = match.id;
|
|
matchReason = 'Match por ID Oportunidad Sucursal (contacto)';
|
|
} else {
|
|
action = 'CREATE';
|
|
matchReason = 'Sin match (Baserow ni contacto) -> CREATE (contacto tiene ' + opportunities.length + ' opps)';
|
|
}
|
|
}
|
|
|
|
return [{
|
|
json: {
|
|
action,
|
|
opportunityId,
|
|
matchReason,
|
|
sucursalOppId,
|
|
contactId: $('Set Contact ID Resuelto').first().json.contactId,
|
|
totalOppsContacto: opportunities.length
|
|
}
|
|
}];
|
|
"""
|
|
|
|
|
|
def build_lookup_node():
|
|
return {
|
|
"parameters": {
|
|
"databaseId": BASEROW_DB,
|
|
"tableId": MAPPING_TABLE,
|
|
"limit": 1,
|
|
"additionalOptions": {
|
|
"filters": {
|
|
"fields": [
|
|
{
|
|
"field": FIELD_ID_OPP_SUCURSAL,
|
|
"value": "={{ $('Datos de Lead').item.json.Oportunidad.opportunity_id }}",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
},
|
|
"type": "n8n-nodes-base.baserow",
|
|
"typeVersion": 1.1,
|
|
"position": [4200, 1140],
|
|
"id": str(uuid.uuid4()),
|
|
"name": NAME_LOOKUP,
|
|
"credentials": BASEROW_CRED,
|
|
"alwaysOutputData": True,
|
|
"onError": "continueRegularOutput",
|
|
"notes": (
|
|
"Idempotencia global: busca en la tabla 754 el mapeo id_opp_sucursal->"
|
|
"id_opp_marca. Si existe, 'Decidir Match' hara UPDATE de esa opp aunque "
|
|
"el contacto resuelto sea otro. alwaysOutputData + onError para no romper "
|
|
"el flujo si no hay match o si Baserow falla."
|
|
),
|
|
}
|
|
|
|
|
|
def parse_args():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--apply", action="store_true",
|
|
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
|
|
return ap.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
client = N8NClient(*load_credentials())
|
|
|
|
print(f"[1/6] GET workflow {WID}")
|
|
wf = client.get_workflow(WID)
|
|
print(f" versionId previo: {wf.get('versionId')} | active: {wf.get('active')} | nodos: {len(wf.get('nodes') or [])}")
|
|
|
|
existing = {n["name"] for n in wf.get("nodes") or []}
|
|
for needed in [SRC, NEXT, DECIDE, "Datos de Lead"]:
|
|
if needed not in existing:
|
|
print(f"\nERROR: falta nodo requerido {needed!r}. Abortando.")
|
|
sys.exit(2)
|
|
if NAME_LOOKUP in existing:
|
|
print(f"\nERROR: nodo {NAME_LOOKUP!r} ya existe. Abortando para no duplicar.")
|
|
sys.exit(2)
|
|
|
|
print(f"\n[2/6] Backup fresco")
|
|
_, bpath = client.backup_workflow(WID, label="pre_baserow_opp_idempotency")
|
|
print(f" backup -> {bpath}")
|
|
|
|
print(f"\n[3/6] Inyectar nodo lookup Baserow")
|
|
client.add_node(wf, build_lookup_node())
|
|
print(f" + {NAME_LOOKUP}")
|
|
|
|
print(f"\n[4/6] Reescribir codigo de '{DECIDE}'")
|
|
decide = client.find_node(wf, DECIDE)
|
|
decide["parameters"]["jsCode"] = NEW_DECIDE_CODE
|
|
print(" jsCode actualizado (chequeo Baserow -> fallback contacto -> CREATE)")
|
|
|
|
print(f"\n[5/6] Reconectar grafo")
|
|
conns = wf["connections"]
|
|
# SRC -> LOOKUP (antes SRC -> NEXT)
|
|
conns[SRC] = {"main": [[{"node": NAME_LOOKUP, "type": "main", "index": 0}]]}
|
|
# LOOKUP -> NEXT
|
|
conns[NAME_LOOKUP] = {"main": [[{"node": NEXT, "type": "main", "index": 0}]]}
|
|
# NEXT -> DECIDE se mantiene (no se toca)
|
|
print(f" {SRC} -> {NAME_LOOKUP} -> {NEXT} -> {DECIDE}")
|
|
|
|
print(f"\n[6/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
|
|
result = client.put_workflow(WID, wf, dry_run=not args.apply)
|
|
if not args.apply:
|
|
print(f" dry-run dump -> {result}")
|
|
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
|
|
return
|
|
|
|
new_wf = client.verify_post(WID, expected_node_names=[NAME_LOOKUP], prev_version_id=wf.get("versionId"))
|
|
print(f" versionId nuevo: {new_wf.get('versionId')}")
|
|
if not new_wf.get("active"):
|
|
print(" reactivando workflow...")
|
|
client.activate(WID)
|
|
new_wf = client.get_workflow(WID)
|
|
print(f" active final: {new_wf.get('active')} | nodos: {len(new_wf.get('nodes') or [])}")
|
|
print("\nOK. Workflow endurecido. Corre el test E2E para validar UPDATE vs CREATE.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|