#!/usr/bin/env python3 """One-off cleanup para los hallazgos residuales en Cancún y Uruapan. Cancún `opportunity.vehiculo` vs `opportunity.opportunityvehiculo`: 4 records con valores complementarios (no contradictorios). Resolución: - Josué Alcocer y Rafael Reyes: el contact tiene la fuente canónica con marca+version+año completos. PUT opp.vehiculo = string canónico desde el contact. - ismael flores y Angel Juarez: contact vacío. Concatenar valores del orphan + clean en formato Marca Modelo Año. Después: DELETE orphan `opportunity.opportunityvehiculo`. Uruapan `contact.fuente_del_prospecto`: 1 record con orphan='Sucursal' vs clean='SUCURSAL'. Mismo valor lógico con casing distinto. Clean ya está alineado con Marca. Acción: DELETE orphan field (zombie en gabriela ramos queda invisible). Default DRY-RUN. Usar --apply para escritura real. """ 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" from paths import MIGRATIONS_DIR # noqa: E402 CANCUN_LOC = "uJEn2iuUficuml9zxAnt" URUAPAN_LOC = "FoQWuksh4wQjPbVVZ8ZQ" # Valores finales decididos por record en Cancún. CANCUN_OPP_VEHICULO_UPDATES = [ {"opp_id": "hGghAwTK9vLoI7aRNED4", "label": "Josué Alcocer", "new_value": "Honda Brv Mas equipada 2018", "source": "regenerated_from_contact"}, {"opp_id": "xiLM8vGogPWNHt9YljPC", "label": "Rafael Reyes Hernandez", "new_value": "Mercedes benz Cla 200 2019", "source": "regenerated_from_contact"}, {"opp_id": "bdFbVzaBr2RJic2rZwDj", "label": "ismael flores", "new_value": "BEAT 2018", "source": "concat_orphan_clean"}, {"opp_id": "wy4Y0QvVn48QN3iHcC3m", "label": "Angel Juarez Lopez", "new_value": "Nissan VERSSA 2025", "source": "concat_orphan_clean_normalized_tab"}, ] CANCUN_ORPHAN_FIELDKEY = "opportunity.opportunityvehiculo" CANCUN_CLEAN_FIELDKEY = "opportunity.vehiculo" URUAPAN_ORPHAN_FIELDKEY = "contact.fuente_del_prospecto" _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, version=API_VERSION): 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", } 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 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 def find_field_id(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.get("id") 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 fix_cancun(branch, apply_flag): location_id = branch["location_id"] token = branch["token"] print(f"\n=== CANCÚN ({location_id}) ===") clean_id = find_field_id(location_id, token, CANCUN_CLEAN_FIELDKEY) orphan_id = find_field_id(location_id, token, CANCUN_ORPHAN_FIELDKEY) print(f" clean_field {CANCUN_CLEAN_FIELDKEY} → id={clean_id}") print(f" orphan_field {CANCUN_ORPHAN_FIELDKEY} → id={orphan_id}") if not clean_id: print(" ✗ clean field no existe en Cancún. ABORTAR.") return # Snapshot del estado actual de cada opp + valor nuevo a aplicar snapshot_records = [] for upd in CANCUN_OPP_VEHICULO_UPDATES: ro = ghl_request("GET", f"/opportunities/{upd['opp_id']}", token, params={"locationId": location_id}) opp = ro.get("opportunity", ro) snapshot_records.append({ "opp_id": upd["opp_id"], "label": upd["label"], "contact_id": opp.get("contactId"), "current_clean": cf_value(opp, clean_id), "current_orphan": cf_value(opp, orphan_id) if orphan_id else None, "new_value": upd["new_value"], "source": upd["source"], }) snap_path = write_snapshot({ "branch": branch["nombre"], "location_id": location_id, "operation": "cancun_vehiculo_merge", "clean_field_id": clean_id, "orphan_field_id": orphan_id, "records": snapshot_records, "apply": apply_flag, "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), }, "cancun_vehiculo") print(f" Snapshot → {snap_path}") print() print(" Plan:") for rec in snapshot_records: print(f" {rec['label']:30s} | actual='{rec['current_clean']}' → nuevo='{rec['new_value']}' ({rec['source']})") if not apply_flag: print(f"\n [DRY-RUN] No se aplican cambios.") return # PUT cada opp con el nuevo valor en clean field errors = [] updated = 0 for rec in snapshot_records: try: ghl_request("PUT", f"/opportunities/{rec['opp_id']}", token, json_body={"customFields": [{"id": clean_id, "value": rec["new_value"]}]}) updated += 1 print(f" ✓ PUT opp {rec['opp_id']} ({rec['label']}) ← '{rec['new_value']}'") except Exception as exc: errors.append({"opp_id": rec["opp_id"], "error": str(exc)}) print(f" ✗ PUT falló opp {rec['opp_id']}: {exc}") if errors: print(f"\n ✗ {len(errors)} PUTs fallaron. NO se elimina el orphan field. Snapshot preservado.") return if orphan_id: try: ghl_request("DELETE", f"/locations/{location_id}/customFields/{orphan_id}", token) print(f"\n ✓ DELETE orphan field {orphan_id} ({CANCUN_ORPHAN_FIELDKEY})") except Exception as exc: print(f"\n ⚠ DELETE orphan falló: {exc}") def fix_uruapan(branch, apply_flag): location_id = branch["location_id"] token = branch["token"] print(f"\n=== URUAPAN ({location_id}) ===") orphan_id = find_field_id(location_id, token, URUAPAN_ORPHAN_FIELDKEY) print(f" orphan_field {URUAPAN_ORPHAN_FIELDKEY} → id={orphan_id}") if not orphan_id: print(f" ✓ orphan ya no existe. SKIP.") return snap_path = write_snapshot({ "branch": branch["nombre"], "location_id": location_id, "operation": "uruapan_orphan_delete", "orphan_field_id": orphan_id, "orphan_fieldkey": URUAPAN_ORPHAN_FIELDKEY, "reason": "Valor 'Sucursal' duplicado funcionalmente como 'SUCURSAL' en contact.fuente_de_prospecto. Clean ya alineado con Marca.", "apply": apply_flag, "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), }, "uruapan_fuente_del_prospecto") print(f" Snapshot → {snap_path}") print(f" Plan: DELETE orphan {orphan_id}. Zombie value en gabriela ramos queda invisible.") if not apply_flag: print(f"\n [DRY-RUN] No se aplica.") return try: ghl_request("DELETE", f"/locations/{location_id}/customFields/{orphan_id}", token) print(f" ✓ DELETE orphan field {orphan_id}") except Exception as exc: print(f" ⚠ DELETE orphan falló: {exc}") def main(): parser = argparse.ArgumentParser(description="One-off cleanup Cancún + Uruapan.") parser.add_argument("--apply", action="store_true", help="Aplica cambios reales. Sin esto corre en DRY-RUN.") parser.add_argument("--cancun-only", action="store_true", help="Solo procesa Cancún.") parser.add_argument("--uruapan-only", action="store_true", help="Solo procesa Uruapan.") args = parser.parse_args() if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") accounts = sync_engine.parse_accounts_csv() accounts_by_id = {a["location_id"]: a for a in accounts} print("=" * 70) print(f"Modo: {'APPLY (escritura real)' if args.apply else 'DRY-RUN'}") print("=" * 70) if not args.uruapan_only: cancun = accounts_by_id.get(CANCUN_LOC) if cancun: fix_cancun(cancun, args.apply) else: print(f"\n⚠ Cancún ({CANCUN_LOC}) no en CSV.") if not args.cancun_only: uruapan = accounts_by_id.get(URUAPAN_LOC) if uruapan: fix_uruapan(uruapan, args.apply) else: print(f"\n⚠ Uruapan ({URUAPAN_LOC}) no en CSV.") if __name__ == "__main__": main()