707 lines
32 KiB
Python
707 lines
32 KiB
Python
#!/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()
|