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