#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Copia el ID NATIVO de cada contacto a su propio custom field "ID Contacto Sucursal" (fieldKey: contact.id_contacto_sucursal). SOLO aplica a SUCURSALES (excluye la cuenta de Marca). En la sucursal, cada contacto queda autoetiquetado con su propio `id`. Propósito: habilitar la sincronización determinística sucursal↔Marca por este campo (análogo al de oportunidades). El workflow n8n y el sync_missing_contacts usan este valor para encontrar/actualizar el contacto Marca correspondiente sin heurísticas. Flujo: - Lee TODOS los contactos de cada sucursal EN VIVO (paginado), no del cache, para tener el estado actual del custom field. - Para cada contacto, si el campo ya vale exactamente su `id`, se omite (idempotente). - Si no, PUT /contacts/{id} con customFields=[{id,key,field_value: contact.id}]. GHL MERGEA customFields en el PUT (validado): preserva los demás. Cada cambio se registra en script_audit (rollback) y se guarda un snapshot por cuenta en generated/migrations/. Sin --apply corre en DRY-RUN. Uso: python scripts/fill_contact_id_sucursal.py --location # dry-run python scripts/fill_contact_id_sucursal.py --demos --apply --run-id python scripts/fill_contact_id_sucursal.py --all --apply --run-id """ import argparse import datetime import json import os import sys import warnings warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!") 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) import sync_engine # noqa: E402 import script_audit # noqa: E402 from paths import MIGRATIONS_DIR # noqa: E402 BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3" DEMO_LOCATION_IDS = ["Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"] FIELD_FKEY = "contact.id_contacto_sucursal" FIELD_NAME_NORM = "id contacto sucursal" gc = sync_engine.ghl_client def safe_print(*args, **kwargs): text = " ".join(str(a) for a in args) 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 norm(s): return " ".join(str(s or "").strip().lower().split()) def resolve_field(location_id, token): """(field_id, field_key) del campo en una location (legacy endpoint).""" data = gc._request("GET", f"/locations/{location_id}/customFields", token, params={"model": "contact"}) for cf in data.get("customFields", []) or []: if cf.get("fieldKey") == FIELD_FKEY or norm(cf.get("name")) == FIELD_NAME_NORM: return cf.get("id"), cf.get("fieldKey") return None, None def search_all_contacts(token, location_id, page_limit=100): """Lista todos los contactos de una location vía POST /contacts/search, que SÍ incluye customFields en cada item (a diferencia de GET /contacts/). Pagina por `searchAfter` que viene en el último contact de cada batch. Defensa anti-loop: tope de páginas + corta si batch < page_limit. """ out = [] search_after = None page_count = 0 while page_count < 500: # tope defensivo (= 500 * 100 = 50k contactos) body = {"locationId": location_id, "pageLimit": page_limit} if search_after is not None: body["searchAfter"] = search_after data = gc._request("POST", "/contacts/search", token, json=body) batch = data.get("contacts") or [] if not batch: break out.extend(batch) page_count += 1 if len(batch) < page_limit: break last = batch[-1] new_after = last.get("searchAfter") if not new_after or new_after == search_after: break search_after = new_after return out def current_field_value(contact, field_id): """Valor actual del CF en un contacto del search (maneja variantes GHL).""" for cf in contact.get("customFields") or []: if cf.get("id") == field_id or cf.get("fieldId") == field_id: return cf.get("value") or cf.get("fieldValue") or cf.get("fieldValueString") return None def plan_account(account): loc = account["location_id"] token = account["token"] field_id, field_key = resolve_field(loc, token) if not field_id: return None, None, [{"status": "field_missing", "details": "El campo 'ID Contacto Sucursal' no existe en esta cuenta."}] contacts = search_all_contacts(token, loc) actions = [] for c in contacts: cid = c.get("id") if not cid: continue cur = current_field_value(c, field_id) if cur == cid: actions.append({"status": "already_ok", "contact_id": cid}) else: name = f"{c.get('firstName') or ''} {c.get('lastName') or ''}".strip() or c.get("contactName") or "" actions.append({"status": "to_set", "contact_id": cid, "old": cur, "new": cid, "name": name}) return field_id, field_key, actions def apply_actions(account, field_id, field_key, actions, *, dry_run, run_id): stats = {"set": 0, "skipped": 0, "errors": []} to_set = [a for a in actions if a["status"] == "to_set"] snap = { "account": account["nombre"], "location_id": account["location_id"], "field_id": field_id, "field_key": field_key, "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "dry_run": dry_run, "total_contacts": len(actions), "to_set": [{"contact_id": a["contact_id"], "old": a.get("old"), "new": a["new"]} for a in to_set], } os.makedirs(MIGRATIONS_DIR, exist_ok=True) ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") safe = "".join(c if c.isalnum() else "_" for c in account["nombre"])[:40] snap_path = os.path.join(MIGRATIONS_DIR, f"fill_contactid_{safe}_{ts}.json") with open(snap_path, "w", encoding="utf-8") as fh: json.dump(snap, fh, ensure_ascii=False, indent=2, default=str) stats["skipped"] = sum(1 for a in actions if a["status"] != "to_set") if dry_run: return stats, snap_path token = account["token"] for a in to_set: cid = a["contact_id"] change_id = None if run_id: change_id = script_audit.record_change( run_id, account["location_id"], "contact", cid, field_id, FIELD_NAME_NORM, {"value": a.get("old")}, {"value": cid}) try: gc._request("PUT", f"/contacts/{cid}", token, json={ "customFields": [{"id": field_id, "key": field_key, "field_value": cid}] }) if change_id: script_audit.mark_change(change_id, "applied") stats["set"] += 1 except Exception as exc: if change_id: script_audit.mark_change(change_id, "failed", str(exc)) stats["errors"].append({"contact_id": cid, "error": str(exc)}) return stats, snap_path def print_plan(account, actions): to_set = [a for a in actions if a["status"] == "to_set"] ok = sum(1 for a in actions if a["status"] == "already_ok") miss = [a for a in actions if a["status"] == "field_missing"] safe_print(f"\n[Plan] {account['nombre']} ({account['location_id']})") if miss: safe_print(f" ✗ {miss[0]['details']}") return safe_print(f" contactos totales={len(actions)} ya ok={ok} a setear={len(to_set)}") for a in to_set[:6]: safe_print(f" ▸ contact {a['contact_id']} {a.get('name')!r} campo: {a.get('old')!r} → {a['new']!r}") if len(to_set) > 6: safe_print(f" … y {len(to_set) - 6} más") def select_targets(args, accounts): if args.location: m = [a for a in accounts if a["location_id"] == args.location] if not m: raise SystemExit(f"Location {args.location} no encontrada.") if args.location == BRAND_LOCATION_ID: raise SystemExit("Este script es SOLO para sucursales; no aplica a Marca.") return m if args.demos: return [a for a in accounts if a["location_id"] in DEMO_LOCATION_IDS] if args.all_productive: return [a for a in accounts if a["location_id"] not in DEMO_LOCATION_IDS and a["location_id"] != BRAND_LOCATION_ID] if args.all: return [a for a in accounts if a["location_id"] != BRAND_LOCATION_ID] raise SystemExit("Especifica --location , --demos, --all-productive o --all.") def run_fill(location_ids=None, contact_ids=None, dry_run=True, run_id=None, log=None): """Ejecuta el llenado del campo 'ID Contacto Sucursal' y devuelve un dict JSON-serializable. Pensado para uso programático (endpoint del dashboard) y CLI. Args: location_ids: lista de location_ids (sucursales) a procesar. None = todas las sucursales (excluye Marca). NUNCA toca Marca (defensa). contact_ids: iterable opcional de contact_ids para filtrar (solo se actualizan los contactos en esta lista). None = todos los detectados. dry_run: True (default) = no escribe en GHL. run_id: id para script_audit (rollback). log: callable opcional log(line). """ if log is None: log = safe_print contact_filter = set(contact_ids) if contact_ids else None accounts = sync_engine.parse_accounts_csv() accounts_by_id = {a["location_id"]: a for a in accounts} if location_ids: targets = [] for lid in location_ids: if lid == BRAND_LOCATION_ID: continue acc = accounts_by_id.get(lid) if acc: targets.append(acc) else: targets = [a for a in accounts if a["location_id"] != BRAND_LOCATION_ID] if run_id: script_audit.create_run( run_id, "fill_contact_id_sucursal.py", arguments=("contact_ids:%d" % len(contact_filter)) if contact_filter else "all-branches", locations=[t["location_id"] for t in targets]) log(f"Modo: {'DRY-RUN' if dry_run else 'APPLY'}") log(f"Cuentas en scope (sucursales): {len(targets)}") if contact_filter is not None: log(f"Filtro contact_ids: {len(contact_filter)} contactos") summary = { "accounts_processed": 0, "contacts_reviewed": 0, "set": 0, "skipped": 0, "errors": 0, } items = [] errors_global = [] for acc in targets: if not script_audit.wait_if_paused_or_stopped(run_id): log("Detención solicitada. Saliendo.") break item = {"location_id": acc["location_id"], "account": acc["nombre"]} try: field_id, field_key, actions = plan_account(acc) except Exception as exc: log(f" [{acc['nombre']}] ERROR plan: {exc}") errors_global.append({"account": acc["nombre"], "error": str(exc)}) summary["errors"] += 1 item.update({"status": "plan_error", "error": str(exc)}) items.append(item) continue if contact_filter is not None: actions = [a for a in actions if a.get("contact_id") in contact_filter] contacts_in_scope = sum(1 for a in actions if a.get("status") in ("to_set", "already_ok")) summary["contacts_reviewed"] += contacts_in_scope print_plan(acc, actions) if field_id is None: item.update({"status": "field_missing", "contacts_in_scope": contacts_in_scope}) items.append(item) continue try: stats, snap_path = apply_actions(acc, field_id, field_key, actions, dry_run=dry_run, run_id=run_id) summary["set"] += stats["set"] summary["skipped"] += stats["skipped"] errors_global.extend(stats["errors"]) summary["errors"] += len(stats["errors"]) summary["accounts_processed"] += 1 log(f" snapshot: {snap_path}") if not dry_run: log(f" ✓ set={stats['set']} skipped={stats['skipped']} errors={len(stats['errors'])}") item.update({ "status": "ok", "contacts_in_scope": contacts_in_scope, "set": stats["set"], "skipped": stats["skipped"], "errors": len(stats["errors"]), "snapshot": snap_path, }) items.append(item) except Exception as exc: log(f" ✗ apply falló: {exc}") errors_global.append({"account": acc["nombre"], "error": str(exc)}) summary["errors"] += 1 item.update({"status": "apply_error", "error": str(exc)}) items.append(item) log("\n" + "=" * 60) if dry_run: log("DRY-RUN — no se modificó nada. Revisa el plan y vuelve a correr con --apply.") log(f"Cuentas procesadas : {len(targets)}") log(f"Contactos revisados: {summary['contacts_reviewed']}") log(f"Campos seteados : {summary['set']}") log(f"Saltados (ya ok) : {summary['skipped']}") log(f"Errores : {len(errors_global)}") for e in errors_global[:20]: log(f" - {e}") return { "dry_run": dry_run, "summary": summary, "items": items, "errors": errors_global, } def main(): parser = argparse.ArgumentParser( description="Copia el id nativo de cada contacto a su custom field 'ID Contacto Sucursal' (solo sucursales).") parser.add_argument("--location", help="Procesar solo esta cuenta (no Marca).") parser.add_argument("--demos", action="store_true", help="Solo las 2 cuentas DEMO.") parser.add_argument("--all-productive", dest="all_productive", action="store_true", help="Todas las sucursales productivas (excluye Marca y DEMOs).") parser.add_argument("--all", action="store_true", help="Todas las sucursales (excluye Marca).") parser.add_argument("--apply", action="store_true", help="Ejecuta los cambios. Sin este flag corre en DRY-RUN.") parser.add_argument("--run-id", help="ID para registrar en script_audit (rollback).") args = parser.parse_args() if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") accounts = sync_engine.parse_accounts_csv() targets = select_targets(args, accounts) location_ids = [t["location_id"] for t in targets] run_fill(location_ids=location_ids, contact_ids=None, dry_run=not args.apply, run_id=args.run_id, log=safe_print) if __name__ == "__main__": main()