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