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

1337 lines
70 KiB
Python

import os
import sys
import json
import uuid
import subprocess
import threading
import queue
import time
import shlex
import concurrent.futures
from datetime import datetime
import error_logging
import script_audit
from paths import SCRIPT_RUNS_DIR
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
METADATA_OVERRIDES_FILE = os.path.join(BASE_DIR, "script_metadata_overrides.json")
DELETED_SCRIPTS_KEY = "_deleted_scripts"
DEFAULT_MAX_WORKERS = 4
HARD_MAX_WORKERS = 20
EDITABLE_METADATA_FIELDS = {
"title",
"description",
"category",
"args_placeholder",
"supports_locations",
"mutator",
"dry_run_mode",
"supports_audit",
}
VALID_CATEGORIES = {
"Búsqueda y Análisis",
"Fix y Corrección",
"Auditorías",
"Cron Jobs",
"Sin registrar",
}
VALID_DRY_RUN_MODES = {"", "dry_run_flag", "apply_flag"}
# Registrar todos los scripts con sus categorías y descripciones
SCRIPTS_METADATA = {
# Búsqueda y Análisis
"daily_summary_mp.py": {
"name": "daily_summary_mp.py",
"title": "Resumen Diario de Marca MP",
"description": "Resumen diario de contactos y oportunidades en MP main (GbKkBpCmKu2QmloKFHy3). Se ejecuta por cron.",
"category": "Búsqueda y Análisis"
},
"mp_contact_search.py": {
"name": "mp_contact_search.py",
"title": "Búsqueda Global de Contactos",
"description": "Busca contactos por nombre/teléfono/email en todas las accounts de MP.",
"category": "Búsqueda y Análisis",
"args_placeholder": "nombre, email o telefono"
},
"find_test_contacts.py": {
"name": "find_test_contacts.py",
"title": "Búsqueda de Contactos de Test / Pruebas / E3",
"description": "Busca y clasifica contactos de test, pruebas, demo o E3 en todas las accounts de MP (SQLite).",
"category": "Búsqueda y Análisis",
"args_placeholder": "--all --location <id> --include-main --csv report.csv --limit 200",
"supports_locations": True,
"options": [
{"flag": "--all", "label": "Todas las sucursales", "description": "Busca en todas las sucursales registradas."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta de marca principal en la búsqueda global."},
{"flag": "--csv report.csv", "label": "Exportar CSV", "description": "Guarda los resultados encontrados en un archivo CSV."}
]
},
"mp_opportunity_search.py": {
"name": "mp_opportunity_search.py",
"title": "Búsqueda Global de Oportunidades",
"description": "Busca oportunidades por nombre de contacto o filtra por status (open/won/lost).",
"category": "Búsqueda y Análisis",
"args_placeholder": "nombre, status (open/won/lost)"
},
"mp_opportunities_status_summary.py": {
"name": "mp_opportunities_status_summary.py",
"title": "Resumen de Oportunidades por Sucursal",
"description": "Resume oportunidades abiertas, ganadas, perdidas y abandonadas por sucursal. Omite cuentas demo por defecto.",
"category": "Búsqueda y Análisis",
"args_placeholder": "--include-main, --include-demo, --show-empty",
"supports_locations": True,
"options": [
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta principal en el resumen."},
{"flag": "--include-demo", "label": "Incluir demos", "description": "Incluye cuentas cuyo nombre contiene 'demo'."},
{"flag": "--show-empty", "label": "Mostrar vacias", "description": "Muestra sucursales sin oportunidades."}
]
},
"search_brand_to_branch_opportunity.py": {
"name": "search_brand_to_branch_opportunity.py",
"title": "Trazabilidad de Oportunidades Marca/Sucursal",
"description": "Busca y traza la correspondencia de oportunidades entre la cuenta de Marca y las Sucursales en SQLite.",
"category": "Búsqueda y Análisis",
"args_placeholder": "nombre, email, teléfono o ID",
"options": [
{"flag": "--reverse", "label": "Trazabilidad Inversa (Sucursal -> Marca)", "description": "Audita si todas las oportunidades de sucursales están sincronizadas en la cuenta de Marca."}
]
},
"audit_branch_verifier.py": {
"name": "audit_branch_verifier.py",
"title": "Auditar Verificador de Sucursales",
"description": "Valida el verificador de sucursales contra el CSV de tokens y SQLite sin modificar datos.",
"category": "Auditorías",
"args_placeholder": "--details"
},
"audit_script_compliance.py": {
"name": "audit_script_compliance.py",
"title": "Auditar Cumplimiento de Scripts",
"description": "Revisa scripts Python para detectar usos inseguros de custom fields, schemas sin catalogo y mutadores sin dry-run/apply.",
"category": "Auditorías",
"args_placeholder": "--fail-on-issues",
"options": [
{"flag": "--fail-on-issues", "label": "Fallar con hallazgos", "description": "Retorna codigo 1 si encuentra posibles incumplimientos."}
]
},
"custom_fields_health_report.py": {
"name": "custom_fields_health_report.py",
"title": "Salud de Custom Fields",
"description": "Verifica por location que existan los campos BI requeridos en schemas de contactos y oportunidades.",
"category": "Auditorías",
"args_placeholder": "--all --include-main --show-ids",
"supports_locations": True,
"options": [
{"flag": "--all", "label": "Todas las sucursales", "description": "Evalua todas las sucursales."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta principal."},
{"flag": "--show-ids", "label": "Mostrar IDs", "description": "Muestra los IDs dinamicos resueltos por schema."}
]
},
"find_cross_branch_duplicates.py": {
"name": "find_cross_branch_duplicates.py",
"title": "Duplicados de Contactos entre Sucursales",
"description": "Detecta contactos que aparecen en 2+ sucursales (anomalia) por coincidencia de telefono y/o email, y hace doble check contra la cuenta de Marca. Read-only sobre SQLite.",
"category": "Auditorías",
"args_placeholder": "--match phone,email --top 30 --xlsx reporte.xlsx",
"options": [
{"flag": "--match phone", "label": "Solo por telefono", "description": "Restringe la deteccion a coincidencias por telefono normalizado."},
{"flag": "--match email", "label": "Solo por email", "description": "Restringe la deteccion a coincidencias por email normalizado."},
{"flag": "--no-brand-check", "label": "Sin doble check Marca", "description": "Omite la verificacion cruzada contra la cuenta de Marca principal."},
{"flag": "--top 30", "label": "Top a imprimir", "description": "Cantidad de grupos a imprimir en consola (0 = todos)."},
{"flag": "--xlsx reporte.xlsx", "label": "Exportar Excel", "description": "Vuelca los grupos detectados y miembros a un archivo .xlsx con filtros y resaltado de Marca."},
{"flag": "--json reporte.json", "label": "Exportar JSON", "description": "Vuelca el reporte completo en formato JSON."}
]
},
"audit_contact_sync_coverage.py": {
"name": "audit_contact_sync_coverage.py",
"title": "Cobertura de Sync Sucursal -> Marca",
"description": "Reporte read-only: cuantifica que campos estandar y custom pierde hoy el sync de contactos sucursal->Marca (match exacto vs alias vs sin contraparte). No escribe en GHL.",
"category": "Auditorías",
"args_placeholder": "--location <id> --max-contacts 500",
"supports_locations": True,
"options": [
{"flag": "--max-contacts 500", "label": "Sample por sucursal", "description": "Cantidad de contactos a samplear por sucursal. Default 500."}
]
},
"sync_forms_brand.py": {
"name": "sync_forms_brand.py",
"title": "Sync Formularios (Marca) -> SQLite",
"description": "Descarga formularios y submissions de Marca a SQLite. Pre-requisito del audit Sucursal vs Form. Sin filtro de fecha GHL solo devuelve ~1 mes; usa --backfill-months N o --start-at/--end-at para traer historico completo. Acumula via upsert.",
"category": "Auditorías",
"args_placeholder": "--backfill-months 12 | --start-at 2025-09-01 --end-at 2025-12-31",
"options": [
{"flag": "--backfill-months 12", "label": "Backfill historico", "description": "Pide submissions por ventanas mensuales hacia atras (N meses). 12 = ultimo ano."},
{"flag": "--start-at 2025-09-01", "label": "Fecha desde", "description": "Filtra createdAt >= esta fecha (YYYY-MM-DD)."},
{"flag": "--end-at 2025-12-31", "label": "Fecha hasta", "description": "Filtra createdAt <= esta fecha (YYYY-MM-DD)."},
{"flag": "--max 500", "label": "Tope por ventana", "description": "Limita la cantidad de submissions por formulario por ventana. Default: todos."},
{"flag": "--page-size 100", "label": "Tamano de pagina", "description": "Submissions por pagina (max 100). Default 100."},
{"flag": "--quiet", "label": "Logs reducidos", "description": "Imprime menos progreso por pagina."}
]
},
"audit_brand_sucursal_vs_form.py": {
"name": "audit_brand_sucursal_vs_form.py",
"title": "Sucursal en Marca vs Formulario Original",
"description": "Compara el campo Sucursal del formulario original vs Sucursal actual en Marca, con similitud difusa para tolerar abreviaciones de estado. Clasifica cada contacto por origen probable (FORMULARIO, SUCURSAL, DIGITAL, INTEGRATION) usando tags y source nativo de GHL. Buckets: OK/VERIFICAR/DISCREPANCIA + SIN_SUBMISSION (contactos sin form). Requiere correr antes 'Sync Formularios (Marca) -> SQLite'.",
"category": "Auditorías",
"args_placeholder": "--filter-marca queretaro --show discrepancia --origin SUCURSAL,INTEGRATION --xlsx",
"options": [
{"flag": "--filter-marca queretaro", "label": "Filtrar por sucursal en Marca", "description": "Solo muestra contactos cuya Sucursal actual en Marca contiene este texto."},
{"flag": "--origin SUCURSAL", "label": "Filtrar por origen", "description": "Filtra el detalle por origen probable (coma-separado): FORMULARIO, FORMULARIO_SIN_RASTRO, SUCURSAL, DIGITAL, INTEGRATION, DESCONOCIDO."},
{"flag": "--show all", "label": "Mostrar todos los buckets", "description": "Imprime detalle de todos los buckets, no solo DISCREPANCIA."},
{"flag": "--show verificar", "label": "Mostrar verificar+discrepancia", "description": "Imprime detalle de VERIFICAR y DISCREPANCIA."},
{"flag": "--ok-threshold 0.60", "label": "Umbral OK", "description": "Similitud minima para considerar OK. Default 0.60."},
{"flag": "--verify-threshold 0.30", "label": "Umbral Verificar", "description": "Similitud minima para VERIFICAR. Default 0.30."},
{"flag": "--xlsx", "label": "Exportar Excel", "description": "Exporta los 6 buckets a un .xlsx en exports/ con columnas de origen, tags y source."}
]
},
"ghl_branch_analysis.py": {
"name": "ghl_branch_analysis.py",
"title": "Análisis Paralelo Sucursales",
"description": "Análisis paralelo de todas las sucursales MP: contacts, oportunidades, status, discrepancias. (Skill ghl-analytics)",
"category": "Búsqueda y Análisis"
},
# Fix y Corrección
"reconcile_and_sync_opportunities.py": {
"name": "reconcile_and_sync_opportunities.py",
"title": "Sincronizar Oportunidades Sucursal -> Marca",
"description": "Corrige brechas y sincroniza oportunidades desde Sucursales a Marca. Concilia con ventana de 1 hora y aplica POST/PUT.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run",
"supports_locations": True,
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra las modificaciones que haría sin escribir en GHL."},
{"flag": "--updates-only", "label": "Solo actualizaciones (PUT)", "description": "Ignora creaciones (contactos y oportunidades) y solo actualiza registros existentes."},
{"flag": "--no-contacts", "label": "No crear contactos nuevos", "description": "Evita crear contactos centrales nuevos en la Marca Principal."},
{"flag": "--no-creations", "label": "No crear oportunidades nuevas", "description": "Evita replicar nuevas oportunidades (POST) en la Marca Principal."}
]
},
"cleanup_brand_orphan_opportunities.py": {
"name": "cleanup_brand_orphan_opportunities.py",
"title": "Limpieza de Oportunidades Huérfanas de Marca",
"description": "Investiga las oportunidades de la cuenta de Marca Principal y detecta cuáles no tienen contraparte en ninguna sucursal activa. Permite simular o borrar definitivamente las huérfanas vía API.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run",
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra las modificaciones que haría sin borrar de GHL."},
{"flag": "--delete", "label": "Borrar real (GHL)", "description": "Elimina permanentemente de GHL las oportunidades huérfanas encontradas."},
{"flag": "--min-score 35", "label": "Score mínimo de similitud", "description": "Establece el score mínimo (0-100) para considerar una oportunidad como coincidente."}
]
},
"cleanup_brand_duplicate_replica_opps.py": {
"name": "cleanup_brand_duplicate_replica_opps.py",
"title": "Limpiar Réplicas Duplicadas de Opps en Marca",
"description": "Detecta opps de Marca que comparten el mismo 'ID Oportunidad Sucursal' (réplicas duplicadas creadas cuando el sync n8n hace CREATE en vez de UPDATE; causa del descuadre positivo Marca>Sucursales). Conserva la canónica (jerarquía: valor → status → más antigua, resuelta con createdAt en vivo) y elimina las sobrantes. Snapshot + script_audit para auditoría. Dry-run por defecto; aplica con --apply.",
"category": "Fix y Corrección",
"args_placeholder": "--only-link <id> --apply",
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar borrados", "description": "Sin este flag el script corre en dry-run (solo muestra el plan conservar/borrar)."},
{"flag": "--only-link <id>", "label": "Un solo cluster", "description": "Limita a un cluster por su valor de ID Oportunidad Sucursal (para piloto)."},
{"flag": "--json", "label": "Salida JSON", "description": "Imprime el resultado como JSON."}
]
},
"reconcile_brand_deadlink_opps.py": {
"name": "reconcile_brand_deadlink_opps.py",
"title": "Reconciliar Réplicas Huérfanas (link muerto) en Marca",
"description": "Detecta opps de Marca cuyo 'ID Oportunidad Sucursal' apunta a una opp de sucursal YA BORRADA (link muerto) — causa del descuadre positivo cuando una opp de sucursal se borra y GHL no avisa. Verifica EN VIVO (GET 400) y clasifica determinísticamente: borra la réplica obsoleta si el contacto ya tiene otra opp con link válido o si la sucursal no tiene opps vivas; re-enlaza si el id de sucursal solo rotó. Snapshot + script_audit. Dry-run por defecto; aplica con --apply.",
"category": "Fix y Corrección",
"args_placeholder": "--only-opp <id> --resync-first --apply",
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar (borrar/re-enlazar)", "description": "Sin este flag corre en dry-run (solo muestra el plan DELETE/RELINK/SKIP)."},
{"flag": "--resync-first", "label": "Re-sync Marca primero", "description": "Re-sincroniza Marca antes de detectar, para descartar caché viejo."},
{"flag": "--only-opp <id>", "label": "Una sola opp", "description": "Limita a una opp de Marca por id (para piloto)."},
{"flag": "--json", "label": "Salida JSON", "description": "Imprime el resultado como JSON."}
]
},
"sync_contact_sucursal_to_opportunity.py": {
"name": "sync_contact_sucursal_to_opportunity.py",
"title": "Copiar Campo Sucursal a Opp",
"description": "Copia el campo Sucursal del contacto a oportunidades vinculadas. Usa SQLite como fuente y aplica PUT en GHL solo con --apply.",
"category": "Fix y Corrección",
"args_placeholder": "--limit 100, --workers 3, --apply",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta principal cuando se usa --all."}
]
},
"fix_branch_user_origin.py": {
"name": "fix_branch_user_origin.py",
"title": "Origen Sucursal (contactos creados por usuario)",
"description": "Identifica contactos creados por un usuario en la sucursal (createdBy.source WEB_USER o MOBILE_USER, leido del GET individual) y corrige su origen: deja el tag unico 'sucursal' (quita formulario/facebook-ads) y Canal de Origen = SUCURSAL. Si al contacto le falta Sucursal/TIENDA los completa desde el Verificador CSV. Luego propaga a TODAS sus oportunidades (Canal de Origen de la Oportunidad = Sucursal + Sucursal/TIENDA). NO toca 'Fuente de Prospecto'. Solo sucursales: excluye Marca y demos. Dry-run muestra la distribucion de createdBy.source y el plan; aplica con --apply.",
"category": "Fix y Corrección",
"args_placeholder": "--location <id> --apply",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run (solo identifica y muestra el plan)."}
]
},
"create_opportunities_for_contacts_without_any.py": {
"name": "create_opportunities_for_contacts_without_any.py",
"title": "Crear Oportunidades a Contactos sin Ellas",
"description": "Busca contactos en una sucursal con cero oportunidades y les crea una con su nombre completo en la etapa final ('En Pausa'). Aplica si la sucursal tiene un único pipeline llamado 'Standar' con exactamente 8 etapas, siendo la última 'En Pausa'.",
"category": "Fix y Corrección",
"args_placeholder": "--location <id> --apply",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios reales", "description": "Sin este flag, el script simula los cambios sin alterar GHL."},
{"flag": "--limit 100", "label": "Límite de contactos", "description": "Límite máximo de contactos sin oportunidad a procesar en esta corrida."}
]
},
"sync_contacts_branch_to_brand.py": {
"name": "sync_contacts_branch_to_brand.py",
"title": "Sincronizar Contactos Sucursal -> Marca",
"description": "Lee contactos live de sucursales y escribe solo en Marca: crea faltantes y completa datos/campos personalizados por nombre. Dry-run por defecto; aplica POST/PUT solo con --apply.",
"category": "Fix y Corrección",
"args_placeholder": "--limit 20, --fields \"Sucursal, TIENDA\"",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--overwrite", "label": "Sobrescribir Marca", "description": "Actualiza valores existentes en Marca cuando difieren."},
{"flag": "--no-create", "label": "No crear contactos", "description": "Solo actualiza contactos ya existentes en Marca."},
{"flag": "--no-update", "label": "No actualizar contactos", "description": "Solo crea contactos faltantes en Marca."},
{"flag": "--no-auto-bi", "label": "Desactivar auto-poblado BI", "description": "Por defecto rellena TIENDA, Sucursal, Canal de Origen y Fuente de Prospecto cuando estan vacios en Marca. Desactivar solo si se desea respetar valores en blanco."},
{"flag": "--yes", "label": "Saltar confirmacion", "description": "Requerido al combinar --apply con --all desde el dashboard (no TTY)."}
]
},
"fix_sucursal_discrepancies.py": {
"name": "fix_sucursal_discrepancies.py",
"title": "Corregir Discrepancias Sucursal",
"description": "Corrige discrepancias Sucursal entre contacto y oportunidad.",
"category": "Fix y Corrección"
},
"fix_brand_sucursal_from_form.py": {
"name": "fix_brand_sucursal_from_form.py",
"title": "Corregir Sucursal/TIENDA en Marca desde Formulario",
"description": "Para contactos en Marca con DISCREPANCIA (form_sucursal != Marca actual), actualiza Sucursal y TIENDA con los valores del verificador correspondientes a la sucursal que el cliente eligio en el formulario. Solo planifica el cambio si el contacto existe fisicamente en esa sucursal. NO mueve oportunidades ni elimina contactos. Dry-run por default.",
"category": "Fix y Corrección",
"args_placeholder": "--filter-form-sucursal queretaro | --only-cid <id>",
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--filter-form-sucursal queretaro", "label": "Filtrar por form_sucursal", "description": "Procesa solo casos donde el form original pidio esta sucursal."},
{"flag": "--only-cid <id>", "label": "Solo un contacto", "description": "Procesa unicamente ese contact_id. Se puede repetir el flag."},
{"flag": "--verify-threshold 0.30", "label": "Umbral DISCREPANCIA", "description": "Similitud por debajo de este valor cuenta como DISCREPANCIA. Default 0.30."},
{"flag": "--no-require-duplicate", "label": "Permitir sin duplicado", "description": "Permite re-etiquetar contactos aunque no exista duplicado en la sucursal correcta (peligroso, default OFF)."},
{"flag": "--yes", "label": "Saltar confirmacion", "description": "Requerido al combinar --apply en entornos no TTY (dashboard)."}
]
},
"fix_brand_tienda_from_sucursal.py": {
"name": "fix_brand_tienda_from_sucursal.py",
"title": "Llenar TIENDA en Marca desde Sucursal",
"description": "Para contactos en Marca sin TIENDA poblada pero con Sucursal poblada, escribe el TIENDA esperado segun el verificador. Solo procesa si Sucursal mapea inequivocamente a una sucursal con TIENDA conocida. Dry-run por default.",
"category": "Fix y Corrección",
"args_placeholder": "--apply | --only-contact <id>",
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--only-contact <id>", "label": "Solo un contacto", "description": "Procesa unicamente ese contact_id. Se puede repetir."},
{"flag": "--yes", "label": "Saltar confirmacion", "description": "Requerido al combinar --apply en entornos no TTY (dashboard)."}
]
},
"fix_orphaned_pipelines_direct.py": {
"name": "fix_orphaned_pipelines_direct.py",
"title": "Corregir Pipelines Huérfanos Directo",
"description": "Detecta y reasigna oportunidades huérfanas (sin pipeline activo).",
"category": "Fix y Corrección"
},
"fix_perdido_status.py": {
"name": "fix_perdido_status.py",
"title": "Forzar Status Perdido",
"description": "Cambia status de oportunidades a 'perdido'.",
"category": "Fix y Corrección"
},
"fix_consultoria_e3_status.py": {
"name": "fix_consultoria_e3_status.py",
"title": "Fix Status Consultoría E3",
"description": "Similar al anterior para cuenta de Consultoría E3.",
"category": "Fix y Corrección"
},
"migrate_opportunity_stages.py": {
"name": "migrate_opportunity_stages.py",
"title": "Migrar Etapas de Oportunidad",
"description": "Migra/actualiza stages de oportunidades entre pipelines.",
"category": "Fix y Corrección"
},
"move_opportunities_pipeline.py": {
"name": "move_opportunities_pipeline.py",
"title": "Mover Oportunidades de Pipeline",
"description": "Mueve oportunidades de un pipeline a otro.",
"category": "Fix y Corrección"
},
"dedupe_branch_pipelines.py": {
"name": "dedupe_branch_pipelines.py",
"title": "Deduplicar Pipelines en Sucursales",
"description": "Detecta pipelines duplicados por sucursal y mueve oportunidades del pipeline mas antiguo al mas reciente (por fecha de actualizacion). Util tras un pull request de snapshot que dejo pipelines repetidos.",
"category": "Fix y Corrección",
"args_placeholder": "--live, --apply --yes, --location <id> o --all, --max-opps N",
"supports_locations": True,
"mutator": True,
"supports_audit": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--live", "label": "Consultar GHL en vivo", "description": "Lee pipelines y oportunidades desde la API (recomendado para precision)."},
{"flag": "--apply", "label": "Aplicar migracion", "description": "Mueve oportunidades en GHL. Sin este flag solo hace dry-run."},
{"flag": "--yes", "label": "Saltar confirmacion", "description": "Requerido al lanzar desde el dashboard junto con --apply."}
]
},
"update_ano_vehiculo.py": {
"name": "update_ano_vehiculo.py",
"title": "Actualizar Año Vehículo",
"description": "Actualiza el campo 'Año del Vehículo' en contacts.",
"category": "Fix y Corrección"
},
"fix_web_user_branch_contacts.py": {
"name": "fix_web_user_branch_contacts.py",
"title": "Corregir WEB_USER a Sucursal",
"description": "Corrige contactos creados manualmente en sucursales: tags, Canal/Fuente de Prospecto y oportunidad mas antigua.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra que haria sin modificar GHL."}
]
},
"fill_tienda_from_location.py": {
"name": "fill_tienda_from_location.py",
"title": "Llenar TIENDA por Location",
"description": "Llena TIENDA en contactos y oportunidades usando GET del objeto y el verificador de sucursales.",
"category": "Fix y Corrección",
"args_placeholder": "--location <id> --limit 10, --all --apply",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--contacts-only", "label": "Solo contactos", "description": "No procesa oportunidades."},
{"flag": "--opportunities-only", "label": "Solo oportunidades", "description": "No procesa contactos."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye cuenta principal cuando se usa --all."}
]
},
"fill_sucursal_tienda_from_location.py": {
"name": "fill_sucursal_tienda_from_location.py",
"title": "Llenar Sucursal y TIENDA por Location",
"description": "Llena Sucursal y TIENDA en contactos y oportunidades usando el Verificador CSV como fuente autoritativa. Por defecto consulta el cache SQLite local para decidir qué registros corregir; solo hace PUT a la API en los que realmente necesitan cambio.",
"category": "Fix y Corrección",
"args_placeholder": "--location <id>, --all --apply",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "apply_flag",
"options": [
{"flag": "--apply", "label": "Aplicar cambios", "description": "Sin este flag el script corre en dry-run."},
{"flag": "--contacts-only", "label": "Solo contactos", "description": "No procesa oportunidades."},
{"flag": "--opportunities-only", "label": "Solo oportunidades", "description": "No procesa contactos."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye cuenta principal cuando se usa --all."},
{"flag": "--fresh", "label": "Consulta en vivo (API)", "description": "Ignora el cache local y consulta cada contacto/oportunidad directamente a Bucéfalo. Más lento, pero garantiza datos al instante. Útil si sospechas que la última sincronización quedó desactualizada."},
{"flag": "--force-stale", "label": "Ignorar antigüedad del cache", "description": "Permite correr en modo local aunque el cache tenga más de 24h. Sin este flag, el script aborta cuando el cache está viejo."},
{"flag": "--quiet", "label": "Solo correcciones", "description": "Oculta los registros que ya estaban correctos y deja en el log únicamente los cambios reales."}
]
},
# Auditorías
"find_contacts_without_sucursal.py": {
"name": "find_contacts_without_sucursal.py",
"title": "Contactos sin Campo Sucursal",
"description": "Busca contactos sin campo Sucursal en todas las cuentas MP.",
"category": "Auditorías"
},
"audit_custom_fields_schema.py": {
"name": "audit_custom_fields_schema.py",
"title": "Audit Custom Fields (Marca vs Sucursales)",
"description": "Compara campos personalizados de contact y opportunity entre Marca Principal (referencia) y cada sucursal. Detecta faltantes, sobrantes, renombrados, diff de opciones, diff de dataType, duplicados y fuzzy matches. Output JSON + XLSX para revisión humana.",
"category": "Auditorías",
"args_placeholder": "--all, --location <id>, --fuzzy-threshold 0.85",
"supports_locations": True,
"supports_audit": True,
"options": [
{"flag": "--object contact", "label": "Solo contactos", "description": "Auditar solo el schema de contact (omite opportunity)."},
{"flag": "--object opportunity", "label": "Solo oportunidades", "description": "Auditar solo el schema de opportunity (omite contact)."},
{"flag": "--fuzzy-threshold 0.85", "label": "Threshold fuzzy", "description": "Umbral SequenceMatcher para detectar matches por similitud. Default 0.85. Subir reduce falsos positivos."}
]
},
"audit_and_fix_orphaned_pipelines.py": {
"name": "audit_and_fix_orphaned_pipelines.py",
"title": "Auditar y Reparar Pipelines Huérfanos",
"description": "Audita y repara pipelines huérfanos en todas las cuentas.",
"category": "Auditorías"
},
"audit_orphaned_pipelines_readonly.py": {
"name": "audit_orphaned_pipelines_readonly.py",
"title": "Auditar Pipelines (Solo Lectura)",
"description": "Audita pipelines y busca oportunidades huerfanas en una sucursal o de manera global.",
"category": "Auditorias",
"args_placeholder": "--location <id>, --all, --include-main",
"supports_locations": True,
"supports_audit": True,
"options": [
{"flag": "--all", "label": "Todas las sucursales", "description": "Audita todas las sucursales de manera global."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta de marca principal cuando se usa --all."}
]
},
"analyze_duplicate_contacts.py": {
"name": "analyze_duplicate_contacts.py",
"title": "Analizar Duplicados de Teléfono",
"description": "Analiza y reporta contactos duplicados por teléfono.",
"category": "Auditorías"
},
"analyze_sucursal_discrepancies.py": {
"name": "analyze_sucursal_discrepancies.py",
"title": "Analizar Discrepancias de Sucursal",
"description": "Analiza discrepancias de Sucursal entre contactos y opps.",
"category": "Auditorías"
},
"audit_brand_contact_opportunity_map.py": {
"name": "audit_brand_contact_opportunity_map.py",
"title": "Mapeo Contacto <-> Oportunidad Marca",
"description": "Mapea la relación entre contactos y oportunidades en la cuenta de Marca Principal e identifica discrepancias (duplicados, huérfanos, sin oportunidades).",
"category": "Auditorías",
"args_placeholder": "--show-no-opp, --show-multi-opp, --show-orphan-opp, --all",
"options": [
{"flag": "--show-no-opp", "label": "Mostrar contactos sin opp", "description": "Lista detalladamente los contactos que no tienen ninguna oportunidad."},
{"flag": "--show-multi-opp", "label": "Mostrar contactos con multi-opp", "description": "Lista detalladamente los contactos con 2 o más oportunidades."},
{"flag": "--show-orphan-opp", "label": "Mostrar oportunidades huérfanas", "description": "Lista detalladamente las oportunidades cuyo contacto ID no existe en la base de datos."},
{"flag": "--all", "label": "Mostrar todo", "description": "Muestra todos los listados de auditoría detallados."}
]
},
"audit_brand_vs_branches_discrepancy.py": {
"name": "audit_brand_vs_branches_discrepancy.py",
"title": "Discrepancia Marca vs Sucursales",
"description": "Audita el gap entre contactos y oportunidades en Marca cruzándolo contra todas las sucursales. Identifica gaps reales de sync, casos multi-opp parcialmente replicados, contactos sin sync y leads digitales sin opp.",
"category": "Auditorías",
"args_placeholder": "--show-sync-gaps, --show-multi-opp-gaps, --show-unsynced-contacts, --show-brand-only, --all",
"options": [
{"flag": "--show-sync-gaps", "label": "Gaps reales de sync", "description": "Contactos en Marca sin opp que SÍ tienen opp en su sucursal."},
{"flag": "--show-multi-opp-gaps", "label": "Multi-opp parciales", "description": "Casos donde la sucursal tiene 2+ opps y Marca solo replicó parte (posible bug)."},
{"flag": "--show-unsynced-contacts", "label": "Contactos sin sync", "description": "Contactos en sucursal que nunca llegaron a Marca."},
{"flag": "--show-brand-only", "label": "Solo en Marca", "description": "Contactos sin opp ni contraparte en sucursal (leads digitales / imports)."},
{"flag": "--all", "label": "Mostrar todo", "description": "Muestra todos los listados detallados."}
]
},
"audit_brand_vs_branches_totals.py": {
"name": "audit_brand_vs_branches_totals.py",
"title": "Comparativa Marca vs Sucursales (totales)",
"description": "Conteo total de contactos y oportunidades de Marca vs suma de todas las sucursales no-demo. Cruza el campo TIENDA del contacto Marca contra el verificador para identificar a qué sucursal pertenece y reporta los registros ausentes en cualquiera de los dos lados. Misma fuente de datos que la tab 'Comparativa' del dashboard.",
"category": "Auditorías",
"args_placeholder": "--show-missing, --limit-missing N, --missing-cap N, --json",
"options": [
{"flag": "--show-missing", "label": "Mostrar listados de ausentes", "description": "Imprime ejemplos de contactos/opps ausentes (limitado por --missing-cap)."},
{"flag": "--json", "label": "Salida JSON", "description": "Devuelve el resultado completo como JSON en vez del reporte humano."}
]
},
"full_autos_investigation.py": {
"name": "full_autos_investigation.py",
"title": "Investigación Full Autos",
"description": "Investiga contactos y oportunidades de las sucursales catalogadas como FULL AUTOS en el verificador.",
"category": "Auditorías"
},
"full_audit_cross_account.py": {
"name": "full_audit_cross_account.py",
"title": "Auditoría Profunda Cross-Account",
"description": "Auditoría profunda cross-account: custom fields, pipelines, contacts.",
"category": "Auditorías"
},
"mp_branches_deep_audit.py": {
"name": "mp_branches_deep_audit.py",
"title": "Auditoría Deep Branches",
"description": "Auditoría completa de custom fields y pipelines en todas las branches.",
"category": "Auditorías"
},
"check_multi_pipeline.py": {
"name": "check_multi_pipeline.py",
"title": "Detectar Cuentas Multi-Pipeline",
"description": "Detecta cuentas GHL con múltiples pipelines.",
"category": "Auditorías"
},
"compare_pipelines_stages.py": {
"name": "compare_pipelines_stages.py",
"title": "Comparar Pipelines y Etapas",
"description": "Compara nombres y cantidades de pipelines/etapas desde SQLite contra marca o contra el patron mas comun.",
"category": "Auditorías",
"args_placeholder": "--reference-main, --reference-common, --show-ids, --details, --live, --migrate-duplicate-pipelines, --apply, --location <id>",
"supports_locations": False,
"options": [
{"flag": "--reference-main", "label": "Comparar contra marca", "description": "Usa la cuenta principal como referencia oficial."},
{"flag": "--reference-common", "label": "Detectar patron comun", "description": "Usa el patron mas frecuente entre todas las cuentas."},
{"flag": "--live", "label": "Consultar GHL live", "description": "Consulta la API en vivo en vez de usar SQLite local."},
{"flag": "--migrate-duplicate-pipelines", "label": "Migracion duplicados dry-run", "description": "Propone migrar oportunidades desde pipelines duplicados antiguos al mas reciente."},
{"flag": "--apply", "label": "Aplicar migracion", "description": "Aplica cambios en GHL. Requiere --migrate-duplicate-pipelines y --location."},
{"flag": "--show-ids", "label": "Mostrar IDs", "description": "Incluye IDs de pipelines y etapas en el reporte."},
{"flag": "--details", "label": "Mostrar detalle", "description": "Lista pipelines y etapas de cada cuenta evaluada."}
]
},
# Cron Jobs
"monitor_no_email.py": {
"name": "monitor_no_email.py",
"title": "Monitor Contactos Sin Email",
"description": "Detecta contactos sin email ni etiqueta 'sucursal' en todas las cuentas MP.",
"category": "Cron Jobs"
},
"health_check_workflows.py": {
"name": "health_check_workflows.py",
"title": "Health Check Workflows",
"description": "Verifica que los workflows de GHL estén activos en todas las cuentas.",
"category": "Cron Jobs"
},
"sync_workflows.py": {
"name": "sync_workflows.py",
"title": "Sincronizar Workflows GHL",
"description": "Obtiene todos los workflows de todas las cuentas de GHL y los almacena en SQLite.",
"category": "Cron Jobs",
"supports_locations": True,
"options": [
{"flag": "--include-main", "label": "Incluir Marca Principal", "description": "También procesa la cuenta de marca principal."}
]
},
"ghl_browser_session_generator.py": {
"name": "ghl_browser_session_generator.py",
"title": "Generar Sesión GHL (MFA)",
"description": "Abre un navegador Chrome para iniciar sesión en Bucefalo CRM y registrar las cookies de autenticación.",
"category": "Workflows GHL"
},
"ghl_browser_workflow_manager.py": {
"name": "ghl_browser_workflow_manager.py",
"title": "Gestor de Workflows Playwright",
"description": "Motor en segundo plano que realiza acciones en los workflows usando Playwright e intercepción híbrida.",
"category": "Workflows GHL"
},
"ghl_browser_workflow_anomaly_scanner.py": {
"name": "ghl_browser_workflow_anomaly_scanner.py",
"title": "Detector de Anomalías en Workflows",
"description": "Abre cada workflow de Bucéfalo en el editor visual y reporta nodos con ícono de alerta, IDs de campos personalizados sin resolver o avisos en la cabecera. Read-only sobre GHL — no modifica nada. Lento: abrir el editor de cada workflow toma ~30-40s, considera correr por location o de noche.",
"category": "Workflows GHL",
"args_placeholder": "--location <id>, --all --include-main, --workflow-id <id>",
"supports_locations": True,
"supports_audit": True,
"options": [
{"flag": "--all", "label": "Todas las subcuentas", "description": "Escanea workflows de todas las sucursales registradas."},
{"flag": "--include-main", "label": "Incluir marca", "description": "Incluye la cuenta principal cuando se usa --all."},
{"flag": "--workflow-id", "label": "Solo un workflow", "description": "Escanea solo este workflow (requiere --location)."},
{"flag": "--status all", "label": "Incluir borradores", "description": "Por defecto escanea solo publicados/activos. Esto incluye también drafts (más ruido)."},
{"flag": "--headed", "label": "Ver navegador", "description": "Lanza Chromium con UI visible para depurar."},
{"flag": "--max-workflows 20", "label": "Tope para QA", "description": "Limita el lote total. Útil para validar antes de correr todo."},
{"flag": "--workers 3", "label": "Workers paralelos", "description": "1-5 contextos simultáneos sobre el mismo browser (acelera escaneos grandes). Ignorado en perfil persistente."},
{"flag": "--action-pause 1", "label": "Pausa entre items (s)", "description": "Espera por worker entre workflows. Subir si Bucéfalo responde lento."}
]
},
# Workflows GHL
"tag_canal_origen_workflow.py": {
"name": "tag_canal_origen_workflow.py",
"title": "Step 1 - Tag a Canal Origen",
"description": "Actualiza Canal de Origen en contactos y campos de oportunidad según tags facebook-ads/sucursal/formulario.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run, --contact-only",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra qué haría sin modificar GHL."},
{"flag": "--contact-only", "label": "Solo contactos", "description": "No actualiza oportunidades."},
{"flag": "--sync-main", "label": "También marca principal", "description": "Busca match exacto y sincroniza la cuenta de marca."}
]
},
"fuente_prospecto_workflow.py": {
"name": "fuente_prospecto_workflow.py",
"title": "Step 2 - Fuente Prospecto",
"description": "Actualiza Fuente de Prospecto según Canal de Origen. Ejecutar después del Step 1.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run, --no-skip",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra qué haría sin modificar GHL."},
{"flag": "--no-skip", "label": "Forzar actualización", "description": "Actualiza aunque el valor ya coincida."},
{"flag": "--sync-main", "label": "También marca principal", "description": "Busca match exacto y sincroniza la cuenta de marca."}
]
},
"run_origen_fuente_workflows.py": {
"name": "run_origen_fuente_workflows.py",
"title": "Workflow Origen + Fuente",
"description": "Ejecuta juntos Step 1 y Step 2 en orden usando los mismos argumentos.",
"category": "Fix y Corrección",
"args_placeholder": "--dry-run, --contact-only, --no-skip",
"supports_locations": True,
"supports_audit": True,
"mutator": True,
"dry_run_mode": "dry_run_flag",
"options": [
{"flag": "--dry-run", "label": "Simular sin cambios", "description": "Muestra ambos pasos sin modificar GHL."},
{"flag": "--contact-only", "label": "Step 1 solo contactos", "description": "Step 1 no actualiza oportunidades."},
{"flag": "--no-skip", "label": "Step 2 forzar", "description": "Step 2 actualiza aunque el valor ya coincida."},
{"flag": "--sync-main", "label": "También marca principal", "description": "Busca match exacto y sincroniza la cuenta de marca."}
]
}
}
running_tasks = {} # task_id -> task_info
def get_task_status(task_id):
task = running_tasks.get(task_id)
if not task:
return None
return {
"id": task["id"],
"script_name": task["script_name"],
"status": task["status"],
"started_at": task["started_at"],
"finished_at": task["finished_at"],
"log_count": len(task["logs"]),
}
def stop_task(task_id):
"""Cancela un task en curso terminando su subproceso Python (SIGTERM).
El subproceso tiene handlers (_install_signal_handlers en los scripts Playwright)
que capturan SIGTERM, cierran el browser limpio y registran la interrupción en
script_audit. Devuelve True si se mandó la señal, False si el task no existe o ya terminó.
"""
task = running_tasks.get(task_id)
if not task:
return False, "Task no encontrado"
proc = task.get("process")
if proc is None:
return False, "El task no tiene subproceso activo"
try:
if proc.poll() is not None:
return False, "El subproceso ya terminó"
# En Windows terminate() == TerminateProcess; en Unix manda SIGTERM.
proc.terminate()
task["log_queue"].put("\n[CANCELADO] Subproceso terminado por solicitud del usuario.\n")
task["status"] = "cancelled"
return True, "Señal de cancelación enviada"
except Exception as e:
return False, f"Error cancelando subproceso: {e}"
def ensure_scripts_dir():
if not os.path.exists(SCRIPTS_DIR):
os.makedirs(SCRIPTS_DIR)
# Crear un cron subfolder si se requiere
os.makedirs(os.path.join(SCRIPTS_DIR, "cron"), exist_ok=True)
def load_metadata_overrides():
if not os.path.exists(METADATA_OVERRIDES_FILE):
return {}
try:
with open(METADATA_OVERRIDES_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def save_metadata_overrides(overrides):
with open(METADATA_OVERRIDES_FILE, "w", encoding="utf-8") as f:
json.dump(overrides, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
def get_deleted_scripts(overrides=None):
overrides = overrides if overrides is not None else load_metadata_overrides()
deleted = overrides.get(DELETED_SCRIPTS_KEY, [])
return set(deleted) if isinstance(deleted, list) else set()
def safe_script_path(script_name):
ensure_scripts_dir()
if not script_name or script_name != os.path.basename(script_name) or not script_name.endswith(".py"):
raise ValueError("Nombre de script inválido")
script_path = os.path.abspath(os.path.join(SCRIPTS_DIR, script_name))
scripts_dir = os.path.abspath(SCRIPTS_DIR)
if os.path.commonpath([scripts_dir, script_path]) != scripts_dir:
raise ValueError("Ruta de script inválida")
return script_path
def normalize_metadata(script_name, metadata):
if not isinstance(metadata, dict):
raise ValueError("La metadata debe ser un objeto JSON")
cleaned = {"name": script_name}
for key in EDITABLE_METADATA_FIELDS:
if key not in metadata:
continue
value = metadata[key]
if key in {"supports_locations", "mutator", "supports_audit"}:
cleaned[key] = bool(value)
elif key == "category":
value = str(value or "Sin registrar").strip() or "Sin registrar"
if value not in VALID_CATEGORIES:
raise ValueError("Categoría inválida")
cleaned[key] = value
elif key == "dry_run_mode":
value = str(value or "").strip()
if value not in VALID_DRY_RUN_MODES:
raise ValueError("Modo dry-run inválido")
if value:
cleaned[key] = value
else:
cleaned[key] = str(value or "").strip()
return cleaned
def build_script_metadata(script_name):
script_path = safe_script_path(script_name)
base = SCRIPTS_METADATA.get(script_name, {
"name": script_name,
"title": script_name,
"description": "Script detectado en la carpeta scripts; agrega metadata para mostrar un título y categoría específicos.",
"category": "Sin registrar",
}).copy()
override = load_metadata_overrides().get(script_name, {})
if isinstance(override, dict):
base.update(override)
base["name"] = script_name
base["exists"] = os.path.exists(script_path)
base["path"] = script_path
return base
def get_available_scripts():
"""
Retorna la lista de scripts con metadatos y si el archivo físico existe o no.
"""
ensure_scripts_dir()
scripts = []
registered_names = set(SCRIPTS_METADATA)
overrides = load_metadata_overrides()
deleted_scripts = get_deleted_scripts(overrides)
for script_file, meta in SCRIPTS_METADATA.items():
if script_file in deleted_scripts:
continue
script_path = os.path.join(SCRIPTS_DIR, script_file)
exists = os.path.exists(script_path)
merged_meta = meta.copy()
if isinstance(overrides.get(script_file), dict):
merged_meta.update(overrides[script_file])
scripts.append({
**merged_meta,
"name": script_file,
"exists": exists,
"path": script_path
})
for script_file in sorted(os.listdir(SCRIPTS_DIR)):
if not script_file.endswith(".py") or script_file in registered_names or script_file in deleted_scripts:
continue
script_path = os.path.join(SCRIPTS_DIR, script_file)
if not os.path.isfile(script_path):
continue
meta = {
"name": script_file,
"title": script_file,
"description": "Script detectado en la carpeta scripts; agrega metadata en script_runner.py para mostrar un título y categoría específicos.",
"category": "Sin registrar",
}
if isinstance(overrides.get(script_file), dict):
meta.update(overrides[script_file])
scripts.append({**meta, "name": script_file, "exists": True, "path": script_path})
return scripts
def get_script_metadata(script_name):
return build_script_metadata(script_name)
def update_script_metadata(script_name, metadata):
safe_script_path(script_name)
cleaned = normalize_metadata(script_name, metadata)
overrides = load_metadata_overrides()
deleted_scripts = get_deleted_scripts(overrides)
if script_name in deleted_scripts:
deleted_scripts.remove(script_name)
overrides[DELETED_SCRIPTS_KEY] = sorted(deleted_scripts)
overrides[script_name] = cleaned
save_metadata_overrides(overrides)
return build_script_metadata(script_name)
def delete_script(script_name):
script_path = safe_script_path(script_name)
file_deleted = False
if os.path.isfile(script_path):
os.remove(script_path)
file_deleted = True
overrides = load_metadata_overrides()
overrides.pop(script_name, None)
deleted_scripts = get_deleted_scripts(overrides)
deleted_scripts.add(script_name)
overrides[DELETED_SCRIPTS_KEY] = sorted(deleted_scripts)
save_metadata_overrides(overrides)
return {"name": script_name, "file_deleted": file_deleted, "metadata_deleted": True}
def split_arguments(arguments=""):
if not arguments:
return []
try:
return shlex.split(arguments)
except ValueError:
return arguments.split()
def build_script_commands(script_path, arguments="", locations=None, all_locations=False, run_id=None, supports_audit=False):
base_cmd = [sys.executable, "-X", "utf8", "-u", script_path]
base_args = split_arguments(arguments)
if supports_audit and run_id:
base_args = base_args + ["--run-id", run_id]
if all_locations:
return [base_cmd + base_args + ["--all"]]
if locations:
return [base_cmd + base_args + ["--location", location_id] for location_id in locations]
return [base_cmd + base_args]
def get_runner_max_workers():
raw_value = os.getenv("SCRIPT_RUNNER_MAX_WORKERS", str(DEFAULT_MAX_WORKERS))
try:
workers = int(raw_value)
except (TypeError, ValueError):
workers = DEFAULT_MAX_WORKERS
return max(1, min(workers, HARD_MAX_WORKERS))
def format_duration(seconds):
seconds = int(max(0, seconds or 0))
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def command_location_label(cmd):
if "--location" in cmd:
idx = cmd.index("--location")
if idx + 1 < len(cmd):
return cmd[idx + 1]
if "--all" in cmd:
return "--all"
return "general"
def _write_task_log_to_file(task, text):
"""Persiste una linea en el archivo plano del run, si esta abierto.
Silencioso ante fallos de IO para no tumbar el run completo."""
fh = task.get("log_fh") if isinstance(task, dict) else None
if not fh:
return
try:
fh.write(text)
if not text.endswith("\n"):
fh.write("\n")
fh.flush()
except Exception:
# Si el disco falla o el handle se cerro, perdemos persistencia pero
# el run sigue en memoria. No vale interrumpir por esto.
pass
def emit_task_log(task, text):
task["logs"].append(text)
task["log_queue"].put(text)
_write_task_log_to_file(task, text)
def get_task_log_path(task_id):
"""Devuelve la ruta absoluta del archivo .log del run, o None si no aplica.
Usado por los endpoints /api/scripts/logs/{task_id} y /tail."""
if not task_id:
return None
# Sanitizar agresivamente: solo aceptamos los chars validos de un uuid hex/-.
import re
if not re.match(r"^[A-Za-z0-9_-]{1,64}$", task_id):
return None
return os.path.join(SCRIPT_RUNS_DIR, f"{task_id}.log")
def store_command_result(task, result):
with task["results_lock"]:
task["command_results"].append(result)
def build_final_summary(task, commands, final_return_code, total_duration_seconds):
with task["results_lock"]:
results = sorted(task["command_results"], key=lambda item: item["index"])
successes = [result for result in results if result.get("return_code") == 0]
failures = [result for result in results if result.get("return_code") != 0]
audit_summary = script_audit.get_run_summary(task["id"])
change_status = audit_summary.get("by_status", {})
object_counts = audit_summary.get("by_object_type", {})
field_counts = audit_summary.get("by_field_name", {})
lines = [
"\n--------------------------------------------------\n",
"=== RESUMEN FINAL ===\n",
f"Script: {task['script_name']}\n",
f"Estado final: {'OK' if final_return_code == 0 else 'ERROR'}\n",
f"Modo: {'paralelo' if task.get('execution_mode') == 'parallel' else 'una por una'}\n",
f"Ejecuciones programadas: {len(commands)}\n",
f"Ejecuciones completadas: {len(results)}\n",
f"Exitosas: {len(successes)}\n",
f"Fallidas: {len(failures)}\n",
f"Duración total: {format_duration(total_duration_seconds)}\n",
]
if results:
lines.append("\nEjecuciones:\n")
for result in results:
status = "OK" if result.get("return_code") == 0 else "ERROR"
code = result.get("return_code")
code_text = "no iniciado" if code is None else str(code)
lines.append(
f" [{result['index']}/{result['total']}] {status} | "
f"Location: {result['location']} | Codigo: {code_text} | "
f"Duracion: {format_duration(result['duration_seconds'])}\n"
)
if audit_summary.get("total_changes"):
lines.append("\nCambios registrados por auditoría:\n")
for status in ("planned", "applied", "failed", "rolled_back"):
lines.append(f" {status}: {change_status.get(status, 0)}\n")
if object_counts:
object_text = ", ".join(f"{key}: {value}" for key, value in object_counts.items())
lines.append(f" Por objeto: {object_text}\n")
if field_counts:
top_fields = list(field_counts.items())[:8]
field_text = ", ".join(f"{key}: {value}" for key, value in top_fields)
lines.append(f" Campos principales: {field_text}\n")
else:
lines.append("\nCambios registrados por auditoría: 0\n")
failed_changes = audit_summary.get("failed_changes", [])
if failures or failed_changes:
lines.append("\n=== ERRORES DETECTADOS ===\n")
for result in failures:
lines.append(
f"[{result['index']}/{result['total']}] Location: {result['location']} | "
f"Codigo: {result.get('return_code')} | error_id: {result.get('error_id') or 'N/A'}\n"
)
for output_line in result.get("last_output_lines", [])[-6:]:
lines.append(f" {output_line.rstrip()}\n")
for change in failed_changes:
lines.append(
f"Cambio fallido | Location: {change['location_id']} | "
f"Objeto: {change['object_type']} {change['object_id']} | "
f"Campo: {change['field_name'] or 'N/A'} | Error: {change['error_message'] or 'N/A'}\n"
)
else:
lines.append("\nErrores detectados: 0\n")
return lines
def run_single_command(task, cmd, index, total):
task["log_queue"].put(f"\n--- EJECUCIÓN {index}/{total} ---\n")
task["log_queue"].put(f"Comando: {' '.join(cmd)}\n")
task["log_queue"].put("--------------------------------------------------\n")
started_monotonic = time.monotonic()
started_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
result = {
"index": index,
"total": total,
"command": " ".join(cmd),
"location": command_location_label(cmd),
"started_at": started_at,
"finished_at": None,
"duration_seconds": 0,
"return_code": None,
"status": "running",
"error_id": None,
"last_output_lines": [],
}
try:
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
cwd=os.path.dirname(os.path.abspath(__file__))
)
except Exception as exc:
error_id = error_logging.log_error("script_subprocess_start_failed", exc, {
"task_id": task.get("id"),
"script_name": task.get("script_name"),
"command": cmd,
"execution_index": index,
"execution_total": total,
"arguments": task.get("arguments"),
"locations": task.get("locations"),
})
task["log_queue"].put(f"ERROR registrado al iniciar subproceso: error_id={error_id}\n")
result.update({
"finished_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"duration_seconds": time.monotonic() - started_monotonic,
"return_code": None,
"status": "failed",
"error_id": error_id,
"last_output_lines": [f"No se pudo iniciar el subproceso: {exc}"],
})
store_command_result(task, result)
raise
task["process"] = process
prefix = f"[{index}/{total}] " if total > 1 else ""
command_logs = []
for line in iter(process.stdout.readline, ''):
output_line = f"{prefix}{line}" if prefix else line
command_logs.append(output_line)
task["logs"].append(output_line)
task["log_queue"].put(output_line)
# Persistencia minima a archivo plano por task_id, para descarga y reconexion.
_write_task_log_to_file(task, output_line)
process.stdout.close()
return_code = process.wait()
result.update({
"finished_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"duration_seconds": time.monotonic() - started_monotonic,
"return_code": return_code,
"status": "success" if return_code == 0 else "failed",
"last_output_lines": command_logs[-12:],
})
if return_code != 0:
error_id = error_logging.log_error("script_command_failed", None, {
"task_id": task.get("id"),
"script_name": task.get("script_name"),
"command": cmd,
"return_code": return_code,
"execution_index": index,
"execution_total": total,
"arguments": task.get("arguments"),
"locations": task.get("locations"),
"all_locations": task.get("all_locations"),
"execution_mode": task.get("execution_mode"),
"last_output_lines": command_logs[-80:],
})
result["error_id"] = error_id
task["log_queue"].put(f"\n--- EJECUCIÓN {index} FALLÓ CON CÓDIGO {return_code} ---\n")
task["log_queue"].put(f"ERROR registrado para diagnóstico: error_id={error_id}\n")
store_command_result(task, result)
return result
def run_script_subprocess(task_id, script_name, arguments="", locations=None, all_locations=False, execution_mode="sequential"):
"""
Función que corre en un hilo de fondo.
Ejecuta el script, lee la salida en vivo y la encola.
"""
task = running_tasks[task_id]
script_path = os.path.join(SCRIPTS_DIR, script_name)
meta = SCRIPTS_METADATA.get(script_name, {})
script_audit.create_run(task_id, script_name, arguments, locations or (["--all"] if all_locations else []), execution_mode)
commands = build_script_commands(script_path, arguments, locations, all_locations, task_id, bool(meta.get("supports_audit")))
task["log_queue"].put(f"=== INICIANDO EJECUCIÓN: {script_name} ===\n")
task["log_queue"].put(f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
task["log_queue"].put(f"Ejecuciones programadas: {len(commands)}\n")
task["log_queue"].put(f"Modo de ejecución: {'paralelo' if execution_mode == 'parallel' else 'una por una'}\n")
task["log_queue"].put("--------------------------------------------------\n")
started_monotonic = time.monotonic()
try:
final_return_code = 0
if execution_mode == "parallel" and len(commands) > 1:
max_workers = min(len(commands), get_runner_max_workers())
task["log_queue"].put(f"Ejecutando hasta {max_workers} sucursales/cuentas en paralelo.\n")
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(run_single_command, task, cmd, index, len(commands)): index
for index, cmd in enumerate(commands, 1)
}
for future in concurrent.futures.as_completed(futures):
result = future.result()
return_code = result.get("return_code")
if return_code != 0 and final_return_code == 0:
final_return_code = return_code or 1
else:
for index, cmd in enumerate(commands, 1):
result = run_single_command(task, cmd, index, len(commands))
return_code = result.get("return_code")
if return_code != 0:
final_return_code = return_code or 1
break
task["finished_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
total_duration_seconds = time.monotonic() - started_monotonic
if final_return_code == 0:
task["status"] = "success"
script_audit.update_run_status(task_id, "success")
emit_task_log(task, "\n=== EJECUCIÓN COMPLETADA CON ÉXITO (Código 0) ===\n")
else:
task["status"] = "failed"
script_audit.update_run_status(task_id, "failed", f"Return code {final_return_code}")
emit_task_log(task, f"\n=== ERROR DE EJECUCIÓN: Script retornó código {final_return_code} ===\n")
for line in build_final_summary(task, commands, final_return_code, total_duration_seconds):
emit_task_log(task, line)
except Exception as e:
task["status"] = "failed"
script_audit.update_run_status(task_id, "failed", str(e))
task["finished_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
error_id = error_logging.log_error("script_runner_fatal", e, {
"task_id": task_id,
"script_name": script_name,
"arguments": arguments,
"locations": locations,
"all_locations": all_locations,
"execution_mode": execution_mode,
"last_output_lines": task["logs"][-80:],
})
err_msg = f"\nError fatal ejecutando el subproceso: {str(e)} | error_id={error_id}"
task["logs"].append(err_msg)
task["log_queue"].put(err_msg)
total_duration_seconds = time.monotonic() - started_monotonic
for line in build_final_summary(task, commands, 1, total_duration_seconds):
emit_task_log(task, line)
finally:
task["log_queue"].put(None) # Indicador de fin de stream
# Cerrar handle de persistencia. El archivo queda en disco para descarga.
fh = task.get("log_fh")
if fh:
try:
fh.flush()
fh.close()
except Exception:
pass
task["log_fh"] = None
def start_script(script_name, arguments="", locations=None, all_locations=False, execution_mode="sequential"):
"""
Inicia la ejecución de un script y retorna el task_id.
"""
ensure_scripts_dir()
script_path = os.path.join(SCRIPTS_DIR, script_name)
if not os.path.exists(script_path):
raise FileNotFoundError(f"El archivo {script_name} no existe físicamente en {SCRIPTS_DIR}")
task_id = str(uuid.uuid4())
# Archivo plano para persistencia del log completo del run.
# Sirve para descarga ("Descargar log") y para reconexion del SSE por tail.
log_path = None
log_fh = None
try:
os.makedirs(SCRIPT_RUNS_DIR, exist_ok=True)
log_path = os.path.join(SCRIPT_RUNS_DIR, f"{task_id}.log")
# encoding utf-8 explicito; buffering 1 = line-buffered, alineado con el
# bufsize del subprocess. Asi el cliente puede leer tail en tiempo real.
log_fh = open(log_path, "w", encoding="utf-8", buffering=1)
except Exception as exc:
# No tumbar el run por fallo de persistencia; queda solo en RAM.
error_logging.log_error("script_runner_log_file_open_failed", exc, {
"task_id": task_id,
"log_path": log_path,
"script_name": script_name,
})
log_fh = None
task_info = {
"id": task_id,
"script_name": script_name,
"status": "running",
"started_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"finished_at": None,
"logs": [],
"log_queue": queue.Queue(),
"process": None,
"arguments": arguments,
"locations": locations or [],
"all_locations": all_locations,
"execution_mode": execution_mode,
"command_results": [],
"results_lock": threading.Lock(),
"log_path": log_path,
"log_fh": log_fh,
}
running_tasks[task_id] = task_info
# Lanzar hilo ejecutor
t = threading.Thread(target=run_script_subprocess, args=(task_id, script_name, arguments, locations, all_locations, execution_mode))
t.daemon = True
t.start()
return task_id
def get_script_log_stream(task_id):
"""
Generador para Server-Sent Events (SSE).
"""
if task_id not in running_tasks:
yield "data: Error: ID de tarea de ejecución no encontrado\n\n"
return
task = running_tasks[task_id]
q = task["log_queue"]
# Si la tarea ya terminó y no hay elementos en cola, enviar logs guardados
if task["status"] in ("success", "failed") and q.empty():
for line in task["logs"]:
yield f"data: {line.rstrip()}\n\n"
yield "data: [EOF]\n\n"
return
# De lo contrario, ir leyendo de la cola activa en tiempo real
while True:
try:
line = q.get(timeout=0.1)
if line is None: # Señal de fin
break
yield f"data: {line.rstrip()}\n\n"
except queue.Empty:
if task["status"] in ("success", "failed"):
break
# Dar un respiro
time.sleep(0.05)
continue
yield "data: [EOF]\n\n"
# Asegurar carpeta de scripts al importar
ensure_scripts_dir()