Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
#!/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 <id> # dry-run
python scripts/apply_custom_field_layout.py --location <id> --apply --run-id <uuid>
python scripts/apply_custom_field_layout.py --all --apply --run-id <uuid>
"""
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 <id>, --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()