Primer commit
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user