Primer commit
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
#!/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 <id> # dry-run
|
||||
python scripts/fill_contact_id_sucursal.py --demos --apply --run-id <uuid>
|
||||
python scripts/fill_contact_id_sucursal.py --all --apply --run-id <uuid>
|
||||
"""
|
||||
|
||||
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 <id>, --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()
|
||||
Reference in New Issue
Block a user