#!/usr/bin/env python3 """Aplica un LAYOUT explícito (folder + orden) a los custom fields de cada cuenta. A diferencia de align_custom_field_order.py (que usa Marca como referencia), aquí el orden lo define una tabla declarativa `LAYOUT` editable abajo: por cada objeto (contact/opportunity) y folder, la lista ordenada de campos por NOMBRE. Para cada campo listado se asigna: - parentId = el folder destino (resuelto por NOMBRE en cada cuenta), moviéndolo de folder si hace falta. - position = posición incremental según su orden en la lista (100, 200, 300…). Reglas: - Los campos NO listados en el LAYOUT no se tocan (quedan en su folder/posición). - Match por nombre normalizado (case + acentos + espacios insensitive). - Campos estándar (First Name, Phone, Email…) NO los expone la API → no se tocan. - Si un campo o folder del LAYOUT no existe en la cuenta, se reporta y se omite. Verificado contra la API V2 de GHL: `PUT /locations/{loc}/customFields/{id}` con `{"parentId": ..., "position": N}` aplica ambos (la `position` sola es ignorada). Cambio puramente organizacional (UI): no afecta valores, workflows ni formularios. Uso: python scripts/apply_custom_field_layout.py --location # dry-run python scripts/apply_custom_field_layout.py --location --apply --run-id python scripts/apply_custom_field_layout.py --all --apply --run-id """ import argparse import datetime import json import os import sys import time import unicodedata import warnings warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!") import requests 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 BASE_URL = "https://services.leadconnectorhq.com" API_VERSION = "2021-07-28" BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3" DEMO_LOCATION_IDS = ["Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"] POSITION_STEP = 100 # separación entre campos consecutivos dentro de un folder # ─────────────────────────────────────────────────────────────────────────── # LAYOUT declarativo — editar aquí el orden deseado. # Estructura: { object_key: [ (folder_name, [field_name_en_orden, ...]), ... ] } # Solo se tocan los campos listados. Los demás quedan intactos. # ─────────────────────────────────────────────────────────────────────────── LAYOUT = { "opportunity": [ ("Opportunity Details", [ "Persona que atendió al prospecto", "Vehículo", "Modalidad de Empeño", "Fuente de Prospecto", "Visita a Sucursal", "Fecha de ultima visita a sucursal", "Lead Descartado", "CANAL DE ORIGEN", "Sucursal", "TIENDA", "ID Oportunidad Sucursal", ]), ], "contact": [ ("Contact", [ "Marca del Vehículo", "Versión del Vehículo", "Año del Vehículo", "¿Qué modalidad prefieres?", "TIENDA", ]), # Campos sacados del folder "Contact" hacia "Additional Info" (después de # los que ya viven ahí). Se listan los existentes para fijar el orden. ("Additional Info", [ "Descripción", "Presupuesto", "Archivos Adicionales", "Sucursal", "Información Adicional", "Fuente de Prospecto", "ID Contacto Monte Providencia", "ID Contacto Sucursal", ]), ], } _last_request_by_token: dict = {} def normalize_name(value): s = str(value or "").strip().lower() s = unicodedata.normalize("NFKD", s) s = "".join(c for c in s if not unicodedata.combining(c)) return " ".join(s.split()) def wait_for_rate_limit(token): elapsed = time.time() - _last_request_by_token.get(token, 0) if elapsed < 0.110: time.sleep(0.110 - elapsed) _last_request_by_token[token] = time.time() def ghl_request(method, endpoint, token, *, params=None, json_body=None, version=API_VERSION, retries=3): wait_for_rate_limit(token) url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}" headers = { "Accept": "application/json", "Authorization": f"Bearer {token}", "Version": version, "Content-Type": "application/json", } last_exc = None for attempt in range(retries): try: resp = requests.request(method, url, headers=headers, params=params, json=json_body, timeout=30) except requests.RequestException as exc: last_exc = exc time.sleep(0.5 * (attempt + 1)) continue if resp.status_code == 429: time.sleep(1.0 * (attempt + 1)) continue if resp.status_code >= 500: time.sleep(1.0 * (attempt + 1)) continue resp.raise_for_status() if resp.status_code == 204: return {} return resp.json() if last_exc: raise last_exc def list_fields_and_folders(account, model): data = ghl_request("GET", f"/locations/{account['location_id']}/customFields", account["token"], params={"model": model}) items = data.get("customFields") or [] fields = [f for f in items if f.get("documentType") != "folder"] parent_ids = {f.get("parentId") for f in fields if f.get("parentId")} folders_by_id = {} for pid in parent_ids: try: d = ghl_request("GET", f"/locations/{account['location_id']}/customFields/{pid}", account["token"]) cf = d.get("customField") or d if cf.get("documentType") == "folder": folders_by_id[pid] = cf except Exception as exc: print(f" ⚠ No pude leer folder {pid}: {exc}") return fields, folders_by_id def plan_account_model(account, model, folder_specs): """folder_specs: lista [(folder_name, [field_name,...]), ...].""" actions = [] fields, folders_by_id = list_fields_and_folders(account, model) field_by_norm = {} for f in fields: field_by_norm.setdefault(normalize_name(f.get("name")), f) folder_id_by_norm = {normalize_name(fo.get("name")): pid for pid, fo in folders_by_id.items()} # Posición máxima de los campos ESTÁNDAR por folder. Los estándar (First # Name, Phone, Email, Pipeline, Stage…) no los expone el endpoint legacy de # customFields, pero SÍ el schema V2, y comparten folder y position con los # custom. Colocamos los custom DESPUÉS del último estándar del folder para # que no queden intercalados (p.ej. Marca del Vehículo debajo de Phone). std_max_by_pid = {} try: v2 = sync_engine.ghl_client.get_object_schema_fields( account["token"], account["location_id"], model) for f in v2: if f.get("dataType") == "STANDARD_FIELD" or f.get("standard"): pid = f.get("parentId") pos = f.get("position") or 0 if pid and pos > std_max_by_pid.get(pid, -1): std_max_by_pid[pid] = pos except Exception as exc: print(f" ⚠ No pude leer schema V2 (posiciones estándar): {exc}") for folder_name, field_names in folder_specs: target_pid = folder_id_by_norm.get(normalize_name(folder_name)) if not target_pid: actions.append({ "status": "folder_missing", "object": model, "folder": folder_name, "details": f"Folder {folder_name!r} no existe en la cuenta.", }) continue base = std_max_by_pid.get(target_pid, 0) for idx, fname in enumerate(field_names): target_pos = base + (idx + 1) * POSITION_STEP f = field_by_norm.get(normalize_name(fname)) if not f: actions.append({ "status": "field_missing", "object": model, "folder": folder_name, "field_name": fname, "details": f"Campo {fname!r} no existe en {model}.", }) continue cur_pid = f.get("parentId") cur_pos = f.get("position") cur_folder = (folders_by_id.get(cur_pid) or {}).get("name") if cur_pid == target_pid and cur_pos == target_pos: actions.append({ "status": "already_ok", "object": model, "field_name": f.get("name"), "folder": folder_name, }) continue actions.append({ "status": "to_set", "object": model, "field_id": f.get("id"), "field_name": f.get("name"), "from": {"folder": cur_folder, "parentId": cur_pid, "position": cur_pos}, "to": {"folder": folder_name, "parentId": target_pid, "position": target_pos}, }) return actions def apply_actions(account, actions, *, dry_run, run_id): stats = {"set": 0, "skipped": 0, "errors": []} snap = { "account": account["nombre"], "location_id": account["location_id"], "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "dry_run": dry_run, "actions": actions, } 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"layout_{safe}_{ts}.json") with open(snap_path, "w", encoding="utf-8") as fh: json.dump(snap, fh, ensure_ascii=False, indent=2, default=str) for a in actions: if a["status"] != "to_set": stats["skipped"] += 1 continue if dry_run: continue change_id = None if run_id: change_id = script_audit.record_change( run_id, account["location_id"], "__schema__", a["field_id"], a["field_id"], a["field_name"], {"parentId": a["from"]["parentId"], "position": a["from"]["position"]}, {"parentId": a["to"]["parentId"], "position": a["to"]["position"]}, ) try: ghl_request("PUT", f"/locations/{account['location_id']}/customFields/{a['field_id']}", account["token"], json_body={"parentId": a["to"]["parentId"], "position": a["to"]["position"]}) 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({"field_id": a["field_id"], "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"] in ("field_missing", "folder_missing")] print(f"\n[Plan] {account['nombre']} ({account['location_id']})") print(f" ya ok={ok} a aplicar={len(to_set)} faltantes={len(miss)}") for a in to_set: fr, to = a["from"], a["to"] moved = "" if fr["folder"] == to["folder"] else f" [MUEVE folder {fr['folder']!r} → {to['folder']!r}]" print(f" ▸ {a['object']}.{a['field_name']!r} pos {fr['position']}→{to['position']} en {to['folder']!r}{moved}") for a in miss: print(f" ✗ {a['status']} {a.get('field_name') or a.get('folder')!r}: {a['details']}") 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.") 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 main(): parser = argparse.ArgumentParser( description="Aplica un LAYOUT explícito de folder+orden a los custom fields.") parser.add_argument("--location", help="Procesar solo esta cuenta.") 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("--object", choices=["contact", "opportunity", "both"], default="both", help="Objeto a procesar. Default both.") 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") dry_run = not args.apply models = ["contact", "opportunity"] if args.object == "both" else [args.object] accounts = sync_engine.parse_accounts_csv() targets = select_targets(args, accounts) if args.run_id: script_audit.create_run( args.run_id, "apply_custom_field_layout.py", arguments=" ".join(sys.argv[1:]), locations=[t["location_id"] for t in targets], ) print(f"Modo: {'DRY-RUN' if dry_run else 'APPLY'}") print(f"Objetos: {', '.join(models)}") print(f"Cuentas en scope: {len(targets)}") for model in models: for folder_name, names in LAYOUT.get(model, []): print(f" {model} / {folder_name}: {len(names)} campos") total_set = total_skipped = 0 errors_global = [] for acc in targets: if not script_audit.wait_if_paused_or_stopped(args.run_id): print("Detención solicitada. Saliendo.") break all_actions = [] try: for model in models: if model in LAYOUT: all_actions.extend(plan_account_model(acc, model, LAYOUT[model])) except Exception as exc: print(f" [{acc['nombre']}] ERROR plan: {exc}") errors_global.append({"account": acc["nombre"], "error": str(exc)}) continue print_plan(acc, all_actions) try: stats, snap_path = apply_actions(acc, all_actions, dry_run=dry_run, run_id=args.run_id) total_set += stats["set"] total_skipped += stats["skipped"] errors_global.extend(stats["errors"]) print(f" snapshot: {snap_path}") if not dry_run: print(f" ✓ set={stats['set']} skipped={stats['skipped']} errors={len(stats['errors'])}") except Exception as exc: print(f" ✗ apply falló: {exc}") errors_global.append({"account": acc["nombre"], "error": str(exc)}) print("\n" + "=" * 60) if dry_run: print("DRY-RUN — no se modificó nada. Revisa el plan y vuelve a correr con --apply.") print(f"Cuentas procesadas : {len(targets)}") print(f"Campos colocados : {total_set}") print(f"Acciones saltadas : {total_skipped}") print(f"Errores : {len(errors_global)}") for e in errors_global[:20]: print(f" - {e}") if __name__ == "__main__": main()