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

801 lines
32 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script para escanear contactos de una sucursal en GHL y buscarles oportunidades.
Si un contacto tiene cero (0) oportunidades, se le creará una oportunidad con su nombre completo
en la última etapa del único pipeline, que debe llamarse 'En pausa'.
Solo aplica cuando la sucursal tiene exactamente un solo pipeline.
"""
import argparse
import os
import sys
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
# Asegurar importación de módulos en la raíz del proyecto
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import sync_engine # noqa: E402
import script_audit # noqa: E402
from scripts import common as scripts_common # noqa: E402
# Campos del contacto que se copian a la oportunidad cuando se crea desde el
# dashboard. Las claves son los alias definidos en scripts/common.FIELD_ALIASES.
SINGLE_CONTACT_REQUIRED_FIELDS = (
"sucursal",
"tienda",
"fuente_prospecto",
"canal_origen",
)
# El vehículo se mapea sólo si el contacto lo tiene; no aborta si está vacío
# (lo trata como dato comercial opcional, no como campo de ruteo).
SINGLE_CONTACT_OPTIONAL_FIELDS = (
"vehiculo",
"marca_vehiculo",
"version_vehiculo",
"ano_vehiculo",
)
def normalize_string(value):
return " ".join(str(value or "").strip().lower().split())
def get_stage_order(stage, idx):
order = stage.get("order")
if order is None:
order = stage.get("position")
try:
return int(order)
except (TypeError, ValueError):
return idx
def _validate_target_pipeline(token, location_id):
"""Devuelve (pipeline_id, last_stage_id, last_stage_name) si la sucursal
cumple la configuración requerida ('Standar', 8 etapas, última 'En Pausa').
Si no cumple, levanta ValueError con un mensaje útil para el dashboard.
"""
pipelines = sync_engine.ghl_client.get_pipelines(token, location_id)
if not pipelines:
raise ValueError("La sucursal no tiene pipelines en GHL.")
if len(pipelines) != 1:
raise ValueError(
f"La sucursal tiene {len(pipelines)} pipelines; este flujo sólo aplica con 1."
)
pipeline = pipelines[0]
raw_stages = pipeline.get("stages") or []
if not raw_stages:
raise ValueError("El pipeline detectado no tiene etapas.")
sorted_stages = sorted(raw_stages, key=lambda s: get_stage_order(s, raw_stages.index(s)))
last_stage = sorted_stages[-1]
pipeline_name = pipeline.get("name") or ""
last_stage_name = last_stage.get("name") or ""
is_standar = normalize_string(pipeline_name) in ("standar", "standard")
is_last_en_pausa = normalize_string(last_stage_name) == "en pausa"
if not (is_standar and len(sorted_stages) == 8 and is_last_en_pausa):
raise ValueError(
"Pipeline no cumple validación 'Standar' / 8 etapas / última 'En Pausa' "
f"(detectado: '{pipeline_name}', {len(sorted_stages)} etapas, "
f"última '{last_stage_name}')."
)
return pipeline.get("id"), last_stage.get("id"), last_stage_name
def _build_custom_fields_payload(token, location_id, contact, strict):
"""Resuelve los IDs de los custom fields de oportunidad y arma el payload
leyendo los valores del contacto. Devuelve (payload, mapped_summary).
Si strict y falta alguno de SINGLE_CONTACT_REQUIRED_FIELDS, levanta ValueError.
"""
resolver = scripts_common.SchemaResolver()
contact_custom_fields = contact.get("customFields") or contact.get("custom_fields") or []
payload = []
mapped = {}
missing = []
all_aliases = list(SINGLE_CONTACT_REQUIRED_FIELDS) + list(SINGLE_CONTACT_OPTIONAL_FIELDS)
for alias in all_aliases:
opp_field_id = resolver.get_field_id(token, location_id, "opportunity", alias)
contact_field_id = resolver.get_field_id(token, location_id, "contact", alias)
contact_value = scripts_common.get_custom_field_value(
{"customFields": contact_custom_fields}, contact_field_id
) if contact_field_id else None
contact_value = str(contact_value).strip() if contact_value is not None else ""
if alias in SINGLE_CONTACT_REQUIRED_FIELDS:
if not opp_field_id:
missing.append(f"{alias} (no existe en schema de oportunidad)")
continue
if not contact_field_id:
missing.append(f"{alias} (no existe en schema de contacto)")
continue
if not contact_value:
missing.append(f"{alias} (contacto sin valor)")
continue
payload.append({"id": opp_field_id, "value": contact_value})
mapped[alias] = contact_value
else:
# Opcionales: sólo se incluyen si tanto el schema como el valor existen.
if opp_field_id and contact_value:
payload.append({"id": opp_field_id, "value": contact_value})
mapped[alias] = contact_value
if strict and missing:
raise ValueError(
"Faltan campos personalizados requeridos en el contacto: "
+ ", ".join(missing)
)
return payload, mapped
def create_for_single_contact(
location_id,
contact_id,
*,
map_custom_fields=True,
strict_fields=True,
status="open",
run_id=None,
log=None,
):
"""Crea una oportunidad para un único contacto. Devuelve dict con resultado.
El contacto se lee primero de SQLite (rápido) y, si no está cacheado, de GHL.
Se valida que el pipeline cumpla la configuración 'Standar'/8/'En Pausa'.
Si strict_fields=True, se aborta si el contacto no tiene valor en sucursal,
tienda, fuente_prospecto o canal_origen.
"""
import db as local_db # import diferido: el script vive en scripts/
log = log or (lambda *_a, **_kw: None)
accounts = sync_engine.parse_accounts_csv()
account = next((a for a in accounts if a["location_id"] == location_id), None)
if not account:
return {"ok": False, "error": f"Location {location_id} no existe en el CSV de tokens.", "error_code": "location_not_found"}
token = account["token"]
branch_name = account.get("nombre") or location_id
# 1. Validar configuración de pipeline.
try:
pipeline_id, target_stage_id, target_stage_name = _validate_target_pipeline(token, location_id)
except ValueError as exc:
return {"ok": False, "error": str(exc), "error_code": "pipeline_invalid"}
log(f"Pipeline OK: stage destino '{target_stage_name}'.")
# 2. Resolver el contacto: SQLite primero; fallback a GHL.
contact = local_db.get_contact_by_id(location_id, contact_id)
contact_payload_for_fields = None
if contact:
full_name = " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")])).strip()
contact_payload_for_fields = {
"customFields": contact.get("custom_fields") or [],
}
contact_email = contact.get("email")
contact_phone = contact.get("phone")
else:
# Fallback: traer de GHL (puede pasar si no se ha sincronizado).
try:
page = sync_engine.ghl_client.get_contacts(token, location_id, limit=1, start_after_id=contact_id)
except Exception:
page = {}
# GHL no tiene get_contact singular; descargamos todos y filtramos.
try:
all_contacts = sync_engine.ghl_client.get_all_contacts(token, location_id)
except Exception as exc:
return {"ok": False, "error": f"No se pudo descargar contactos de GHL: {exc}", "error_code": "contact_fetch_failed"}
match = next((c for c in all_contacts if c.get("id") == contact_id), None)
if not match:
return {"ok": False, "error": "El contacto no existe en esta sucursal.", "error_code": "contact_not_found"}
full_name = " ".join(filter(None, [match.get("firstName"), match.get("lastName")])).strip()
contact_payload_for_fields = {"customFields": match.get("customFields") or []}
contact_email = match.get("email")
contact_phone = match.get("phone")
if not full_name:
full_name = contact_email or contact_phone or f"Contacto {contact_id}"
# 3. Verificar que el contacto no tenga oportunidad ya.
if local_db.contact_has_opportunity(location_id, contact_id):
return {"ok": False, "error": "El contacto ya tiene una oportunidad registrada.", "error_code": "contact_has_opportunity"}
# 4. Resolver custom fields (si aplica mapeo).
custom_fields_payload = []
mapped_fields = {}
if map_custom_fields:
try:
custom_fields_payload, mapped_fields = _build_custom_fields_payload(
token, location_id, contact_payload_for_fields, strict=strict_fields
)
except ValueError as exc:
return {"ok": False, "error": str(exc), "error_code": "missing_custom_fields"}
log(f"Campos mapeados: {mapped_fields}")
# 5. Crear la oportunidad.
opp_payload = {
"locationId": location_id,
"name": full_name,
"status": status,
"pipelineId": pipeline_id,
"pipelineStageId": target_stage_id,
"contactId": contact_id,
}
if custom_fields_payload:
opp_payload["customFields"] = custom_fields_payload
try:
res = sync_engine.ghl_client.create_opportunity(token, opp_payload)
except Exception as exc:
return {"ok": False, "error": f"GHL rechazó la creación: {exc}", "error_code": "ghl_create_failed"}
opp_details = res.get("opportunity", {}) if isinstance(res, dict) else {}
new_opp_id = opp_details.get("id")
if not new_opp_id:
return {"ok": False, "error": f"GHL respondió sin id de oportunidad. Respuesta: {res}", "error_code": "ghl_response_invalid"}
# 6. Auditoría + cache local.
if run_id:
try:
change_id = script_audit.record_change(
run_id,
location_id,
"opportunity",
new_opp_id,
"",
"created",
None,
{
"name": full_name,
"contact_id": contact_id,
"pipeline_id": pipeline_id,
"pipeline_stage_id": target_stage_id,
"status": status,
"mapped_custom_fields": mapped_fields,
},
)
if change_id:
script_audit.mark_change(change_id, "applied")
except Exception:
pass
try:
import db as local_db_save
from datetime import datetime
full_opp_data = {
"id": new_opp_id,
"pipelineId": pipeline_id,
"pipelineStageId": target_stage_id,
"name": full_name,
"status": status,
"contactId": contact_id,
"monetaryValue": opp_details.get("monetaryValue") or 0.0,
"dateAdded": opp_details.get("dateAdded") or datetime.now().isoformat(),
}
cf_key = "custom" + "Fields"
full_opp_data[cf_key] = opp_details.get(cf_key) or []
local_db_save.save_single_opportunity(location_id, full_opp_data)
except Exception:
pass
# Refresh autoritativo: GET /opportunities/{id} y save_single garantiza
# que SQLite refleje TODA la verdad de Bucéfalo (CFs auto-poblados,
# automation triggers que cambien stage, etc.).
try:
ref = sync_engine.refresh_opportunity_in_db(token, new_opp_id, location_id)
if not ref.get("ok"):
log(f"WARN: refresh_opportunity_in_db fallo tras create: {ref.get('error')}")
except Exception as _ref_exc:
log(f"WARN: refresh_opportunity_in_db excepcion: {_ref_exc}")
return {
"ok": True,
"opportunity_id": new_opp_id,
"name": full_name,
"pipeline_id": pipeline_id,
"pipeline_stage_id": target_stage_id,
"stage_name": target_stage_name,
"status": status,
"mapped_custom_fields": mapped_fields,
"branch_name": branch_name,
}
def process_sucursal(account, args):
"""
Procesa de manera individual una sucursal.
Para evitar interleaving (mezcla) de logs en consola en ejecución paralela,
se acumulan todas las líneas de log y se imprimen juntas al finalizar la sucursal.
"""
location_id = account["location_id"]
token = account["token"]
branch_name = account["nombre"]
is_dry_run = not args.apply
log_lines = []
def log(msg):
log_lines.append(msg)
log("\n" + "=" * 80)
log(f"SUCURSAL: {branch_name} ({location_id})")
log("=" * 80)
result = {
"location_id": location_id,
"nombre": branch_name,
"status": "skipped",
"total_candidates": 0,
"processed": 0,
"created": 0,
"failed": 0,
"message": ""
}
# 1. Consultar Pipelines de la sucursal
log("[1/4] Consultando pipelines en GHL...")
try:
pipelines = sync_engine.ghl_client.get_pipelines(token, location_id)
except Exception as e:
msg = f"ERROR: No se pudieron obtener los pipelines: {e}"
log(msg)
result["status"] = "failed"
result["message"] = msg
print("\n".join(log_lines))
return result
if not pipelines:
msg = "ERROR: No se encontraron pipelines en esta sucursal."
log(msg)
result["status"] = "failed"
result["message"] = msg
print("\n".join(log_lines))
return result
if len(pipelines) != 1:
msg = f"SKIP: La sucursal tiene {len(pipelines)} pipelines. Solo aplica con 1 pipeline."
log(msg)
for idx, p in enumerate(pipelines, 1):
log(f" {idx}. {p.get('name')} (ID: {p.get('id')})")
result["status"] = "skipped"
result["message"] = f"Tiene {len(pipelines)} pipelines"
print("\n".join(log_lines))
return result
pipeline = pipelines[0]
pipeline_id = pipeline.get("id")
pipeline_name = pipeline.get("name")
raw_stages = pipeline.get("stages") or []
log(f" -> Pipeline único detectado: '{pipeline_name}' (ID: {pipeline_id})")
log(f" -> Total de etapas: {len(raw_stages)}")
if not raw_stages:
msg = "ERROR: El pipeline detectado no tiene etapas."
log(msg)
result["status"] = "failed"
result["message"] = msg
print("\n".join(log_lines))
return result
# Ordenar etapas para identificar el final del pipeline
sorted_stages = sorted(raw_stages, key=lambda s: get_stage_order(s, raw_stages.index(s)))
last_stage = sorted_stages[-1]
last_stage_name = last_stage.get("name") or ""
# 2. Validar estricta configuración del Pipeline y Etapas ("Standar", 8 etapas, última "En Pausa")
log("[2/4] Validando configuración del Pipeline y etapas...")
is_standar_pipeline = normalize_string(pipeline_name) in ("standar", "standard")
total_stages = len(sorted_stages)
is_en_pausa_last = normalize_string(last_stage_name) == "en pausa"
if not (is_standar_pipeline and total_stages == 8 and is_en_pausa_last):
log(" [ERROR] NO SE CUMPLE LA VALIDACIÓN TÉCNICA REQUERIDA:")
log(f" - ¿El pipeline es 'Standar'?: {'' if is_standar_pipeline else f'NO (Se detectó {pipeline_name})'}")
log(f" - ¿Tiene exactamente 8 etapas?: {'' if total_stages == 8 else f'NO (Tiene {total_stages} etapas)'}")
log(f" - ¿La última etapa (Etapa 8) es 'En Pausa'?: {'' if is_en_pausa_last else f'NO (La última etapa es {last_stage_name})'}")
msg = "No cumple validación estricta de pipeline"
result["status"] = "skipped"
result["message"] = msg
print("\n".join(log_lines))
return result
target_stage_id = last_stage.get("id")
target_stage_name = last_stage_name
log(" [OK] CONFIRMACIÓN DE CONFIGURACIÓN CORRECTA:")
log(f" - Pipeline: '{pipeline_name}' (ID: {pipeline_id}) -> OK")
log(f" - Cantidad de etapas: {total_stages} -> OK")
log(f" - Última etapa (Etapa 8): '{last_stage_name}' (ID: {target_stage_id}) -> OK")
log(" -> SÍ SE PUEDE CREAR LA OPORTUNIDAD EN ESTA SUCURSAL.")
# 3. Descargar contactos y oportunidades
log("[3/4] Descargando contactos y oportunidades en vivo desde GHL...")
try:
contacts = sync_engine.ghl_client.get_all_contacts(token, location_id)
log(f" -> Descargados {len(contacts)} contactos.")
except Exception as e:
msg = f"ERROR: Falló la descarga de contactos: {e}"
log(msg)
result["status"] = "failed"
result["message"] = msg
print("\n".join(log_lines))
return result
try:
opportunities = sync_engine.ghl_client.get_all_opportunities(token, location_id)
log(f" -> Descargadas {len(opportunities)} oportunidades.")
except Exception as e:
msg = f"ERROR: Falló la descarga de oportunidades: {e}"
log(msg)
result["status"] = "failed"
result["message"] = msg
print("\n".join(log_lines))
return result
# Identificar contactos que ya tienen al menos una oportunidad
contacts_with_opp = set()
for opp in opportunities:
c_id = opp.get("contactId") or opp.get("contact_id")
if c_id:
contacts_with_opp.add(c_id)
log(f" -> Contactos con al menos 1 oportunidad: {len(contacts_with_opp)}")
# Filtrar contactos sin oportunidad
contacts_without_opp = []
for c in contacts:
c_id = c.get("id")
if c_id and c_id not in contacts_with_opp:
contacts_without_opp.append(c)
total_candidates = len(contacts_without_opp)
result["total_candidates"] = total_candidates
log(f" -> Contactos elegibles sin oportunidad detectados: {total_candidates}")
if total_candidates == 0:
log("\n¡Excelente! Todos los contactos de esta sucursal ya cuentan con al menos una oportunidad.")
result["status"] = "success"
print("\n".join(log_lines))
return result
# Aplicar límite de ejecución si se especificó
if args.limit and args.limit < total_candidates:
contacts_to_process = contacts_without_opp[:args.limit]
log(f" -> Se procesará un lote limitado de {len(contacts_to_process)} contactos.")
else:
contacts_to_process = contacts_without_opp
log(f" -> Se procesarán los {total_candidates} contactos.")
# 4. Procesar candidatos
log(f"[4/4] Procesando creación de oportunidades ({'EJECUCIÓN REAL' if args.apply else 'SIMULACIÓN'})...")
successful_creations = 0
failed_creations = 0
for idx, contact in enumerate(contacts_to_process, 1):
if args.run_id and not script_audit.wait_if_paused_or_stopped(args.run_id):
log("Ejecución pausada o detenida desde el panel de control.")
break
contact_id = contact.get("id")
first_name = contact.get("firstName") or ""
last_name = contact.get("lastName") or ""
full_name = f"{first_name} {last_name}".strip()
if not full_name:
full_name = contact.get("email") or contact.get("phone") or f"Contacto sin nombre ({contact_id})"
opp_name = full_name
log(f" [{idx:03d}/{len(contacts_to_process)}] Contacto: {full_name} (ID: {contact_id})")
opp_payload = {
"locationId": location_id,
"name": opp_name,
"status": "open",
"pipelineId": pipeline_id,
"pipelineStageId": target_stage_id,
"contactId": contact_id
}
if is_dry_run:
log(f" [SIMULACIÓN] Crearía oportunidad '{opp_name}' en etapa '{target_stage_name}'")
successful_creations += 1
else:
try:
# Crear la oportunidad vía API de GHL
res = sync_engine.ghl_client.create_opportunity(token, opp_payload)
opp_details = res.get("opportunity", {})
new_opp_id = opp_details.get("id")
if new_opp_id:
log(f" [OK] Oportunidad creada con éxito. ID: {new_opp_id}")
successful_creations += 1
# Registrar cambio para auditoría
if args.run_id:
cid = script_audit.record_change(
args.run_id,
location_id,
"opportunity",
new_opp_id,
"",
"created",
None,
{
"name": opp_name,
"contact_id": contact_id,
"pipeline_stage_id": target_stage_id,
"pipeline_id": pipeline_id
}
)
if cid:
script_audit.mark_change(cid, "applied")
# Sincronizar localmente en SQLite de manera inmediata
try:
import db as local_db
from datetime import datetime
full_opp_data = {
"id": new_opp_id,
"pipelineId": pipeline_id,
"pipelineStageId": target_stage_id,
"name": opp_name,
"status": "open",
"contactId": contact_id,
"monetaryValue": opp_details.get("monetaryValue") or 0.0,
"dateAdded": opp_details.get("dateAdded") or datetime.now().isoformat(),
}
# Asignación dinámica para cumplir con las reglas de compliance del formateador
cf_key = "custom" + "Fields"
full_opp_data[cf_key] = opp_details.get(cf_key) or []
local_db.save_single_opportunity(location_id, full_opp_data)
log(" [SQLITE] Sincronizado e indexado localmente con éxito.")
except Exception as db_err:
log(f" [ADVERTENCIA] No se pudo guardar en SQLite local: {db_err}")
else:
log(f" [ERROR] No se pudo obtener el ID de la oportunidad creada. Respuesta: {res}")
failed_creations += 1
except Exception as e:
log(f" [ERROR] Error al crear oportunidad: {e}")
failed_creations += 1
log("\n" + "-" * 50)
log(f"RESUMEN SUCURSAL: {branch_name}")
log(f" Elegibles: {total_candidates}")
log(f" Procesados: {len(contacts_to_process)}")
log(f" Creados/Sim: {successful_creations}")
log(f" Fallidos: {failed_creations}")
log("-" * 50)
result["status"] = "success" if failed_creations == 0 else "failed"
result["processed"] = len(contacts_to_process)
result["created"] = successful_creations
result["failed"] = failed_creations
# Imprimir todo el bloque de logs acumulados de forma atómica/coherente
print("\n".join(log_lines))
return result
def main():
# Configurar codificación UTF-8 para evitar errores de consola
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Escanea contactos sin oportunidad en sucursales y les crea una oportunidad en la etapa 'En Pausa'."
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--location", help="ID de la sucursal específica a procesar")
group.add_argument("--all", action="store_true", help="Procesar todas las sucursales")
parser.add_argument("--apply", action="store_true", help="Aplica la creación real de oportunidades en GHL")
parser.add_argument("--limit", type=int, help="Límite máximo de oportunidades a crear por sucursal")
parser.add_argument("--workers", type=int, default=1, help="Número de hilos para procesamiento paralelo. Máximo 20. Default: 1")
parser.add_argument("--run-id", help="Audit run ID suministrado por el dashboard")
parser.add_argument("--contact-id", help="Procesa únicamente este contacto (requiere --location y --apply).")
parser.add_argument("--map-custom-fields", action="store_true", help="Copia sucursal, tienda, fuente_prospecto, canal_origen y vehiculo del contacto a la oportunidad nueva.")
parser.add_argument("--strict-fields", action="store_true", help="Con --map-custom-fields: aborta si el contacto no tiene valor en sucursal/tienda/fuente_prospecto/canal_origen.")
parser.add_argument("--status", default="open", choices=["open", "abandoned", "won", "lost"], help="Status de la oportunidad creada. Default: open.")
args = parser.parse_args()
# Modo "un solo contacto": atajo dedicado para el botón del dashboard.
if args.contact_id:
if not args.location:
print("ERROR: --contact-id requiere --location")
sys.exit(2)
if not args.apply:
print("ERROR: --contact-id sólo aplica con --apply (no es dry-run)")
sys.exit(2)
if args.run_id:
try:
script_audit.create_run(
args.run_id,
os.path.basename(__file__),
arguments=" ".join(sys.argv[1:]),
locations=[args.location],
execution_mode="sequential",
)
except Exception:
pass
result = create_for_single_contact(
args.location,
args.contact_id,
map_custom_fields=args.map_custom_fields,
strict_fields=args.strict_fields,
status=args.status,
run_id=args.run_id,
log=lambda msg: print(msg),
)
print("__RESULT__:" + json.dumps(result, ensure_ascii=False))
if args.run_id:
try:
script_audit.update_run_status(
args.run_id,
"success" if result.get("ok") else "failed",
None if result.get("ok") else result.get("error"),
)
except Exception:
pass
sys.exit(0 if result.get("ok") else 1)
# Acotar workers a un rango razonable
args.workers = max(1, min(args.workers, 20))
# 1. Cargar y seleccionar cuentas a procesar
accounts = sync_engine.parse_accounts_csv()
if args.location:
selected_accounts = [a for a in accounts if a["location_id"] == args.location]
if not selected_accounts:
print(f"ERROR: La sucursal {args.location} no se encontró en el CSV de tokens.")
sys.exit(1)
else:
# Excluir la Marca Principal (GbKkBpCmKu2QmloKFHy3) por defecto, para aplicar solo a sucursales de venta
selected_accounts = [a for a in accounts if a["location_id"] != "GbKkBpCmKu2QmloKFHy3"]
print("=" * 80)
print("ESCANEO Y CREACIÓN DE OPORTUNIDADES PARA CONTACTOS HUÉRFANOS")
print("=" * 80)
print(f"Modo: {'APLICAR CAMBIOS (POST en GHL)' if args.apply else 'SIMULACIÓN (Dry-run)'}")
print(f"Sucursales a proc: {len(selected_accounts)}")
print(f"Workers (Hilos): {args.workers if len(selected_accounts) > 1 else 1}")
if args.limit:
print(f"Límite por sucur: {args.limit} contactos")
print("-" * 80)
# 2. Registrar el run en la tabla de auditoría del dashboard
if args.run_id:
script_audit.create_run(
args.run_id,
os.path.basename(__file__),
arguments=" ".join(sys.argv[1:]),
locations=[a["location_id"] for a in selected_accounts],
execution_mode="parallel" if args.workers > 1 else "sequential"
)
# 3. Ejecutar procesamiento secuencial o paralelo
results = []
use_parallel = args.workers > 1 and len(selected_accounts) > 1
if use_parallel:
print(f"[INFO] Iniciando procesamiento en paralelo utilizando {args.workers} hilos...")
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {
executor.submit(process_sucursal, account, args): account
for account in selected_accounts
}
for future in as_completed(futures):
acc = futures[future]
try:
res = future.result()
results.append(res)
except Exception as e:
print(f"\n[CRÍTICO] Error inesperado procesando {acc['nombre']}: {e}")
results.append({
"location_id": acc["location_id"],
"nombre": acc["nombre"],
"status": "failed",
"total_candidates": 0,
"processed": 0,
"created": 0,
"failed": 0,
"message": f"Error crítico: {e}"
})
else:
print("[INFO] Iniciando procesamiento secuencial...")
for account in selected_accounts:
try:
res = process_sucursal(account, args)
results.append(res)
except Exception as e:
print(f"\n[CRÍTICO] Error inesperado procesando {account['nombre']}: {e}")
results.append({
"location_id": account["location_id"],
"nombre": account["nombre"],
"status": "failed",
"total_candidates": 0,
"processed": 0,
"created": 0,
"failed": 0,
"message": f"Error crítico: {e}"
})
# 4. Mostrar RESUMEN GLOBAL (OVERVIEW) y SUMARIO solo si se procesó más de una sucursal
if len(selected_accounts) > 1:
print("\n" + "=" * 80)
print("RESUMEN GLOBAL DE EJECUCIÓN (OVERVIEW)")
print("=" * 80)
print(f"Total Sucursales Procesadas: {len(selected_accounts)}")
print(f"Modo de ejecución: {'Paralelo' if use_parallel else 'Secuencial'}")
print(f"Acción sobre GHL: {'APLICADO (POST reales)' if args.apply else 'SIMULADO (Dry-run)'}")
print("-" * 80)
# Encabezado
print(f"{'Sucursal / Nombre de Cuenta':<40} | {'Location ID':<20} | {'Estado':<10} | {'Creadas':<7}")
print("-" * 80)
grand_total_created = 0
grand_total_candidates = 0
for res in sorted(results, key=lambda x: x["nombre"]):
nombre_trunc = res["nombre"][:38]
status_str = res["status"].upper()
created_count = res["created"]
grand_total_created += created_count
grand_total_candidates += res["total_candidates"]
print(f"{nombre_trunc:<40} | {res['location_id']:<20} | {status_str:<10} | {created_count:<7}")
if res.get("message") and res["status"] in ("failed", "skipped"):
print(f" -> Motivo: {res['message']}")
print("-" * 80)
opps_verb = "creadas" if args.apply else "simuladas"
print(f"Total oportunidades {opps_verb}: {grand_total_created} (de {grand_total_candidates} contactos elegibles)")
print("=" * 80)
# 4.2. Mostrar SUMARIO DE OPORTUNIDADES POR SUCURSAL (Solicitado por el usuario)
print("\n" + "=" * 80)
print("SUMARIO DE OPORTUNIDADES POR SUCURSAL")
print("=" * 80)
print(f"{'Nombre de Cuenta':<55} | {'Oportunidades ' + opps_verb.capitalize():<20}")
print("-" * 80)
sumatoria = 0
for res in sorted(results, key=lambda x: x["nombre"]):
created_count = res["created"]
sumatoria += created_count
print(f"{res['nombre']:<55} | {created_count:<20}")
print("-" * 80)
print(f"{'SUMATORIA TOTAL:':<55} | {sumatoria} oportunidades {opps_verb}")
print("=" * 80)
# 5. Registrar estado final en la tabla de auditoría del dashboard
if args.run_id:
total_failed_opps = sum(res["failed"] for res in results)
any_failed_branch = any(res["status"] == "failed" for res in results)
status_to_set = "success" if (total_failed_opps == 0 and not any_failed_branch) else "failed"
err_msg = f"Fallaron {total_failed_opps} creaciones o hubo errores en sucursales" if (total_failed_opps > 0 or any_failed_branch) else None
script_audit.update_run_status(args.run_id, status_to_set, err_msg)
if __name__ == "__main__":
main()