#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Backfill de la tabla de mapeo Baserow id_opp_sucursal -> id_opp_marca. Contexto: el workflow n8n "Sincronizar Oportunidad - Nodos Nuevos" (Cfgwp0bOtDW8zuKW) decide UPDATE-vs-CREATE buscando la opp existente en Marca SOLO entre las opps del contacto resuelto ese run. Cuando la identidad del contacto en Marca es ambigua (mismo telefono / nombre variante), resuelve un contacto distinto al de la opp original, no la encuentra y hace CREATE -> replica duplicada (causa del descuadre positivo Marca > sucursales). El fix: una tabla Baserow que mapea de forma GLOBAL el id de la opp de sucursal al id de la opp de Marca. El workflow consultara esa tabla antes de decidir; si existe el mapeo -> UPDATE de esa opp (independiente del contacto). Este script puebla la tabla con el estado ACTUAL: cada opp de Marca con "ID Oportunidad Sucursal" valido => fila {id_opp_sucursal, id_opp_marca}. Idempotente: upsert por id_opp_sucursal (crea si falta, actualiza si cambio el id_opp_marca). Dry-run por defecto. Uso: python scripts/backfill_baserow_opp_mapping.py --table-id # dry-run python scripts/backfill_baserow_opp_mapping.py --table-id --apply python scripts/backfill_baserow_opp_mapping.py --table-id --json """ import argparse import datetime import json import os import sqlite3 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) SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPTS_DIR not in sys.path: sys.path.insert(0, SCRIPTS_DIR) from paths import DB_PATH # noqa: E402 from baserow_client import BaserowClient # noqa: E402 from audit_brand_vs_branches_totals import ( # noqa: E402 resolve_opp_link_field_id, extract_opp_link_value, OPP_ID_PATTERN, BRAND_LOCATION_ID, ) # Nombres de columna esperados en la tabla de mapeo (user_field_names). COL_SUC = "id_opp_sucursal" COL_MARCA = "id_opp_marca" COL_LOC = "location_id_sucursal" COL_UPDATED = "updated_at" def safe_print(*a): text = " ".join(str(x) for x in a) try: sys.stdout.write(text + "\n"); sys.stdout.flush() except UnicodeEncodeError: enc = sys.stdout.encoding or "utf-8" sys.stdout.write(text.encode(enc, errors="replace").decode(enc) + "\n"); sys.stdout.flush() def load_brand_mappings(): """Lee de SQLite las opps de Marca y devuelve [{id_opp_sucursal, id_opp_marca, location_id_sucursal}].""" conn = sqlite3.connect(DB_PATH); conn.row_factory = sqlite3.Row try: fid = resolve_opp_link_field_id(conn, BRAND_LOCATION_ID) # location de origen: la opp de sucursal con ese id nativo branch_loc_by_oppid = { r["id"]: r["location_id"] for r in conn.execute( "SELECT id, location_id FROM opportunities WHERE location_id != ?", (BRAND_LOCATION_ID,), ) } out = [] for r in conn.execute( "SELECT id, custom_fields_json FROM opportunities WHERE location_id = ?", (BRAND_LOCATION_ID,), ): v = extract_opp_link_value(r["custom_fields_json"], fid) if v and OPP_ID_PATTERN.match(str(v)): out.append({ COL_SUC: str(v), COL_MARCA: r["id"], COL_LOC: branch_loc_by_oppid.get(str(v), ""), }) return out finally: conn.close() def run(table_id, apply=False, log=safe_print): cli = BaserowClient.from_credentials() mappings = load_brand_mappings() log(f"Opps de Marca con link valido: {len(mappings)}") existing = cli.list_rows(table_id) by_suc = {} dup_keys = 0 for row in existing: k = str(row.get(COL_SUC) or "").strip() if not k: continue if k in by_suc: dup_keys += 1 by_suc.setdefault(k, row) log(f"Filas existentes en la tabla {table_id}: {len(existing)} (claves duplicadas: {dup_keys})") ts = datetime.datetime.now(datetime.timezone.utc).isoformat() to_create, to_update, ok = [], [], 0 for m in mappings: cur = by_suc.get(m[COL_SUC]) if cur is None: to_create.append(m) elif str(cur.get(COL_MARCA) or "") != m[COL_MARCA]: to_update.append({"row_id": cur["id"], **m}) else: ok += 1 log(f"Plan: crear={len(to_create)} actualizar={len(to_update)} ya_ok={ok}") summary = {"total_mappings": len(mappings), "existing_rows": len(existing), "to_create": len(to_create), "to_update": len(to_update), "already_ok": ok, "created": 0, "updated": 0, "errors": 0, "duplicate_keys_in_table": dup_keys} if not apply: log("DRY-RUN. Para aplicar: --apply") return {"summary": summary, "to_create": to_create, "to_update": to_update} # Backup de la tabla antes de mutar. bpath = cli.backup_table(table_id, label="opp_mapping_prebackfill") log(f"Backup: {bpath}") for m in to_create: try: cli.create_row(table_id, {**m, COL_UPDATED: ts}, dry_run=False) summary["created"] += 1 except Exception as e: summary["errors"] += 1; log(f" ERROR create {m[COL_SUC]}: {e}") for u in to_update: rid = u.pop("row_id") try: cli.update_row(table_id, rid, {**u, COL_UPDATED: ts}, dry_run=False) summary["updated"] += 1 except Exception as e: summary["errors"] += 1; log(f" ERROR update {u[COL_SUC]}: {e}") log(f"Resultado: created={summary['created']} updated={summary['updated']} errors={summary['errors']}") return {"summary": summary} def main(): p = argparse.ArgumentParser(description="Backfill tabla Baserow de mapeo opp sucursal->marca.") p.add_argument("--table-id", type=int, required=True, help="ID de la tabla de mapeo en Baserow.") p.add_argument("--apply", action="store_true", help="Aplica (sin esto, dry-run).") p.add_argument("--json", action="store_true", help="Salida JSON.") args = p.parse_args() if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") res = run(args.table_id, apply=args.apply, log=(lambda *a: None) if args.json else safe_print) if args.json: print(json.dumps(res, ensure_ascii=False, indent=2, default=str)) if __name__ == "__main__": main()