#!/usr/bin/env python3 """One-off cleanup de Cancún Modalidad de Empeño. Cancún tiene 2 fields divergentes con valores: - opportunity.modalidad_de_empeo (21 records) - opportunity.opportunitymodalidad_de_empeo (4 records, orphan doble prefijo) Y NO tiene el clean target opportunity.modalidad_del_empeno. Estrategia (misma que Cancún Vehículo): 1. Crear opportunity.modalidad_del_empeno (TEXT) en Cancún copiando metadata de Marca. POST con create_name 'Modalidad del Empeno' → fieldKey limpio. 2. PUT rename a 'Modalidad de Empeño ' (con trailing space, como Marca). 3. PUT cada record con su valor canónico (preferencia contact > div > orphan). 4. DELETE ambos orphans. Default DRY-RUN. --apply para escritura. """ import argparse import datetime import json import os import sys import time 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 BASE_URL = "https://services.leadconnectorhq.com" API_VERSION = "2021-07-28" BRAND_LOC = "GbKkBpCmKu2QmloKFHy3" CANCUN_LOC = "uJEn2iuUficuml9zxAnt" from paths import MIGRATIONS_DIR # noqa: E402 DIVERGENT_FK = "opportunity.modalidad_de_empeo" ORPHAN_DOBLE_FK = "opportunity.opportunitymodalidad_de_empeo" TARGET_FK = "opportunity.modalidad_del_empeno" CREATE_NAME = "Modalidad del Empeno" # genera fieldKey "modalidad_del_empeno" FINAL_NAME = "Modalidad de Empeño " # trailing space para coincidir con Marca # Decisiones por record (output del análisis previo). # (opp_id, label, value, source) RECORD_DECISIONS = [ ("o4vZBkAmrunKVjIkmMP9", "Sandy Mercedes Soberanis", "Sin Dejarlo (GPS)", "from_contact"), ("mvXQUytoUgJzJhyK75Pp", "Elmer Remirez", "Tradicional (Resguardo)", "from_contact"), ("357pUhFea1utGMGACjnT", "arena vidaña", "Tradicional (Resguardo)", "from_contact"), ("pnlbGSlBfAFnefRE2Xip", "Judith San Martin", "Sin Dejarlo (GPS)", "from_contact"), ("y6YlAqIN8ZN1vj2sHDNl", "Luis Alberto Ruiz Tello", "Sin Dejarlo (GPS)", "from_contact"), ("tVRbyohH8pmTa70wWV5B", "Karla Manero", "Sin Dejarlo (GPS)", "from_contact"), ("xqJco8gLC1D367UVF8DZ", "Arena Vidaña", "Tradicional (Resguardo)", "from_contact"), ("qN7rWnVTHSBXJjfCwRWj", "Mozart Saucedo", "Sin Dejarlo (GPS)", "from_contact"), ("AzDzok9G2cRvjH2ZZW49", "Sofía Padilla Padilla", "Sin Dejarlo (GPS)", "from_contact"), ("G36hzr1gEhrkNQLMlU73", "César Solorzano", "Sin Dejarlo (GPS)", "from_contact"), ("a4ZpaeSece9JV5zCZSrH", "Yazmin Sarai Pool Canto", "Tradicional (Resguardo)", "from_contact"), ("yW6VYDF2P3W6KMB6uDk1", "Maria Nataly Casas Rojas", "Sin Dejarlo (GPS)", "from_contact"), ("d0HLx64uqtIozGzpej7k", "Mario Lopez", "Sin dejarlo (GPS)", "from_contact"), ("m7NGBcXqCF3b2KFQR25v", "Andrés leon", "Sin dejarlo (GPS)", "from_contact"), ("ijdHcg6yzZo2XyMwIlmb", "Ana Mareli Ramos Aguilar", "Sin Dejarlo (GPS)", "from_contact"), ("wv1gm571BNMgPGDJTytm", "Yolanda Hernandez", "Tradicional (Resguardo)", "from_contact"), ("tgT7Xf2U1iFAJUvryUff", "Ulises Antonio Hernandez", "Sin Dejarlo (GPS)", "from_contact"), ("hGghAwTK9vLoI7aRNED4", "Josué Alcocer", "Sin dejarlo (GPS)", "from_contact"), ("xiLM8vGogPWNHt9YljPC", "Rafael Reyes Hernandez", "Sin Dejarlo (GPS)", "from_contact"), ("2mK2Zb9yCimHGaACRmIw", "IVAN CARDONA RICALDE", "RESGUARDO", "div_only"), ("oflaaxVxn5A7komk5ZoO", "ALEXANDER CABRERA MENDOZA", "RESGUARDO", "orphan_only"), ("YWnlFWg6OWPrxkT3qBFy", "isaias cornelio", "Sin Dejarlo (GPS)", "from_contact"), ("wy4Y0QvVn48QN3iHcC3m", "Angel Juarez Lopez", "GPS", "div_only_stripped"), ] _last_request_by_token: dict = {} def wait_for_rate_limit(token): now = time.time() elapsed = now - _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): 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": API_VERSION, "Content-Type": "application/json"} resp = requests.request(method, url, headers=headers, params=params, json=json_body, timeout=45) resp.raise_for_status() if resp.status_code == 204: return {} return resp.json() def find_field(location_id, token, fieldkey): for model in ("contact", "opportunity"): data = ghl_request("GET", f"/locations/{location_id}/customFields", token, params={"model": model}) for cf in data.get("customFields") or []: if cf.get("fieldKey") == fieldkey: return cf return None def write_snapshot(payload, name): 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 name)[:60] path = os.path.join(MIGRATIONS_DIR, f"residual_{safe}_{ts}.json") with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, ensure_ascii=False, indent=2, default=str) return path def main(): parser = argparse.ArgumentParser(description="Cancún Modalidad cleanup.") parser.add_argument("--apply", action="store_true", help="Aplica cambios reales. Sin esto corre en DRY-RUN.") args = parser.parse_args() if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") accounts = sync_engine.parse_accounts_csv() brand = next(a for a in accounts if a["location_id"] == BRAND_LOC) cancun = next(a for a in accounts if a["location_id"] == CANCUN_LOC) print("=" * 70) print(f"Modo: {'APPLY' if args.apply else 'DRY-RUN'}") print("=" * 70) # 1. Verificar estado actual en Cancún print("\n1) Estado actual en Cancún:") target_existing = find_field(CANCUN_LOC, cancun["token"], TARGET_FK) divergent = find_field(CANCUN_LOC, cancun["token"], DIVERGENT_FK) orphan = find_field(CANCUN_LOC, cancun["token"], ORPHAN_DOBLE_FK) print(f" target {TARGET_FK}: {'EXISTE id='+target_existing['id'] if target_existing else 'NO EXISTE (esperado)'}") print(f" divergent {DIVERGENT_FK}: {'EXISTE id='+divergent['id'] if divergent else 'NO EXISTE'}") print(f" orphan {ORPHAN_DOBLE_FK}: {'EXISTE id='+orphan['id'] if orphan else 'NO EXISTE'}") if target_existing: print("\n ⚠ Target ya existe en Cancún — usaremos ese y saltamos la creación.") # 2. Snapshot del estado actual + plan plan_records = [] for opp_id, label, value, source in RECORD_DECISIONS: plan_records.append({ "opp_id": opp_id, "label": label, "new_value": value, "source": source, }) snap_path = write_snapshot({ "location": "Cancún", "location_id": CANCUN_LOC, "operation": "modalidad_cleanup", "target_fk": TARGET_FK, "divergent_fk": DIVERGENT_FK, "orphan_fk": ORPHAN_DOBLE_FK, "plan": plan_records, "apply": args.apply, "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), }, "cancun_modalidad") print(f"\nSnapshot → {snap_path}") print(f"\n2) Plan: {len(plan_records)} PUTs después de crear el target.") for rec in plan_records: print(f" {rec['opp_id']:25s} | {rec['label'][:30]:30s} | ← '{rec['new_value']}' ({rec['source']})") if not args.apply: print(f"\n[DRY-RUN] No se aplican cambios.") return # 3. Crear el target field (si no existe) if target_existing: target_id = target_existing["id"] print(f"\n3) Target ya existe id={target_id}, no creo.") else: # Obtener metadata de Marca brand_field = find_field(BRAND_LOC, brand["token"], TARGET_FK) if not brand_field: print(f"\n✗ ABORT: Marca no tiene {TARGET_FK}. No puedo replicar metadata.") return print(f"\n3) Creando target field en Cancún con metadata de Marca ({brand_field.get('dataType')}):") body = { "name": CREATE_NAME, "dataType": brand_field.get("dataType"), "model": "opportunity", } if brand_field.get("placeholder"): body["placeholder"] = brand_field["placeholder"] if brand_field.get("picklistOptions"): body["options"] = brand_field["picklistOptions"] try: rc = ghl_request("POST", f"/locations/{CANCUN_LOC}/customFields", cancun["token"], json_body=body) new_field = rc.get("customField", rc) target_id = new_field.get("id") generated_fk = new_field.get("fieldKey") print(f" ✓ Created id={target_id} fieldKey={generated_fk!r}") if generated_fk != TARGET_FK: print(f" ⚠ fieldKey generado ({generated_fk}) ≠ target ({TARGET_FK}). ABORT.") return # PUT rename al final_name try: ghl_request("PUT", f"/locations/{CANCUN_LOC}/customFields/{target_id}", cancun["token"], json_body={"name": FINAL_NAME}) print(f" ✓ RENAME → '{FINAL_NAME}'") except Exception as exc: print(f" ⚠ rename falló: {exc}") except Exception as exc: print(f" ✗ Create falló: {exc}. ABORT.") return # 4. PUT cada record con su valor canónico print(f"\n4) PUT a {len(plan_records)} records:") errors = [] updated = 0 for rec in plan_records: try: ghl_request("PUT", f"/opportunities/{rec['opp_id']}", cancun["token"], json_body={"customFields": [{"id": target_id, "value": rec["new_value"]}]}) updated += 1 print(f" ✓ {rec['opp_id']} ({rec['label'][:25]}) ← '{rec['new_value']}'") except Exception as exc: errors.append({"opp_id": rec["opp_id"], "error": str(exc)}) print(f" ✗ {rec['opp_id']}: {exc}") if errors: print(f"\n✗ {len(errors)} PUTs fallaron. NO se eliminan orphans. Snapshot preservado.") return # 5. Eliminar ambos orphans print(f"\n5) DELETE orphans:") for fk, field in [(DIVERGENT_FK, divergent), (ORPHAN_DOBLE_FK, orphan)]: if not field: print(f" {fk}: ya no existe, skip.") continue try: ghl_request("DELETE", f"/locations/{CANCUN_LOC}/customFields/{field['id']}", cancun["token"]) print(f" ✓ DELETE {fk} (id={field['id']})") except Exception as exc: print(f" ⚠ DELETE {fk} falló: {exc}") print("\n✓ Cancún Modalidad cleanup completo.") if __name__ == "__main__": main()