#!/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 (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 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()