Files
MP-Manager/scripts/dedupe_branch_pipelines.py
T
2026-05-30 14:31:19 -06:00

761 lines
31 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Deduplica pipelines repetidos en sucursales MP y mueve las oportunidades
del pipeline mas antiguo (por fecha de actualizacion) al mas reciente.
Flujo:
1. Lista pipelines por sucursal (live API o SQLite local).
2. Agrupa pipelines duplicados por nombre normalizado dentro de cada sucursal.
3. Elige como "oficial" el pipeline con dateUpdated mas reciente (fallback dateAdded).
4. Mapea etapas obsoleto -> oficial por nombre normalizado; si todas matchean usa
ese mapeo, si no intenta por posicion.
5. Lista las oportunidades que apuntan al pipeline obsoleto via
POST /opportunities/search (paginacion completa).
6. En --apply llama PUT /opportunities/{id} con pipelineId + pipelineStageId
hacia el pipeline oficial.
La cuenta principal (marca) se ignora siempre: solo se procesan sucursales.
Default es DRY-RUN. Para aplicar usa --apply junto con --yes (TTY o dashboard).
"""
import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone
from common import BRAND_LOCATION_ID, normalize_name, select_accounts
import db
import script_audit
import sync_engine
SCRIPT_NAME = "dedupe_branch_pipelines.py"
def parse_datetime(value):
if not value:
return None
text = str(value).strip()
try:
if text.endswith("Z"):
text = text[:-1] + "+00:00"
return datetime.fromisoformat(text).astimezone(timezone.utc)
except ValueError:
return None
def display_date(value):
parsed = parse_datetime(value)
if not parsed:
return value or "Sin fecha"
return parsed.strftime("%Y-%m-%d %H:%M UTC")
def clean_label(value):
return " ".join(str(value or "").strip().split())
def stage_name(stage):
return clean_label(stage.get("name") or stage.get("title") or stage.get("id") or "Sin nombre")
def stage_order(stage, index):
value = stage.get("order")
if value is None:
value = stage.get("position")
try:
return int(value)
except (TypeError, ValueError):
return index
def normalize_stage(stage, index):
name = stage_name(stage)
return {
"id": stage.get("id"),
"name": name,
"norm_name": normalize_name(name),
"order": stage_order(stage, index),
}
def normalize_pipeline(pipeline):
name = clean_label(pipeline.get("name") or pipeline.get("id") or "Sin nombre")
raw_stages = pipeline.get("stages") or []
stages = [normalize_stage(stage, idx) for idx, stage in enumerate(raw_stages)]
stages.sort(key=lambda item: (item["order"], item["norm_name"]))
return {
"id": pipeline.get("id"),
"name": name,
"norm_name": normalize_name(name),
"stages": stages,
"date_added": pipeline.get("date_added") or pipeline.get("dateAdded") or pipeline.get("createdAt"),
"date_updated": pipeline.get("date_updated") or pipeline.get("dateUpdated") or pipeline.get("updatedAt"),
}
def pipeline_sort_date(pipeline):
return (
parse_datetime(pipeline.get("date_updated"))
or parse_datetime(pipeline.get("date_added"))
or datetime.min.replace(tzinfo=timezone.utc)
)
def load_pipelines(account, live, client):
if live:
raw = client.get_pipelines(account["token"], account["location_id"])
else:
raw = db.get_pipelines(account["location_id"])
return [normalize_pipeline(pipeline) for pipeline in raw]
def group_duplicates(pipelines):
groups = defaultdict(list)
for pipeline in pipelines:
groups[pipeline["norm_name"]].append(pipeline)
return {name: items for name, items in groups.items() if len(items) > 1}
def stage_map_by_name(obsolete, official):
name_to_official = {stage["norm_name"]: stage for stage in official["stages"]}
mapping = []
for old_stage in obsolete["stages"]:
new_stage = name_to_official.get(old_stage["norm_name"])
if not new_stage:
return None
mapping.append((old_stage, new_stage))
return mapping
def stage_map_by_position(obsolete, official):
if len(obsolete["stages"]) != len(official["stages"]):
return None
return list(zip(obsolete["stages"], official["stages"]))
def resolve_stage_mapping(obsolete, official):
mapping = stage_map_by_name(obsolete, official)
if mapping:
return mapping, "por nombre"
mapping = stage_map_by_position(obsolete, official)
if mapping:
return mapping, "por posicion"
return None, "imposible"
def fetch_opportunities_live(token, location_id, pipeline_id, client):
opportunities = []
page = 1
limit = 100
while True:
data = client.search_opportunities(token, location_id, limit=limit, page=page, pipeline_id=pipeline_id)
batch = data.get("opportunities", []) if isinstance(data, dict) else []
if not batch:
break
opportunities.extend(batch)
total = data.get("total", 0) or 0
if len(batch) < limit or (total > 0 and len(opportunities) >= total):
break
page += 1
return opportunities
def fetch_opportunities_sqlite(location_id, pipeline_id):
rows = db.get_opportunities(location_id, pipeline_id=pipeline_id)
out = []
for row in rows:
out.append({
"id": row.get("id"),
"name": row.get("name"),
"pipelineStageId": row.get("pipeline_stage_id"),
"pipelineId": row.get("pipeline_id"),
"contactId": row.get("contact_id"),
})
return out
def index_opportunities_by_contact(opps):
"""Indexa una lista de opps por contactId. Si un contacto tiene varias opps en
el mismo pipeline (caso raro pero posible si GHL lo permite), se queda la primera
encontrada — el resto se ignora porque el merge solo decide entre 2 a la vez."""
out = {}
for opp in opps or []:
cid = opp.get("contactId") or opp.get("contact_id")
if cid and cid not in out:
out[cid] = opp
return out
def stage_order_in_pipeline(pipeline, stage_id):
"""Devuelve el order/posicion de stage_id dentro de los stages normalizados
del pipeline. None si no existe. Stages ya vienen ordenados por order ascendente."""
for stage in pipeline.get("stages") or []:
if stage.get("id") == stage_id:
return stage.get("order")
return None
def merge_custom_fields(advanced_cfs, less_advanced_cfs):
"""Custom fields ganadores: los de la opp en etapa más avanzada. Los huecos
(custom fields que existen en la otra opp pero no en la avanzada) se rellenan.
Cada cf tiene shape GHL: {id, fieldValue|value, ...}. No mergeamos por nombre,
solo por id, porque GHL identifica por id."""
def cf_id(cf):
return cf.get("id") or cf.get("customFieldId") or cf.get("field_id")
merged = {}
for cf in advanced_cfs or []:
cid = cf_id(cf)
if cid:
merged[cid] = cf
for cf in less_advanced_cfs or []:
cid = cf_id(cf)
if cid and cid not in merged:
merged[cid] = cf
return list(merged.values())
def _annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account):
"""Para cada opp del obsoleto, decide si entra al flujo de merge (porque el
contacto ya tiene otra opp en el pipeline oficial) y precomputa quién gana.
Agrega al plan:
- `official_opps_by_contact`: dict para lookup rápido durante apply.
- `merge_analysis`: lista paralela a opportunities. Cada item es {
"opp_id": ...,
"contact_id": ...,
"is_duplicate": bool,
"winner": "obsolete" | "official" | None, # None si no hay duplicado
"official_opp_id": ... | None,
"obsolete_stage_order": int | None,
"official_stage_order": int | None,
}
"""
if not mapping:
plan["official_opps_by_contact"] = {}
plan["merge_analysis"] = []
return
# Cargar opps del pipeline oficial (live siempre que sea posible — los datos
# locales pueden estar stale y el merge necesita la última verdad).
if live:
official_opps = fetch_opportunities_live(account["token"], account["location_id"], official["id"], client)
else:
official_opps = fetch_opportunities_sqlite(account["location_id"], official["id"])
by_contact = index_opportunities_by_contact(official_opps)
plan["official_opps_by_contact"] = by_contact
# Mapeo de stage_id obsoleto → stage del oficial (objeto stage normalizado).
stage_map = {old["id"]: new for old, new in mapping}
analysis = []
for opp in plan["opportunities"]:
opp_id = opp.get("id")
contact_id = opp.get("contactId") or opp.get("contact_id")
existing = by_contact.get(contact_id) if contact_id else None
if not existing:
analysis.append({
"opp_id": opp_id,
"contact_id": contact_id,
"is_duplicate": False,
"winner": None,
"official_opp_id": None,
"obsolete_stage_order": None,
"official_stage_order": None,
})
continue
# El obsoleto se "mapearía" al stage X del oficial. El existente está en
# stage Y del oficial. Ganador = el de mayor order (etapa más avanzada).
current_stage_id = opp_stage_id(opp)
mapped_stage = stage_map.get(current_stage_id)
mapped_order = mapped_stage.get("order") if mapped_stage else None
existing_stage_id = (existing.get("pipelineStageId") or
existing.get("pipeline_stage_id") or
existing.get("stageId"))
existing_order = stage_order_in_pipeline(official, existing_stage_id)
# Resolución de ties y nulos:
# - Si una de las dos no tiene order, gana la otra.
# - Si ambas tienen order, gana la mayor.
# - Tie: oficial gana (ya existe, evita escrituras innecesarias).
if mapped_order is None and existing_order is None:
winner = "official"
elif mapped_order is None:
winner = "official"
elif existing_order is None:
winner = "obsolete"
elif mapped_order > existing_order:
winner = "obsolete"
else:
winner = "official"
analysis.append({
"opp_id": opp_id,
"contact_id": contact_id,
"is_duplicate": True,
"winner": winner,
"official_opp_id": existing.get("id"),
"obsolete_stage_order": mapped_order,
"official_stage_order": existing_order,
})
plan["merge_analysis"] = analysis
def build_plan(account, pipelines, live, client):
duplicate_groups = group_duplicates(pipelines)
plans = []
for group in duplicate_groups.values():
ordered = sorted(group, key=pipeline_sort_date)
official = ordered[-1]
obsoletes = ordered[:-1]
for obsolete in obsoletes:
mapping, mapping_kind = resolve_stage_mapping(obsolete, official)
if live:
opps = fetch_opportunities_live(account["token"], account["location_id"], obsolete["id"], client)
else:
opps = fetch_opportunities_sqlite(account["location_id"], obsolete["id"])
plan = {
"obsolete": obsolete,
"official": official,
"mapping": mapping,
"mapping_kind": mapping_kind,
"opportunities": opps,
}
_annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account)
plans.append(plan)
return plans
def build_plan_explicit(account, pipelines, from_id, to_id, live, client):
by_id = {pipeline["id"]: pipeline for pipeline in pipelines}
obsolete = by_id.get(from_id)
official = by_id.get(to_id)
if obsolete is None:
raise SystemExit(f"--from-pipeline {from_id} no existe en la sucursal {account['location_id']}.")
if official is None:
raise SystemExit(f"--to-pipeline {to_id} no existe en la sucursal {account['location_id']}.")
if from_id == to_id:
raise SystemExit("--from-pipeline y --to-pipeline no pueden ser el mismo ID.")
mapping, mapping_kind = resolve_stage_mapping(obsolete, official)
if live:
opps = fetch_opportunities_live(account["token"], account["location_id"], obsolete["id"], client)
else:
opps = fetch_opportunities_sqlite(account["location_id"], obsolete["id"])
plan = {
"obsolete": obsolete,
"official": official,
"mapping": mapping,
"mapping_kind": mapping_kind,
"opportunities": opps,
}
_annotate_with_merge_analysis(plan, official, obsolete, mapping, live, client, account)
return [plan]
def opp_stage_id(opp):
return (
opp.get("pipelineStageId")
or opp.get("pipeline_stage_id")
or opp.get("stageId")
)
def opp_label(opp):
name = opp.get("name") or opp.get("contactName") or opp.get("contact_name")
return name or opp.get("id") or "(sin nombre)"
def print_plan(account, plan):
obsolete = plan["obsolete"]
official = plan["official"]
print(f"\n--- Sucursal: {account['nombre']} ({account['location_id']}) ---")
print(
f"Pipeline obsoleto: '{obsolete['name']}' ({obsolete['id']}) | "
f"creado {display_date(obsolete.get('date_added'))} | "
f"actualizado {display_date(obsolete.get('date_updated'))}"
)
print(
f"Pipeline oficial : '{official['name']}' ({official['id']}) | "
f"creado {display_date(official.get('date_added'))} | "
f"actualizado {display_date(official.get('date_updated'))}"
)
print(f"Mapeo de etapas: {plan['mapping_kind']} | obsoleto {len(obsolete['stages'])} -> oficial {len(official['stages'])}")
if plan["mapping"] is None:
print(" ERROR: no se puede mapear etapas, plan ignorado.")
return
for idx, (old_stage, new_stage) in enumerate(plan["mapping"], 1):
marker = "OK" if old_stage["norm_name"] == new_stage["norm_name"] else "POS"
print(f" {idx:>2}. [{marker}] {old_stage['name']} ({old_stage['id']}) -> {new_stage['name']} ({new_stage['id']})")
print(f"Oportunidades en pipeline obsoleto: {len(plan['opportunities'])}")
DUPLICATE_OPP_HINTS = ("duplicate opportunity", "duplicate opportunity for the contact")
def is_duplicate_contact_error(exc):
response = getattr(exc, "response", None)
body = ""
if response is not None:
try:
body = response.text or ""
except Exception:
body = ""
haystack = (body + " " + str(exc)).lower()
return any(hint in haystack for hint in DUPLICATE_OPP_HINTS)
def _execute_merge(opp, opp_id, current_stage_id, new_stage_id,
existing_official, winner, account, official, obsolete,
run_id, client):
"""Resuelve un duplicado mergeando custom fields y eliminando la opp obsoleta.
`winner` ∈ {"obsolete", "official"}:
- obsolete: la opp del pipeline obsoleto está en etapa más avanzada. PUT a
la oficial con stage del obsoleto (post-mapeo) + custom fields ganadores
de la obsoleta (rellenando huecos con los de la oficial). DELETE obsoleta.
- official: la oficial ya está más avanzada. No tocamos sus campos. DELETE obsoleta.
Devuelve True si todo OK, False si hubo error inesperado.
"""
token = account["token"]
official_opp_id = existing_official.get("id")
obsolete_cfs = opp.get("customFields") or opp.get("custom_fields") or []
official_cfs = existing_official.get("customFields") or existing_official.get("custom_fields") or []
# PUT a la oficial cuando el obsoleto gana (cambia stage + custom fields).
if winner == "obsolete":
payload = {
"pipelineId": official["id"],
"pipelineStageId": new_stage_id,
"customFields": merge_custom_fields(obsolete_cfs, official_cfs),
}
try:
client.update_opportunity(token, official_opp_id, payload)
except Exception as exc:
print(f" MERR {opp_id} {opp_label(opp)} | merge PUT a oficial fallo: {exc}")
if run_id:
script_audit.record_change(
run_id, account["location_id"], "opportunity_merge_put_failed",
official_opp_id, official["id"], "merge_put",
{"opp_id": opp_id, "winner": winner}, {"error": str(exc)},
)
return False
# DELETE de la obsoleta (siempre, ganase quien ganase).
try:
client.delete_opportunity(token, opp_id, account["location_id"])
except Exception as exc:
print(f" MERR {opp_id} {opp_label(opp)} | DELETE obsoleta fallo: {exc}")
if run_id:
script_audit.record_change(
run_id, account["location_id"], "opportunity_merge_delete_failed",
opp_id, obsolete["id"], "merge_delete",
{"winner": winner, "official_opp_id": official_opp_id}, {"error": str(exc)},
)
return False
# Audit de la operación completa.
if run_id:
try:
change_id = script_audit.record_change(
run_id,
account["location_id"],
"opportunity_merged_and_deleted",
opp_id,
obsolete["id"],
"merge",
{"obsolete_opp_id": opp_id, "obsolete_pipeline_id": obsolete["id"],
"obsolete_stage_id": current_stage_id},
{"official_opp_id": official_opp_id, "official_pipeline_id": official["id"],
"winner": winner, "winning_stage_id": new_stage_id if winner == "obsolete" else existing_official.get("pipelineStageId")},
)
if change_id is not None:
script_audit.mark_change(change_id, "applied")
except Exception:
pass
return True
def apply_plan(account, plan, run_id, max_opps, client):
obsolete = plan["obsolete"]
official = plan["official"]
mapping = plan["mapping"]
if mapping is None:
return {"moved": 0, "skipped": 0, "duplicates": 0, "failed": 0,
"merged_obsolete_won": 0, "merged_official_won": 0,
"duplicate_records": []}
stage_id_map = {old_stage["id"]: new_stage["id"] for old_stage, new_stage in mapping}
opportunities = plan["opportunities"]
if max_opps is not None:
opportunities = opportunities[:max_opps]
# Lookup precomputado: contact_id → opp existente en pipeline oficial.
official_opps_by_contact = plan.get("official_opps_by_contact") or {}
analysis_by_opp_id = {a["opp_id"]: a for a in (plan.get("merge_analysis") or [])}
moved = 0
skipped = 0
duplicates = 0 # legacy: errores GHL inesperados que no se pudieron mergear
failed = 0
merged_obsolete_won = 0
merged_official_won = 0
duplicate_records = []
token = account["token"]
for opp in opportunities:
opp_id = opp.get("id")
if not opp_id:
skipped += 1
continue
current_stage_id = opp_stage_id(opp)
new_stage_id = stage_id_map.get(current_stage_id)
if not new_stage_id:
print(f" SKIP {opp_id} {opp_label(opp)} | etapa actual {current_stage_id} sin mapeo")
skipped += 1
continue
if run_id and not script_audit.wait_if_paused_or_stopped(run_id):
print(" Detenido por control de ejecucion.")
break
analysis = analysis_by_opp_id.get(opp_id)
contact_id = opp.get("contactId") or opp.get("contact_id")
existing_official = official_opps_by_contact.get(contact_id) if contact_id else None
# FLUJO MERGE: el contacto ya tiene opp en el pipeline oficial.
if existing_official and analysis and analysis.get("is_duplicate"):
winner = analysis.get("winner") or "official"
ok = _execute_merge(opp, opp_id, current_stage_id, new_stage_id,
existing_official, winner, account, official, obsolete,
run_id, client)
if not ok:
failed += 1
continue
if winner == "obsolete":
merged_obsolete_won += 1
print(f" MRGO {opp_id} {opp_label(opp)} | merge: obsoleto gana (datos copiados, obsoleta eliminada)")
else:
merged_official_won += 1
print(f" MRGF {opp_id} {opp_label(opp)} | merge: oficial gana (obsoleta eliminada)")
continue
# FLUJO NORMAL: PUT directo al pipeline oficial.
change_id = None
if run_id:
change_id = script_audit.record_change(
run_id,
account["location_id"],
"opportunity_pipeline",
opp_id,
obsolete["id"],
"pipeline+stage",
{"pipelineId": obsolete["id"], "pipelineStageId": current_stage_id},
{"pipelineId": official["id"], "pipelineStageId": new_stage_id},
)
try:
client.update_opportunity(token, opp_id, {
"pipelineId": official["id"],
"pipelineStageId": new_stage_id,
})
moved += 1
if change_id is not None:
script_audit.mark_change(change_id, "applied")
print(f" OK {opp_id} {opp_label(opp)} -> {official['id']} / {new_stage_id}")
except Exception as exc:
if is_duplicate_contact_error(exc):
# Race condition: el merge analysis no lo detectó (quizá la opp
# oficial se creó después de cargar el plan). Fallback: marcamos
# como duplicate sin mergear y reportamos para revisión manual.
duplicates += 1
duplicate_records.append({
"location_id": account["location_id"],
"location_name": account["nombre"],
"opp_id": opp_id,
"opp_name": opp_label(opp),
"obsolete_pipeline_id": obsolete["id"],
})
if change_id is not None:
script_audit.mark_change(change_id, "skipped_duplicate",
"Duplicado detectado en GHL pero no en merge_analysis (race condition).")
print(f" DUP {opp_id} {opp_label(opp)} | duplicado detectado por GHL post-plan; requiere revisión manual")
else:
failed += 1
if change_id is not None:
script_audit.mark_change(change_id, "failed", str(exc))
print(f" ERR {opp_id} {opp_label(opp)}: {exc}")
return {
"moved": moved,
"skipped": skipped,
"duplicates": duplicates,
"failed": failed,
"merged_obsolete_won": merged_obsolete_won,
"merged_official_won": merged_official_won,
"duplicate_records": duplicate_records,
}
def confirm_apply(plans_summary, args):
if args.yes:
return True
if sys.stdin.isatty():
total_opps = sum(item["opps"] for item in plans_summary)
print(f"\nVas a mover {total_opps} oportunidad(es) entre pipelines en {len(plans_summary)} caso(s).")
response = input("Escribe CONFIRMO para continuar: ").strip()
if response != "CONFIRMO":
raise SystemExit("Cancelado por el usuario.")
return True
raise SystemExit("--apply requiere --yes en entornos no TTY (dashboard). Abortando por seguridad.")
def parse_args():
parser = argparse.ArgumentParser(
description="Detecta pipelines duplicados por sucursal y migra oportunidades del mas antiguo al mas reciente.",
)
parser.add_argument("--location", help="Procesa solo esta sucursal (no aplica a marca).")
parser.add_argument("--all", action="store_true", help="Procesa todas las sucursales del CSV.")
parser.add_argument("--live", action="store_true", help="Consulta GHL en vivo en lugar de SQLite local (recomendado para precision).")
parser.add_argument("--apply", action="store_true", help="Aplica los cambios en GHL. Sin este flag solo hace dry-run.")
parser.add_argument("--yes", action="store_true", help="Salta la confirmacion interactiva (necesario en dashboard).")
parser.add_argument("--max-opps", type=int, help="Limite de oportunidades a mover por plan (debug).")
parser.add_argument("--run-id", help="ID de auditoria asignado por el dashboard.")
parser.add_argument("--from-pipeline", help="ID del pipeline origen (obsoleto). Requiere --to-pipeline y --location. Salta la deteccion por nombre.")
parser.add_argument("--to-pipeline", help="ID del pipeline destino (oficial). Requiere --from-pipeline y --location.")
return parser.parse_args()
def main():
args = parse_args()
explicit_pair = bool(args.from_pipeline or args.to_pipeline)
if explicit_pair:
if not (args.from_pipeline and args.to_pipeline):
raise SystemExit("--from-pipeline y --to-pipeline deben usarse juntos.")
if not args.location:
raise SystemExit("--from-pipeline/--to-pipeline requieren --location <id> (no compatible con --all).")
if args.all:
raise SystemExit("--from-pipeline/--to-pipeline no son compatibles con --all.")
if not args.location and not args.all:
raise SystemExit("Especifica --location <id> o --all para procesar todas las sucursales.")
if args.apply and args.location == BRAND_LOCATION_ID:
raise SystemExit("Por seguridad este script no aplica cambios sobre la cuenta principal.")
accounts = select_accounts(location_id=args.location, all_locations=args.all, include_main=False)
accounts = [account for account in accounts if account["location_id"] != BRAND_LOCATION_ID]
if not accounts:
raise SystemExit("No hay sucursales para procesar (la cuenta principal queda excluida).")
print("=== DEDUPE PIPELINES EN SUCURSALES MP ===")
print(f"Modo: {'APPLY (escribira en GHL)' if args.apply else 'DRY-RUN (no escribira nada)'}")
print(f"Fuente: {'live API GHL' if args.live else 'SQLite local'}")
if explicit_pair:
print(f"Modo explicito: pipeline {args.from_pipeline} -> {args.to_pipeline}")
print(f"Sucursales evaluadas: {len(accounts)}")
print("-" * 78)
client = sync_engine.ghl_client if args.live or args.apply else None
if args.apply and not args.live:
print("Nota: --apply siempre consulta GHL para mover oportunidades. La deteccion sigue usando SQLite salvo que pases --live.")
all_plans = []
for index, account in enumerate(accounts, 1):
print(f"[{index}/{len(accounts)}] {account['nombre']} ({account['location_id']})")
try:
pipelines = load_pipelines(account, args.live, client)
except Exception as exc:
print(f" ERROR consultando pipelines: {exc}")
continue
if explicit_pair:
plans = build_plan_explicit(account, pipelines, args.from_pipeline, args.to_pipeline, args.live, client)
else:
plans = build_plan(account, pipelines, args.live, client)
if not plans:
print(" Sin duplicados.")
continue
for plan in plans:
print_plan(account, plan)
all_plans.append({"account": account, "plan": plan})
if not all_plans:
print("\nNo se detectaron pipelines duplicados. Nada que hacer.")
return
print("\n" + "=" * 78)
summary = [{
"location": entry["account"]["location_id"],
"nombre": entry["account"]["nombre"],
"obsolete": entry["plan"]["obsolete"]["id"],
"official": entry["plan"]["official"]["id"],
"opps": len(entry["plan"]["opportunities"]),
"mapping_kind": entry["plan"]["mapping_kind"],
} for entry in all_plans]
print(f"Planes detectados: {len(summary)}")
print(f"Oportunidades totales en pipelines obsoletos: {sum(item['opps'] for item in summary)}")
unmappable = [item for item in summary if item["mapping_kind"] == "imposible"]
if unmappable:
print(f"Planes sin mapeo posible (se ignoran en --apply): {len(unmappable)}")
if not args.apply:
print("\nDRY-RUN: no se modifico nada. Usa --apply --yes para mover oportunidades.")
return
confirm_apply(summary, args)
if args.run_id:
script_audit.create_run(
args.run_id,
SCRIPT_NAME,
arguments=" ".join(sys.argv[1:]),
locations=[account["location_id"] for account in accounts],
execution_mode="sequential",
)
total_moved = total_skipped = total_duplicates = total_failed = 0
all_duplicates = []
for entry in all_plans:
account = entry["account"]
plan = entry["plan"]
if plan["mapping"] is None:
print(f"\nSKIP {account['nombre']}: mapeo imposible.")
continue
print(f"\n>>> Aplicando en {account['nombre']} ({account['location_id']}) <<<")
res = apply_plan(account, plan, args.run_id, args.max_opps, client)
moved = res["moved"]
skipped = res["skipped"]
duplicates = res["duplicates"]
failed = res["failed"]
merged_o = res["merged_obsolete_won"]
merged_f = res["merged_official_won"]
dup_records = res["duplicate_records"]
total_moved += moved
total_skipped += skipped
total_duplicates += duplicates
total_failed += failed
all_duplicates.extend(dup_records)
print(f" Movidas: {moved} | Saltadas: {skipped} | Merged (obsoleto gana): {merged_o} | Merged (oficial gana): {merged_f} | DUP no resueltos: {duplicates} | Fallidas: {failed}")
print("\n" + "=" * 78)
print(f"RESUMEN FINAL | Movidas: {total_moved} | Saltadas: {total_skipped} | Duplicadas en pipeline oficial: {total_duplicates} | Fallidas: {total_failed}")
if all_duplicates:
print("\nOportunidades NO movidas porque el contacto ya tiene una en el pipeline oficial:")
print("(Revisar manualmente: decidir si fusionar datos o eliminar la del pipeline obsoleto)")
for record in all_duplicates:
print(f" - {record['location_name']} ({record['location_id']}) | opp {record['opp_id']} {record['opp_name']} | pipeline obsoleto {record['obsolete_pipeline_id']}")
print("\nRecomendacion: corre Sincronizar Todo para refrescar SQLite tras la migracion.")
print("Nota: el rollback automatico del dashboard solo cubre custom fields. Para revertir estos movimientos hay que mover las oportunidades de regreso manualmente.")
if args.run_id:
status = "failed" if total_failed else "success"
script_audit.update_run_status(args.run_id, status)
if total_failed:
raise SystemExit(1)
if __name__ == "__main__":
main()