Files
MP-Manager/n8n/_add_baserow_opp_idempotency.py
T
2026-05-30 14:31:19 -06:00

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