#!/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'?: {'SÍ' if is_standar_pipeline else f'NO (Se detectó {pipeline_name})'}") log(f" - ¿Tiene exactamente 8 etapas?: {'SÍ' if total_stages == 8 else f'NO (Tiene {total_stages} etapas)'}") log(f" - ¿La última etapa (Etapa 8) es 'En Pausa'?: {'SÍ' 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()