Primer commit
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Elimina REPLICAS DUPLICADAS de oportunidades en la cuenta de Marca.
|
||||
|
||||
Origen del problema: cuando el workflow n8n de sincronizacion de oportunidades
|
||||
("Sincronizar Oportunidad - Nodos Nuevos", id Cfgwp0bOtDW8zuKW) decide
|
||||
UPDATE-vs-CREATE, en algunas ejecuciones NO encuentra la opp ya existente en
|
||||
Marca (latencia de indexado del search por custom field / carrera) y hace un
|
||||
CREATE, dejando DOS opps de Marca que apuntan a la MISMA opp de sucursal de
|
||||
origen (mismo valor de "ID Oportunidad Sucursal"). Es la causa tipica del
|
||||
descuadre POSITIVO (Marca > sucursales). El bucket de huerfanas no lo ve porque
|
||||
trata ese campo como salvaguarda y nunca verifica unicidad.
|
||||
|
||||
Estrategia (alineada con duplicate_resolution_rules):
|
||||
1. Detecta los clusters via audit_brand_vs_branches_totals
|
||||
(bucket "opportunities_in_brand_duplicate_link"): grupos de opps de Marca
|
||||
que comparten el mismo valor de link valido (20 chars).
|
||||
2. Por cada opp del cluster, la trae EN VIVO (verifica que exista; createdAt y
|
||||
updatedAt NO viven en el cache de SQLite).
|
||||
3. Decide cual conservar con la jerarquia completa:
|
||||
conservar = mayor monetaryValue
|
||||
(desempate: status won/open > lost/abandoned)
|
||||
(desempate: createdAt MAS ANTIGUO = la replica original)
|
||||
borrar = el resto del cluster (la(s) replica(s) sobrante(s), que en el
|
||||
incidente observado son las creadas en rafaga el 2026-05-30
|
||||
con updatedAt == createdAt, intactas).
|
||||
4. DELETE de la(s) opp(s) sobrante(s) en Marca via API de GHL.
|
||||
|
||||
Cada borrado se registra en script_audit y se guarda un snapshot completo en
|
||||
generated/migrations/ ANTES de mutar (el DELETE no es reversible en GHL; el
|
||||
snapshot permite recrear la opp si hiciera falta).
|
||||
|
||||
Uso:
|
||||
python scripts/cleanup_brand_duplicate_replica_opps.py # dry-run
|
||||
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --run-id <uuid>
|
||||
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link <id_opp_sucursal> # 1 cluster
|
||||
python scripts/cleanup_brand_duplicate_replica_opps.py --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
|
||||
warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!")
|
||||
|
||||
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)
|
||||
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS_DIR)
|
||||
|
||||
import sync_engine # noqa: E402
|
||||
import script_audit # noqa: E402
|
||||
from paths import MIGRATIONS_DIR # noqa: E402
|
||||
import audit_brand_vs_branches_totals as audit # noqa: E402
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
|
||||
# Para conservar: mayor monetaryValue, luego status, luego mas antiguo.
|
||||
# Alineado con el ranking del bucket en audit_brand_vs_branches_totals.
|
||||
STATUS_RANK = {"won": 3, "open": 2, "lost": 1, "abandoned": 0}
|
||||
|
||||
gc = sync_engine.ghl_client
|
||||
|
||||
|
||||
def safe_print(*args, **kwargs):
|
||||
text = " ".join(str(a) for a in args)
|
||||
try:
|
||||
sys.stdout.write(text + "\n")
|
||||
sys.stdout.flush()
|
||||
except UnicodeEncodeError:
|
||||
enc = sys.stdout.encoding or "utf-8"
|
||||
sys.stdout.write(text.encode(enc, errors="replace").decode(enc) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _iso_to_ts(s):
|
||||
if not s:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def fetch_opp_live(opp_id, token):
|
||||
"""GET en vivo. Devuelve la opp (dict) o None si ya no existe.
|
||||
|
||||
GHL responde **400** (no 404) para una opp borrada ("Opportunity doesn't
|
||||
exist or is deleted"), ver memoria positive_opp_descuadre_double_replica.
|
||||
Tratamos 400/404/"doesn't exist"/"deleted"/"not found" como inexistente.
|
||||
"""
|
||||
try:
|
||||
data = gc.get_opportunity(token, opp_id)
|
||||
return (data or {}).get("opportunity") or data
|
||||
except Exception as exc:
|
||||
msg = str(exc).lower()
|
||||
if any(tok_ in msg for tok_ in ("400", "404", "not found", "doesn't exist", "does not exist", "deleted")):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def keep_score(o):
|
||||
"""Score mayor = mas razon para CONSERVAR.
|
||||
|
||||
(monetaryValue, status_rank, -createdAt_ts): a igual valor y status, el
|
||||
createdAt mas antiguo (ts menor -> -ts mayor) gana = la replica original.
|
||||
"""
|
||||
return (
|
||||
float(o.get("monetaryValue") or 0),
|
||||
STATUS_RANK.get((o.get("status") or "").lower(), 0),
|
||||
-_iso_to_ts(o.get("createdAt")),
|
||||
)
|
||||
|
||||
|
||||
def _opp_brief(o):
|
||||
return {
|
||||
"id": o.get("id"),
|
||||
"name": o.get("name"),
|
||||
"status": o.get("status"),
|
||||
"monetaryValue": o.get("monetaryValue"),
|
||||
"createdAt": o.get("createdAt"),
|
||||
"updatedAt": o.get("updatedAt"),
|
||||
"contactId": o.get("contactId") or o.get("contact_id"),
|
||||
"pipelineId": o.get("pipelineId"),
|
||||
"pipelineStageId": o.get("pipelineStageId"),
|
||||
}
|
||||
|
||||
|
||||
def build_cluster_plan(link_value, opp_ids, token, *, log):
|
||||
"""Trae cada opp en vivo, decide conservar/borrar. Devuelve dict de plan."""
|
||||
live = []
|
||||
for oid in opp_ids:
|
||||
o = fetch_opp_live(oid, token)
|
||||
if o is None:
|
||||
log(f" [!] opp {oid} ya no existe en GHL (404), se ignora")
|
||||
continue
|
||||
live.append(o)
|
||||
|
||||
if len(live) < 2:
|
||||
return {
|
||||
"status": "incomplete",
|
||||
"link_value": link_value,
|
||||
"details": f"Solo {len(live)} de {len(opp_ids)} opps viven; el duplicado ya no aplica.",
|
||||
}
|
||||
|
||||
live.sort(key=keep_score, reverse=True)
|
||||
keep = live[0]
|
||||
delete = live[1:]
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"link_value": link_value,
|
||||
"name": keep.get("name") or "",
|
||||
"keep": _opp_brief(keep),
|
||||
"delete": [_opp_brief(o) for o in delete],
|
||||
# snapshot completo de las que se borran (para recrear si fuera necesario)
|
||||
"delete_full": [o for o in live if o.get("id") in {d.get("id") for d in delete}],
|
||||
}
|
||||
|
||||
|
||||
def detect_clusters_from_audit(log):
|
||||
"""Corre el audit (read-only) y agrupa el bucket de duplicados por link_value."""
|
||||
log("Detectando clusters via audit_brand_vs_branches_totals...")
|
||||
data = audit.run_audit(limit_missing=None)
|
||||
items = data["missing"]["opportunities_in_brand_duplicate_link"]["items"]
|
||||
groups = defaultdict(list)
|
||||
for it in items:
|
||||
groups[it["link_value"]].append(it["id"])
|
||||
clusters = [{"link_value": lv, "opp_ids": ids} for lv, ids in groups.items() if len(ids) >= 2]
|
||||
log(f" -> {len(clusters)} cluster(s) de duplicados detectados "
|
||||
f"({sum(len(c['opp_ids']) - 1 for c in clusters)} opps sobrantes).")
|
||||
return clusters
|
||||
|
||||
|
||||
def render_cluster_plan(plan):
|
||||
if plan["status"] != "ok":
|
||||
safe_print(f" [SKIP] link={plan['link_value']}: {plan.get('details')}")
|
||||
return
|
||||
k = plan["keep"]
|
||||
safe_print(f" cluster link={plan['link_value']} ({plan['name']})")
|
||||
safe_print(f" CONSERVAR opp={k['id']} status={k['status']} ${float(k['monetaryValue'] or 0):.0f} created={k['createdAt']}")
|
||||
for d in plan["delete"]:
|
||||
safe_print(f" BORRAR opp={d['id']} status={d['status']} ${float(d['monetaryValue'] or 0):.0f} created={d['createdAt']} (updated={d['updatedAt']})")
|
||||
|
||||
|
||||
def apply_cluster(plan, token, *, run_id, log):
|
||||
"""Borra la(s) opp(s) sobrante(s) del cluster. Devuelve stats."""
|
||||
stats = {"deleted": 0, "errors": []}
|
||||
if plan["status"] != "ok":
|
||||
return stats
|
||||
|
||||
full_by_id = {o.get("id"): o for o in plan.get("delete_full", [])}
|
||||
for d in plan["delete"]:
|
||||
delete_id = d["id"]
|
||||
change_id = None
|
||||
if run_id:
|
||||
change_id = script_audit.record_change(
|
||||
run_id, BRAND_LOCATION_ID, "opportunity",
|
||||
delete_id, "_delete", "delete_duplicate_replica_opp",
|
||||
{"opportunity": full_by_id.get(delete_id, d), "link_value": plan["link_value"]},
|
||||
{"deleted": True})
|
||||
try:
|
||||
gc.delete_opportunity(token, delete_id, BRAND_LOCATION_ID)
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "applied")
|
||||
stats["deleted"] += 1
|
||||
log(f" OK opp borrada en GHL: {delete_id}")
|
||||
except Exception as exc:
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "failed", str(exc))
|
||||
stats["errors"].append({"opp_id": delete_id, "error": str(exc)})
|
||||
log(f" ERROR al borrar {delete_id}: {exc}")
|
||||
return stats
|
||||
|
||||
|
||||
def snapshot_run(plans, run_id, dry_run):
|
||||
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(MIGRATIONS_DIR, f"cleanup_brand_duplicate_replica_opps_{ts}.json")
|
||||
payload = {
|
||||
"run_id": run_id,
|
||||
"dry_run": dry_run,
|
||||
"timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"plans": plans,
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2, default=str)
|
||||
return path
|
||||
|
||||
|
||||
def run(apply=False, run_id=None, only_link=None, log=None):
|
||||
if log is None:
|
||||
log = safe_print
|
||||
|
||||
accounts = sync_engine.parse_accounts_csv()
|
||||
brand = next(a for a in accounts if a["location_id"] == BRAND_LOCATION_ID)
|
||||
token = brand["token"]
|
||||
|
||||
clusters = detect_clusters_from_audit(log)
|
||||
if only_link:
|
||||
clusters = [c for c in clusters if c["link_value"] == only_link]
|
||||
log(f"Filtro --only-link: {len(clusters)} cluster(s).")
|
||||
|
||||
if not clusters:
|
||||
log("Nada que procesar. No hay replicas duplicadas en Marca.")
|
||||
return {"summary": {"clusters": 0, "extra_opps": 0, "deleted": 0, "errors": 0}, "plans": []}
|
||||
|
||||
if run_id and apply:
|
||||
script_audit.create_run(
|
||||
run_id, "cleanup_brand_duplicate_replica_opps.py",
|
||||
arguments=f"clusters:{len(clusters)} apply",
|
||||
locations=[BRAND_LOCATION_ID])
|
||||
|
||||
log(f"\nModo: {'APPLY (DELETE irreversible en GHL)' if apply else 'DRY-RUN'}")
|
||||
log(f"Clusters a procesar: {len(clusters)}\n")
|
||||
|
||||
plans = []
|
||||
for c in clusters:
|
||||
log(f"-- link {c['link_value']} ({len(c['opp_ids'])} opps de Marca)")
|
||||
plan = build_cluster_plan(c["link_value"], c["opp_ids"], token, log=log)
|
||||
render_cluster_plan(plan)
|
||||
plans.append(plan)
|
||||
|
||||
snap = snapshot_run(plans, run_id, dry_run=not apply)
|
||||
log(f"\nSnapshot: {snap}")
|
||||
|
||||
ok_plans = [p for p in plans if p["status"] == "ok"]
|
||||
extra = sum(len(p["delete"]) for p in ok_plans)
|
||||
summary = {
|
||||
"clusters": len(plans),
|
||||
"ok": len(ok_plans),
|
||||
"skipped": len(plans) - len(ok_plans),
|
||||
"extra_opps": extra,
|
||||
"deleted": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
if not apply:
|
||||
log(f"\nDRY-RUN finalizado. clusters_ok={summary['ok']} opps_a_borrar={extra} skipped={summary['skipped']}")
|
||||
log("Para aplicar: --apply --run-id <uuid>")
|
||||
return {"summary": summary, "plans": plans, "snapshot": snap}
|
||||
|
||||
log("\nAplicando cambios...")
|
||||
for plan in ok_plans:
|
||||
log(f"-- {plan['name']} (link {plan['link_value']})")
|
||||
stats = apply_cluster(plan, token, run_id=run_id, log=log)
|
||||
summary["deleted"] += stats["deleted"]
|
||||
summary["errors"] += len(stats["errors"])
|
||||
|
||||
if run_id:
|
||||
script_audit.update_run_status(
|
||||
run_id,
|
||||
"completed" if summary["errors"] == 0 else "failed",
|
||||
f"errors={summary['errors']}" if summary["errors"] else None)
|
||||
|
||||
log(f"\nResumen: clusters={summary['clusters']} deleted={summary['deleted']} errors={summary['errors']}")
|
||||
return {"summary": summary, "plans": plans, "snapshot": snap}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Elimina replicas duplicadas de opps en Marca (mismo ID Oportunidad Sucursal).")
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="Aplica los borrados. Sin este flag corre en DRY-RUN.")
|
||||
parser.add_argument("--run-id", help="ID para script_audit. Si --apply y no se da, se genera uno.")
|
||||
parser.add_argument("--only-link", help="Filtra a un solo cluster por su valor de ID Oportunidad Sucursal.")
|
||||
parser.add_argument("--json", action="store_true", help="Imprime el resultado como JSON.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
run_id = args.run_id
|
||||
if args.apply and not run_id:
|
||||
run_id = str(uuid.uuid4())
|
||||
safe_print(f"[info] run_id autogenerado: {run_id}")
|
||||
|
||||
result = run(apply=args.apply, run_id=run_id, only_link=args.only_link,
|
||||
log=(lambda *a: None) if args.json else safe_print)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user