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
@@ -0,0 +1,706 @@
#!/usr/bin/env python3
"""Alinea opciones de picklists divergentes en sucursales al estándar de Marca.
Origen: el audit cross-object (audit_custom_fields_cross_object.py) detectó que
varios picklists tienen las mismas opciones en Marca y sucursales pero con
casing distinto (o, peor, opciones con texto totalmente distinto). GHL guarda
el VALOR del picklist como string literal en cada record, así que cambiar el
label en el schema deja las opps existentes apuntando a un string que ya no
existe en el dropdown.
Este script:
1. Para cada campo en la tabla `ALIGNMENTS`, lee el schema actual de la
sucursal y construye un plan de cambios.
2. Recorre las opps (o contacts) afectadas y arma un mapeo
`valor_actual → valor_objetivo` usando la tabla VALUE_MAPPINGS del
campo.
3. En modo `--dry-run` (default) reporta el plan sin tocar nada.
4. Con `--apply` ejecuta en este orden:
a. Snapshot completo de schema + records afectados a
`generated/migrations/picklist_align_<branch>_<field>_<ts>.json`.
b. PUT al schema con las nuevas options.
c. PUT a cada record afectado con el valor mapeado.
d. Registro en `script_audit` (planned → applied / failed).
5. Mapeos no-triviales (texto distinto, no solo casing) requieren el flag
`--accept-ambiguous-mappings`. Sin él, se reportan en el plan pero NO
se aplican.
Convención de seguridad:
* Por default solo procesa el campo `opportunity.CANAL DE ORIGEN` (caso
100% trivial — solo casing 1-a-1). Para más casos pasar `--field`.
* Recomendado correr primero en `--location` de una sucursal canary
(ej. La Viga) antes de `--all`.
* El script NO recrea el field (no usa DELETE+POST). Solo hace PUT con
`options`. Si el PUT no acepta `options`, abortar y usar el patrón
DELETE+POST de migrate_branch_fieldkeys.py.
Uso típico:
# 1) Plan en una sucursal:
python scripts/fix_opportunity_picklist_alignment.py \\
--location fKn9SaXZoKcjjLryg10v --field "CANAL DE ORIGEN"
# 2) Aplicar en esa sucursal con run-id:
python scripts/fix_opportunity_picklist_alignment.py \\
--location fKn9SaXZoKcjjLryg10v --field "CANAL DE ORIGEN" \\
--apply --run-id <uuid>
# 3) Rollout a todas las sucursales una vez validado:
python scripts/fix_opportunity_picklist_alignment.py \\
--all --field "CANAL DE ORIGEN" --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"
OBJECT_API_VERSION = "2021-04-15"
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
# ───────────────────────────────────────────────────────────────────────────
# Tabla de alineamientos
# ───────────────────────────────────────────────────────────────────────────
# Cada entrada describe un campo cuyas opciones en sucursales no coinciden con
# Marca. `target_options` es la lista exacta que debe quedar en la sucursal
# (orden incluido). `value_mappings` mapea cada string que pueda existir en
# records actuales hacia el valor objetivo. Mapeos marcados como `ambiguous`
# solo se aplican con --accept-ambiguous-mappings.
ALIGNMENTS = {
"CANAL DE ORIGEN": {
"object": "opportunity",
"match_names": ["CANAL DE ORIGEN", "Canal de Origen"],
"target_options": ["FORMULARIO", "FACEBOOK", "WHATSAPP", "LLAMADA", "INSTAGRAM", "SUCURSAL"],
"value_mappings": {
"Formulario": "FORMULARIO",
"Facebook": "FACEBOOK",
"WhatsApp": "WHATSAPP",
"Whatsapp": "WHATSAPP",
"Llamada": "LLAMADA",
"Instagram": "INSTAGRAM",
"Sucursal": "SUCURSAL",
},
"ambiguous_mappings": {},
},
"Fuente de Prospecto": {
"object": "opportunity",
"match_names": ["Fuente de Prospecto"],
# Target alineado con DOCUMENTACIÓN de Monte Providencia.md líneas
# 178-187 / 242-251: 9 opciones idénticas en contact y opportunity.
# NO se separan "PROSPECCIÓN CAMPO"/"PROSPECCIÓN DIGITAL" (decisión
# del owner del proyecto el 2026-05-26, ver chat).
"target_options": [
"CLIENTE CONOCIDO", "SUCURSAL", "PROSPECCIÓN", "REFERIDO",
"ALIANZA", "EVENTO ESPECIAL", "LEAD DIGITAL", "REDES SOCIALES",
"GALLARDETES",
],
"value_mappings": {
"Sucursal": "SUCURSAL",
"Referido": "REFERIDO",
"Alianza": "ALIANZA",
"Evento especial": "EVENTO ESPECIAL",
"Lead digital": "LEAD DIGITAL",
"Cliente conocido": "CLIENTE CONOCIDO",
"Prospección": "PROSPECCIÓN",
"Redes sociales": "REDES SOCIALES",
"Gallardetes": "GALLARDETES",
# Mapeos confirmados por el owner del proyecto: consolidamos las
# variantes "Campo"/"Digital" a PROSPECCIÓN y "Tienda" a REDES
# SOCIALES, según el catálogo documentado.
"Prospección campo": "PROSPECCIÓN",
"Prospección digital": "PROSPECCIÓN",
"Redes sociales Tienda": "REDES SOCIALES",
},
"ambiguous_mappings": {},
},
"Lead Descartado": {
"object": "opportunity",
"match_names": ["Lead Descartado"],
# Todo en MAYÚSCULAS. "Sin interés" se consolida a "SIN RESPUESTA"
# (decisión confirmada del owner el 2026-05-26 — el campo no tenía
# records con valor en ninguna cuenta al momento del rollout, así que
# la consolidación no afecta datos existentes).
"target_options": ["NO CUMPLE REQUISITOS", "CLIENTE DECLINA", "SIN RESPUESTA", "N/A"],
"value_mappings": {
"No cumple requisitos": "NO CUMPLE REQUISITOS",
"No Cumple Requisitos": "NO CUMPLE REQUISITOS",
"no cumple requisitos": "NO CUMPLE REQUISITOS",
"Cliente Declina": "CLIENTE DECLINA",
"Cliente declina": "CLIENTE DECLINA",
"Sin respuesta": "SIN RESPUESTA",
"Sin interés": "SIN RESPUESTA",
},
"ambiguous_mappings": {},
},
"Sucursal (contact)": {
"object": "contact",
"match_names": ["Sucursal"],
# 41 opciones de Marca (excluye "Tierra Colorada, Guerrero" — sucursal
# eliminada). Se confirmó que ningún contacto tiene ese valor.
"target_options": [
"Atlacomulco, Estado de México", "Atizapán, Estado de México",
"Altamira, Tamaulipas", "Cancún, Quintana Roo",
"Ciudad del Carmen, Campeche", "Ciudad Satélite, Estado de México",
"Chilpancingo, Guerrero", "Cuajimalpa, Ciudad de México",
"Cuautla, Morelos", "Ecatepec, Estado de México",
"Plaza el Salado, Ciudad de México", "Huauchinango, Puebla",
"Interlomas, Estado de México", "Ixmiquilpan, Hidalgo",
"Izcalli, Estado de México", "Jojutla, Morelos",
"La Viga, Ciudad de México", "Marina Nacional, Ciudad de México",
"Metepec, Estado de México", "Miacatlán, Morelos",
"Miahuatlán, Oaxaca", "Morelia, Michoacán", "Pinotepa, Oaxaca",
"Playa del Carmen, Quintana Roo", "Pochutla, Oaxaca",
"Puebla, Puebla", "Querétaro, Querétaro", "Reynosa, Tamaulipas",
"Tampico, Tamaulipas", "Tapachula, Chiapas", "Temixco, Morelos",
"Texcoco, Estado de México", "Toluca, Estado de México",
"Lerma, Estado de México", "Tulyehualco, Ciudad de México",
"Tuxtla, Chiapas", "Uruapan, Michoacán", "Zacatepec, Morelos",
"Zinacantepec, Estado de México", "Zitácuaro, Michoacán",
"Narvarte Oriente, Ciudad de México",
],
"value_mappings": {},
"ambiguous_mappings": {},
},
"Canal de Origen (contact rename)": {
"object": "contact",
"match_names": ["Canal de Origen", "CANAL DE ORIGEN"],
# El field ya existe en MAYÚSCULAS en las opciones (las sucursales
# heredan de Marca). Solo cambiamos el NAME del field a "CANAL DE
# ORIGEN" para alinear con su homólogo en opportunity.
"target_name": "CANAL DE ORIGEN",
"target_options": ["FORMULARIO", "FACEBOOK", "WHATSAPP", "LLAMADA", "INSTAGRAM", "SUCURSAL"],
"value_mappings": {
"Formulario": "FORMULARIO",
"Facebook": "FACEBOOK",
"WhatsApp": "WHATSAPP",
"Whatsapp": "WHATSAPP",
"Llamada": "LLAMADA",
"Instagram": "INSTAGRAM",
"Sucursal": "SUCURSAL",
},
"ambiguous_mappings": {},
},
}
_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=45)
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
# ───────────────────────────────────────────────────────────────────────────
# Acceso a schema y records
# ───────────────────────────────────────────────────────────────────────────
def get_schema_field(location_id, token, object_key, match_names):
"""Localiza el field cuyo nombre normalizado coincide con cualquiera de
`match_names`. Devuelve None si no existe."""
ghl_request("GET", "/objects/", token, params={"locationId": location_id},
version=OBJECT_API_VERSION)
data = ghl_request("GET", f"/objects/{object_key}", token,
params={"locationId": location_id}, version=OBJECT_API_VERSION)
fields = data.get("fields") or []
target_norms = {normalize_name(n) for n in match_names}
for f in fields:
if normalize_name(f.get("name")) in target_norms:
return f
return None
def iter_records(object_key, location_id, token):
if object_key == "opportunity":
page = 1
while True:
data = ghl_request("POST", "/opportunities/search", token,
json_body={"locationId": location_id, "limit": 100, "page": page})
batch = data.get("opportunities", []) or []
if not batch:
break
for o in batch:
yield o
if len(batch) < 100:
break
page += 1
else:
from urllib.parse import parse_qs, urlparse
start_after = None
start_after_id = None
while True:
params = {"locationId": location_id, "limit": 100}
if start_after:
params["startAfter"] = start_after
if start_after_id:
params["startAfterId"] = start_after_id
data = ghl_request("GET", "/contacts/", token, params=params)
batch = data.get("contacts", []) or []
if not batch:
break
for c in batch:
yield c
meta = data.get("meta") or {}
cursor = meta.get("startAfter")
cursor_id = meta.get("startAfterId")
next_url = meta.get("nextPageUrl")
if not cursor and next_url:
qs = parse_qs(urlparse(next_url).query)
cursor = (qs.get("startAfter") or [None])[0]
cursor_id = cursor_id or (qs.get("startAfterId") or [None])[0]
if not cursor and not cursor_id:
break
start_after = cursor
start_after_id = cursor_id
def cf_value(record, field_id):
for f in record.get("customFields", []) or []:
if f.get("id") != field_id:
continue
for key in ("value", "fieldValue", "fieldValueString"):
v = f.get(key)
if v is not None:
return v
return None
return None
# ───────────────────────────────────────────────────────────────────────────
# Mutaciones
# ───────────────────────────────────────────────────────────────────────────
def update_field_schema(location_id, token, field_id, *, target_options=None, target_name=None):
"""Actualiza opciones y/o el name de un custom field sin recrearlo.
El endpoint `PUT /locations/{loc}/customFields/{id}` espera `options` como
array de strings (NO objetos {key,label}). Pasarlo como dicts devuelve
400 "v.trim is not a function". Verificado contra GHL en 2026-05.
Si target_name está, se incluye en el body para renombrar el field. Si
target_options es None, no se modifica la lista de opciones.
"""
body = {}
if target_options is not None:
body["options"] = list(target_options)
if target_name is not None:
body["name"] = target_name
if not body:
return None
return ghl_request("PUT", f"/locations/{location_id}/customFields/{field_id}",
token, json_body=body)
def put_record_field(object_key, location_id, token, record_id, field_id, value):
body = {"customFields": [{"id": field_id, "value": value}]}
endpoint = "/contacts/" if object_key == "contact" else "/opportunities/"
return ghl_request("PUT", f"{endpoint}{record_id}", token, json_body=body)
# ───────────────────────────────────────────────────────────────────────────
# Snapshot
# ───────────────────────────────────────────────────────────────────────────
def write_snapshot(payload, branch_name, field_name):
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
safe_branch = "".join(c if c.isalnum() else "_" for c in branch_name)[:40]
safe_field = "".join(c if c.isalnum() else "_" for c in field_name)[:40]
path = os.path.join(MIGRATIONS_DIR, f"picklist_align_{safe_branch}_{safe_field}_{ts}.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2, default=str)
return path
# ───────────────────────────────────────────────────────────────────────────
# Núcleo: plan + apply por (branch, alignment)
# ───────────────────────────────────────────────────────────────────────────
def plan_alignment(branch, alignment_name, alignment):
"""Construye el plan sin tocar GHL en escritura. Solo lecturas."""
object_key = alignment["object"]
field = get_schema_field(branch["location_id"], branch["token"],
object_key, alignment["match_names"])
plan = {
"branch_name": branch["nombre"],
"branch_location_id": branch["location_id"],
"alignment_name": alignment_name,
"object": object_key,
"schema_field": None,
"schema_changes_needed": False,
"name_changes_needed": False,
"current_name": None,
"target_name": alignment.get("target_name"),
"current_options": [],
"target_options": alignment["target_options"],
"value_mappings_applied": {}, # valor_viejo → valor_nuevo (no ambiguos)
"value_mappings_ambiguous": {}, # valor_viejo → valor_nuevo (ambiguos)
"records_to_update": [], # [{id, current_value, new_value, ambiguous}]
"records_with_unknown_value": [], # valores que no están ni en target ni en mapping
"errors": [],
}
if not field:
plan["errors"].append("Field no encontrado en este branch.")
return plan
plan["schema_field"] = {
"id": field["id"],
"name": field.get("name"),
"dataType": field.get("dataType"),
"fieldKey": field.get("fieldKey"),
}
current = [o.get("label") for o in (field.get("options") or [])]
plan["current_options"] = current
plan["current_name"] = field.get("name")
plan["schema_changes_needed"] = (current != alignment["target_options"])
if alignment.get("target_name") and field.get("name") != alignment["target_name"]:
plan["name_changes_needed"] = True
# Recorrer records solo si hay diferencias. Si schema ya coincide pero queremos
# validar, igual recorremos para asegurar consistencia de valores.
target_set = set(alignment["target_options"])
vmap = alignment["value_mappings"]
amap = alignment["ambiguous_mappings"]
for rec in iter_records(object_key, branch["location_id"], branch["token"]):
val = cf_value(rec, field["id"])
if val is None or val == "" or val == []:
continue
# Picklist single-options: string. Multi-options: lista. Manejamos
# ambos haciendo la migración elemento-a-elemento.
is_list = isinstance(val, list)
vals = val if is_list else [val]
new_vals = []
ambiguous_hit = False
unknown_hit = False
for v in vals:
vs = str(v)
if vs in target_set:
new_vals.append(vs)
continue
if vs in vmap:
new_vals.append(vmap[vs])
plan["value_mappings_applied"][vs] = vmap[vs]
continue
if vs in amap:
new_vals.append(amap[vs])
plan["value_mappings_ambiguous"][vs] = amap[vs]
ambiguous_hit = True
continue
# Caso desconocido: dejamos el valor original
new_vals.append(vs)
unknown_hit = True
new_value = new_vals if is_list else new_vals[0]
if unknown_hit:
plan["records_with_unknown_value"].append({
"record_id": rec.get("id"),
"current_value": val,
})
if new_value != val:
plan["records_to_update"].append({
"record_id": rec.get("id"),
"current_value": val,
"new_value": new_value,
"ambiguous": ambiguous_hit,
})
return plan
def apply_plan(branch, alignment, plan, *, accept_ambiguous, dry_run, run_id):
"""Ejecuta el plan. Devuelve dict con stats."""
stats = {
"schema_updated": False,
"records_updated": 0,
"records_skipped_ambiguous": 0,
"errors": [],
}
object_key = alignment["object"]
location_id = branch["location_id"]
token = branch["token"]
field_id = plan["schema_field"]["id"]
# === Snapshot ===
snapshot_payload = {
"branch": branch["nombre"],
"branch_location_id": location_id,
"alignment_name": plan["alignment_name"],
"object": object_key,
"field_id": field_id,
"field_name": plan["schema_field"]["name"],
"current_options": plan["current_options"],
"target_options": plan["target_options"],
"value_mappings_applied": plan["value_mappings_applied"],
"value_mappings_ambiguous": plan["value_mappings_ambiguous"],
"records_to_update": plan["records_to_update"],
"records_with_unknown_value": plan["records_with_unknown_value"],
"dry_run": dry_run,
"timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
snap_path = write_snapshot(snapshot_payload, branch["nombre"], plan["alignment_name"])
print(f" Snapshot → {snap_path}")
if dry_run:
return stats
# === PUT al schema ===
if plan["schema_changes_needed"] or plan["name_changes_needed"]:
change_id = None
if run_id:
change_id = script_audit.record_change(
run_id, location_id, "__schema__", field_id, field_id,
plan["schema_field"]["name"],
{"name": plan["current_name"], "options": plan["current_options"]},
{"name": plan.get("target_name") or plan["current_name"],
"options": plan["target_options"]},
)
try:
update_field_schema(
location_id, token, field_id,
target_options=(plan["target_options"] if plan["schema_changes_needed"] else None),
target_name=(plan["target_name"] if plan["name_changes_needed"] else None),
)
if change_id:
script_audit.mark_change(change_id, "applied")
stats["schema_updated"] = True
msg_parts = []
if plan["schema_changes_needed"]:
msg_parts.append(f"options {plan['current_options']}{plan['target_options']}")
if plan["name_changes_needed"]:
msg_parts.append(f"name {plan['current_name']!r}{plan['target_name']!r}")
print(f" ✓ Schema PUT: " + " | ".join(msg_parts))
except Exception as exc:
if change_id:
script_audit.mark_change(change_id, "failed", str(exc))
stats["errors"].append(f"PUT schema falló: {exc}")
print(f" ✗ PUT schema falló: {exc}. Snapshot preservado. Abortar antes de tocar records.")
return stats
# === PUT a cada record afectado ===
for r in plan["records_to_update"]:
if r["ambiguous"] and not accept_ambiguous:
stats["records_skipped_ambiguous"] += 1
continue
if not script_audit.wait_if_paused_or_stopped(run_id):
print(" Detención solicitada — aborto.")
break
change_id = None
if run_id:
change_id = script_audit.record_change(
run_id, location_id, object_key, r["record_id"], field_id,
plan["schema_field"]["name"], r["current_value"], r["new_value"],
)
try:
put_record_field(object_key, location_id, token, r["record_id"], field_id, r["new_value"])
if change_id:
script_audit.mark_change(change_id, "applied")
stats["records_updated"] += 1
except Exception as exc:
if change_id:
script_audit.mark_change(change_id, "failed", str(exc))
stats["errors"].append({"record_id": r["record_id"], "error": str(exc)})
return stats
# ───────────────────────────────────────────────────────────────────────────
# Reporte
# ───────────────────────────────────────────────────────────────────────────
def print_plan(plan):
print(f" [Plan] {plan['branch_name']} ({plan['branch_location_id']}) — {plan['alignment_name']}")
if plan["errors"]:
for e in plan["errors"]:
print(f"{e}")
return
f = plan["schema_field"]
print(f" field id={f['id']} name={f['name']!r} dataType={f['dataType']}")
print(f" current options: {plan['current_options']}")
print(f" target options: {plan['target_options']}")
if plan.get("target_name"):
print(f" current name: {plan['current_name']!r}")
print(f" target name: {plan['target_name']!r}")
if plan["schema_changes_needed"] or plan["name_changes_needed"]:
bits = []
if plan["schema_changes_needed"]: bits.append("options")
if plan["name_changes_needed"]: bits.append("name")
print(f" → schema necesita PUT ({', '.join(bits)})")
else:
print(f" → schema ya alineado")
if plan["value_mappings_applied"]:
print(f" mapeos triviales aplicables: {plan['value_mappings_applied']}")
if plan["value_mappings_ambiguous"]:
print(f" ⚠ mapeos AMBIGUOS (requieren --accept-ambiguous-mappings):")
for k, v in plan["value_mappings_ambiguous"].items():
print(f" {k!r}{v!r}")
print(f" records a actualizar: {len(plan['records_to_update'])} "
f"(de los cuales {sum(1 for r in plan['records_to_update'] if r['ambiguous'])} ambiguos)")
if plan["records_with_unknown_value"]:
print(f" ⚠ records con valor desconocido (sin mapping definido): "
f"{len(plan['records_with_unknown_value'])}")
for r in plan["records_with_unknown_value"][:5]:
print(f" {r['record_id']}: {r['current_value']!r}")
# ───────────────────────────────────────────────────────────────────────────
# CLI
# ───────────────────────────────────────────────────────────────────────────
def select_targets(args, accounts):
if args.location:
matches = [a for a in accounts if a["location_id"] == args.location]
if not matches:
raise SystemExit(f"Location {args.location} no encontrada en CSV.")
return matches
if args.all:
# Excluir Marca: el script opera sobre sucursales (Marca es source of truth).
return [a for a in accounts if a["location_id"] != BRAND_LOCATION_ID]
raise SystemExit("Especifica --location <id> o --all.")
def main():
parser = argparse.ArgumentParser(
description="Alinea opciones de picklists de sucursales al estándar de Marca.",
)
parser.add_argument("--location", help="Procesar solo esta sucursal.")
parser.add_argument("--all", action="store_true",
help="Procesar todas las sucursales (excluye Marca).")
parser.add_argument("--field", choices=list(ALIGNMENTS.keys()),
default="CANAL DE ORIGEN",
help="Campo a alinear (default: CANAL DE ORIGEN, el único 100%% trivial).")
parser.add_argument("--accept-ambiguous-mappings", action="store_true",
help="Aplica también los mapeos con texto semánticamente distinto.")
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.")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if args.run_id:
script_audit.init_audit_db()
dry_run = not args.apply
alignment_name = args.field
alignment = ALIGNMENTS[alignment_name]
accounts = sync_engine.parse_accounts_csv()
targets = select_targets(args, accounts)
print(f"Modo: {'DRY-RUN' if dry_run else 'APPLY'}")
print(f"Campo: {alignment_name} ({alignment['object']})")
print(f"Target options: {alignment['target_options']}")
print(f"Sucursales en scope: {len(targets)}")
if args.accept_ambiguous_mappings:
print("⚠ ambiguous mappings ACTIVADOS")
print()
summary = []
for branch in targets:
if not script_audit.wait_if_paused_or_stopped(args.run_id):
print("Detención solicitada — saliendo.")
break
try:
plan = plan_alignment(branch, alignment_name, alignment)
except Exception as exc:
print(f" [Plan] {branch['nombre']}: ERROR — {exc}")
summary.append({"branch": branch["nombre"], "error": str(exc)})
continue
print_plan(plan)
if plan["errors"] or not plan["schema_field"]:
summary.append({"branch": branch["nombre"], "skipped": True})
continue
try:
stats = apply_plan(branch, alignment, plan,
accept_ambiguous=args.accept_ambiguous_mappings,
dry_run=dry_run, run_id=args.run_id)
summary.append({
"branch": branch["nombre"],
"schema_updated": stats["schema_updated"],
"records_updated": stats["records_updated"],
"records_skipped_ambiguous": stats["records_skipped_ambiguous"],
"errors": stats["errors"],
})
print(f" ✓ records_updated={stats['records_updated']} "
f"schema_updated={stats['schema_updated']} "
f"skipped_ambiguous={stats['records_skipped_ambiguous']}")
except Exception as exc:
print(f" ✗ apply falló: {exc}")
summary.append({"branch": branch["nombre"], "apply_error": str(exc)})
# Resumen final
print("\n" + "=" * 60)
if dry_run:
print("DRY-RUN — no se modificó nada. Revisa los snapshots y vuelve a correr con --apply.")
total_updated = sum(s.get("records_updated", 0) for s in summary)
total_skipped = sum(s.get("records_skipped_ambiguous", 0) for s in summary)
print(f"Sucursales procesadas: {len(summary)}")
print(f"Records actualizados : {total_updated}")
print(f"Records ambiguos saltados: {total_skipped}")
if __name__ == "__main__":
main()