Initial commit - dashboard leads

This commit is contained in:
Panchito
2026-08-18 11:48:08 -05:00
commit e90dae89b6
5148 changed files with 714437 additions and 0 deletions

173
FUTUROS_CAMBIOS.md Normal file
View File

@@ -0,0 +1,173 @@
# FUTUROS CAMBIOS — Dashboard LEADS
Lista de cosas pendientes que el usuario quiere hacer más adelante.
Cuando el usuario pida "futuros cambios", recordarle TODO esto.
---
## 1. Mapa de campañas (frases de pauta) → mover a GitHub o Supabase
- Hoy está incrustado en el código: `backend/data_manager_v2.py`, lista `CAMPANIAS`
(las ~90 frases con emojis + programa/sede/código/rango de fechas).
- Objetivo: sacarlo a un JSON en GitHub (o tabla en Supabase) para editarlo
sin tocar el código (igual que se hace en el otro dashboard con las clasificaciones).
## 2. Leyenda de los códigos de pauta → a GitHub/Supabase
- Los códigos de campaña (191, 193, 63a, etc.) y su significado.
- Mismo objetivo: que sea editable fuera del código.
## 3. Excluir cursos (num_indice) puntuales del cálculo
- Por ahora EXCLUIR del cálculo de Ocupabilidad/cursos: **num_indice 1154 y 1121**.
- Motivo: el usuario indicó que no deben contar (ej. reprogramados a otro mes).
- Idea a futuro: manejar esta lista de exclusión desde GitHub/Supabase, no en código.
- (Relacionado: el PBI filtra cursos por FECHA_MOSTRAR_COL = fecha con reprogramación
de SharePoint; aquí no tenemos esa columna todavía → Cursos Reprogramados quedó en fase 2.)
## 4. Reprogramación de cursos (SharePoint) — FASE 2 [DEPENDE DE LA FUENTE]
La fecha ORIGINAL planificada de cada curso venía de SharePoint
(BaseBI_Programacion: FECHA CALENDARIO vs FECHA REPROGRAMADO). Sin esa fuente,
varios KPIs no se pueden calcular exacto. Pendiente: traerla de Google Sheets / Supabase / JSON
y construir FECHA_MOSTRAR_COL, FECHA_CUADRO_BI y ESTADO_CUADRO_BI.
KPIs/cosas que dependen de esto (hoy aproximados o en 0):
- **Cursos Reprogramados** → hoy SALE 0 (no hay fecha original para comparar).
- **Cursos Programados** → hoy usa solo `fch_inicio` (el PBI usa planificada + reprogramada).
Diferencia chica, se cuadrará en fase 2.
- **Cursos Iniciados / Suspendidos** → hoy por `fch_inicio` y `cod_estado`; revisar con reprogramación.
- La exclusión manual de cursos (1154, 1121) es justo por reprogramación: cuando llegue
la fuente, esto debería resolverse solo (ya no haría falta la lista manual).
## 5. Matriz por Curso (num_indice) con métricas de Leads por Pauta — NUEVA
El usuario quiere una MATRIZ debajo de la tabla por Sede, con:
- FILA: Fact_SQL_Base_Cursos[Personalizado], separada por cada num_indice (cursos se repiten).
- VALORES: Fecha_Inicio_Visual, Leads_Acumulados_Historico, Cant_Leads_Nuevos,
Cant_Leads_Nuevos_Con_Asesor, Cant_Leads_Nuevo_x_Mes, Cant_Leads_Nuevos_x_Mes_Con_Asesor.
DEPENDE DE: el campo **Pauta** (código de campaña) de CADA curso, que en el PBI venía de
SharePoint (BaseBI_Programacion → PAUTA_CODIGO). El cruce de TODAS esas medidas es por
TREATAS(Cursos[Pauta], Leads[codigo]) — o sea por CÓDIGO, no por teléfono.
Lo que se necesita para construirla (traer a Google Sheets / Supabase / JSON):
- Por cada num_indice (curso): su **código(s) de Pauta** asociado.
- Idealmente también FECHA_CUADRO_BI (fecha real/reprogramada) — mismo paquete que el punto 4.
Medidas (ya tengo las fórmulas DAX exactas guardadas):
- Cant_Leads_Nuevos = leads únicos (Cantidad_Veces=1) con codigo = Pauta del curso, histórico.
- ..._Con_Asesor = + asesor no vacío.
- ..._x_Mes = igual pero respeta filtro de fecha del mes.
- ..._x_Mes_Con_Asesor = del mes + con asesor.
- Leads_Acumulados_Historico = suma de Cant_Leads_Nuevos de cursos del mismo Personalizado
con fecha <= la del curso actual.
- Fecha_Inicio_Visual = FECHA_CUADRO_BI del curso.
ESTADO: ✅ La MATRIZ YA ESTÁ CONSTRUIDA en el dashboard (backend `leads_logic.matriz_cursos`,
frontend "Detalle por Curso"). Hoy muestra Programa + Fecha Inicio reales; las 5 columnas de
leads salen en 0 porque FALTA el código de Pauta por curso. Cuando llegue la fuente, solo se
rellena el cruce y se activan las 5 medidas.
### Fuente SharePoint que hay que conectar (acordado)
Archivo: Base_BI_2.xlsx
Ruta: https://escuelarefrigeracion.sharepoint.com/sites/ASESORASCOMERCIALES/
Documentos compartidos/2. BASE PROSPECTOS/BASE GENERAL/Patricia/Base_BI_2.xlsx
Tabla: BaseBI_Programacion
Columnas: NUM_INDICE, FECHA CALENDARIO, FECHA REPROGRAMADO, ESTADO, CONTAR, PAUTA_CODIGO
Cruce: Cursos[NUM_INDICE] → BaseBI_Programacion[NUM_INDICE] ⇒ PAUTA_CODIGO
(en el PBI: columna calculada Pauta = RELATED(BaseBI_Programacion[PAUTA_CODIGO]))
Luego: TREATAS(Cursos[Pauta], Leads[codigo]) para contar leads.
Cómo leerla (3 opciones, de menos a más esfuerzo):
1) (Recomendado) Que Patricia/usuario suba a Supabase una tabla `cursos_pauta`
(num_indice, pauta_codigo, fecha_cuadro_bi). El backend ya tiene credenciales Supabase.
→ cero auth de Microsoft, instantáneo, editable.
2) Exportar esa hoja a un Google Sheet / CSV en GitHub (GITHUB_BASE ya configurado).
3) Lectura directa SharePoint con Office365-REST-Python-Client (requiere usuario+clave M365
o app registration). Más frágil; dejarlo como última opción.
Cuando exista la fuente, en `data_manager_v2.py` agregar `traer_cursos_pauta()` y mapear
num_indice→pauta sobre los cursos; en `leads_logic.matriz_cursos` reemplazar los 0 por el
conteo de leads cruzado por codigo (las fórmulas DAX de arriba ya están claras).
## 6. Base Junta (identificador de canal de origen por teléfono) — FUTURO
Objetivo: cuando un número se MATRICULA, saber su CANAL DE ORIGEN (y sede/programa/código)
para clasificarlo. Se usará como "memoria" de consulta por teléfono.
Fuente/lógica (ya PROBADA y validada con el usuario):
- Une leads de POSTGRE (Chatwoot + mapa de campañas CAMPANIAS → canal/sede/programa/código)
+ SUPABASE `datos_unificados` (Telefono, Fechacreada, Canal, Sede, Programa, Codigo).
- Teléfono NORMALIZADO: quita '+51', '+', espacios, '-', '(', ')'.
- DEDUP por teléfono → se queda con el MÁS ANTIGUO (solo por FECHA, sin hora).
- Desempate misma fecha: 1) PAUTA_WSP 2) PAUTA_WSP_FACE 3) orden alfabético del canal.
- Texto en MAYÚSCULAS; '-' de Supabase → vacío.
- Resultado prueba: ~45,054 teléfonos únicos.
Script de prueba (standalone, corre en cualquier carpeta): `base_junta_full.py`
(genera base_junta_full.xlsx con 6 columnas: Telefono, Canal, Fecha Creada, Sede,
Programa, Codigo). NO está conectado al dashboard todavía.
Cómo conectarlo a futuro (acordado, debe ser LIVIANO):
- Subir la base junta a una tabla en Supabase (ej. `base_junta`).
- El backend la carga UNA vez en un dict {telefono → datos}, cacheado (refresco ~15 min).
- Al cruzar una matrícula por teléfono → se obtiene su canal de origen y se clasifica.
- 44-73k filas en dict = instantáneo, sin impacto en la carga.
ESTADO: pendiente. Cuando se retome: crear tabla Supabase + traer_base_junta() en backend
+ cruce por teléfono en el módulo que corresponda.
## 7. Simplificar/normalizar nombres de programa (Personalizado) — MEJORA OPCIONAL
Los nombres largos de los cursos se acortan con una lista de reemplazos de texto
en `backend/leads_logic.py``_PERS_REEMPLAZOS` (hardcoded).
Problemas/mejoras:
- Está en el código: cada nombre nuevo/largo hay que agregarlo a mano.
- Algunos reemplazos quedan SIN sede ni frecuencia consistente
(ej. "VOLUMEN VARIABLE VRF" quedó sin "SEDE -" ni "- FREC").
- Ideal: formato uniforme tipo "SEDE - CORTO - FREC" para todos.
Ideas (opcional, cuando haya tiempo):
1. Mover `_PERS_REEMPLAZOS` a un JSON en GitHub o tabla Supabase → editable sin tocar código.
2. Normalizar TODOS los nombres con un mismo criterio (sede + nombre corto + frecuencia),
no solo los VRF/CO2 ya hechos.
ESTADO: opcional. No urgente; los reemplazos actuales funcionan.
## 8. Manual de tablas de Supabase — PENDIENTE
Mantener UN solo documento que liste cada tabla de Supabase y para qué sirve
(evitar olvidarse). Ej.:
- `datos_unificados` → leads (proyecto ogzjtkxnfs...).
- `basebi_programacion` → pauta/estado/contar por num_indice (proyecto uztqscimts...).
- `cartera_junta` → cartera total combinada por teléfono + es_origen (proyecto uztqscimts...).
- (futuras: `programa_alias` → diccionario de normalización).
Cada vez que se cree una tabla nueva, agregarla aquí.
## 9. Normalización de programa/sede en cartera_junta + alerta de valores nuevos — PENDIENTE
Problema: en `cartera_junta` hay valores repetidos con distinta escritura
(CÁMARAS/CAMARAS, GESTION/GESTIÓN, VRF / VRV vs VRF, SUP. DE OBRAS/SUPER.OBRAS/
SUPERVISIÓN DE OBRAS, DIPLONADO REF typo, LINA→LIMA) y basura (vacío, "0").
Como la tabla se regenera a diario (GitHub Actions), limpiar a mano NO sirve
(se sobrescribe). Hay que normalizar en la GENERACIÓN.
Plan acordado (mínimo de tablas):
- En `carga_cartera.py`: (a) reglas automáticas → MAYÚSCULAS + sin acentos + sin
espacios extra (une CÁMARAS=CAMARAS, GESTIÓN=GESTION, etc. sin mantener nada);
(b) UNA tabla-diccionario `programa_alias` (y sede) en Supabase con
alias → valor_correcto para los sinónimos/typos que las reglas no resuelven.
- Detección de NUEVOS: NO usar tabla de pendientes. Calcular al vuelo en el
dashboard, comparando los valores únicos de cartera_junta vs el diccionario.
- Alerta: 🔔 campanita/panel de notificaciones en el dashboard. Silencioso si todo
es conocido; si aparece sede/programa nuevo no reconocido, se enciende con la
lista para que el usuario decida (unir como alias o dejar como nuevo real).
- Decidir aparte: valores vacíos y "0" → dejar o convertir a SIN PROGRAMA/SIN SEDE.
## 10. Clasificar valores no identificados DESDE la campanita — MEJORA OPCIONAL
Hoy la campanita 🔔 solo AVISA de los programas/sedes no identificados; para
corregirlos hay que ir a Supabase y agregar la fila en `alias_normalizacion`.
Mejora: permitir hacerlo desde la misma web, en la notificación:
- Al lado de cada valor no identificado, un desplegable con los programas/sedes
YA EXISTENTES (canónicos) + botón "Asignar".
- Al asignar, el backend inserta la fila (alias → correcto) en `alias_normalizacion`
directamente desde la web (nuevo endpoint POST), sin entrar a Supabase.
- Así se limpia al instante y en la siguiente corrida del workflow ya sale unido.
ESTADO: opcional. Hoy funciona avisando; esto solo agiliza la corrección.
---
(Agregar aquí cualquier otro pendiente que vaya saliendo.)

13
INICIAR_LEADS.bat Normal file
View File

@@ -0,0 +1,13 @@
@echo off
REM ============================================================
REM INICIAR DASHBOARD LEADS - Backend (8001) + Frontend (5174)
REM ============================================================
echo Iniciando BACKEND y FRONTEND de LEADS...
start "LEADS - BACKEND" cmd /k ""%~dp0_run_backend.bat""
start "LEADS - FRONTEND" cmd /k ""%~dp0_run_frontend.bat""
start "LEADS - TIEMPOS" cmd /k ""%~dp0_run_tiempos.bat""
echo Listo. Se abrieron 3 ventanas (Backend, Frontend y Tiempos de carga).
echo Cuando cargue, abre el navegador en: http://localhost:5174
timeout /t 4 >nul

78
OPTIMIZACION_CACHE.md Normal file
View File

@@ -0,0 +1,78 @@
# OPTIMIZACIÓN — Caché de descargas pesadas (reutilizable en Ventas, Comisiones, etc.)
## El problema (síntoma)
Al cambiar un filtro (mes, sede, etc.) el dashboard se demora 15-130 segundos,
aunque los datos "ya deberían estar cargados". Se siente como si cada filtro
volviera a descargar todo desde cero.
## La causa raíz
Una función que **descarga datos de una fuente externa** (Supabase, SQL Server,
Postgre, API) se llama SIN caché. Cada vez que el usuario filtra, esa función
**vuelve a descargar TODO** desde internet.
En LEADS el culpable fue `traer_cartera_rows()` (bajaba 86,000 filas de Supabase
de 1000 en 1000 = 86 pedidos en serie ≈ 52-130s) y se re-descargaba en CADA
cambio de mes, porque `matriz_web_formulario` la llamaba directo:
```python
# ANTES (malo): re-descarga en cada llamada
def _load():
rows = get_dm().traer_cartera_rows() # baja 86k filas CADA vez
...
```
## Cómo diagnosticarlo (script de tiempos)
Medir cada parte por separado para encontrar QUÉ tarda. Clave: medir un cambio
de mes CON los insumos ya cargados (2a vez). Si sigue lento, algo se re-descarga.
```python
import time, services as S
def cron(label, fn):
t0 = time.time(); fn(); print(f" {label:38} {time.time()-t0:6.1f}s")
cron("dashboard MES1 (1a vez)", lambda: S.mi_funcion("2026","1",...))
cron("dashboard MES2 (2a vez)", lambda: S.mi_funcion("2026","2",...)) # <-- debe ser rapido
# Si MES2 sale lento, desglosar: medir cada traer_* y cada matriz por separado
# hasta ver cual linea se lleva los segundos (casi siempre un traer_* sin cache).
```
## La solución (2 líneas, NO toca lógica ni resultados)
Envolver la descarga pesada en `cache_get_or_set` con clave GLOBAL, y que TODAS
las funciones usen ese helper cacheado en vez de descargar directo.
```python
# 1) Helper cacheado (se baja UNA vez, se reutiliza)
def _cartera_rows_cache():
return cache_get_or_set("cartera_rows", ("GLOBAL",),
lambda: get_dm().traer_cartera_rows())
# 2) Reemplazar TODAS las llamadas directas:
# get_dm().traer_cartera_rows() -> _cartera_rows_cache()
```
Resultado en LEADS:
- Cambiar de mes: de 69-133s -> 0.4s (solo filtra lo que ya está en memoria)
- La descarga pesada solo se paga 1 vez (en la precarga de arranque, en background).
## Regla general para CUALQUIER dashboard (Ventas, Comisiones, Rentabilidad...)
1. Toda función `traer_*` / consulta a SQL/Supabase/API que se use en varias
vistas o en cada filtro, debe estar cacheada con `cache_get_or_set`.
2. NUNCA llamar `get_dm().traer_xxx()` directo dentro de un `_load` que depende
de filtros; usar el helper cacheado.
3. El filtro (mes/sede/programa) debe operar sobre datos YA en memoria, no
re-descargar. Filtrar en memoria = milisegundos.
4. Precargar en segundo plano (hilo daemon al startup) el mes actual/anterior
para que el usuario no espere la 1a carga.
5. El refresco periódico (cada 15 min) invalida caché -> considerar bajar datos
que cambian 1 vez al día (ej. cartera) con menos frecuencia, o recargar en
background sin dejar hueco.
## Mejora opcional pendiente (no aplicada aún)
La 1a descarga de la cartera es lenta (100-155s) porque pagina de 1000 en 1000.
Subir el tamaño de lote (ej. limit=10000) reduce los viajes: de ~155s a ~10-15s.
Aplica a cualquier tabla grande que se baje paginada.
## Verificación (que no cambien números)
Antes de optimizar, guardar una "línea base" con los totales actuales
(diag_base.json). Tras optimizar, comparar: deben ser IDÉNTICOS. La optimización
solo cambia CUÁNDO/CÓMO se descarga, nunca el cálculo.

5
PRUEBAS_EXPORT/.env Normal file
View File

@@ -0,0 +1,5 @@
# Credenciales del Supabase donde vive la tabla cartera_junta
# (proyecto uztqscimtsihrzgybsyb — service_role)
CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co
CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E
CARTERA_TABLA=cartera_junta

Binary file not shown.

View File

@@ -0,0 +1,107 @@
# PRUEBAS_EXPORT/export_cartera_junta.py
"""
Script de PRUEBA e independiente del dashboard.
Exporta la tabla cartera_junta de Supabase a un Excel para revisarla.
Autodetecta en cuál proyecto Supabase vive la tabla, probando las credenciales
que ya tienes configuradas (en el .env de esta carpeta y en el .env del backend).
Lee TODAS las filas paginando (Supabase devuelve máx. 1000 por consulta).
Uso: py -3.12 export_cartera_junta.py
"""
import os
from dotenv import load_dotenv
_AQUI = os.path.dirname(__file__)
# Carga el .env de esta carpeta y también el del backend (para reutilizar credenciales)
load_dotenv(os.path.join(_AQUI, ".env"))
load_dotenv(os.path.join(_AQUI, "..", "backend", ".env"))
PAGINA = 1000
TABLA = os.getenv("CARTERA_TABLA", "cartera_junta")
def _valida(url):
"""Descarta URLs vacías o de plantilla (XXXX)."""
if not url:
return False
u = url.strip().lower()
return u.startswith("http") and "xxxx" not in u
def _candidatos():
"""Pares (nombre, url, key) a probar, en orden de prioridad."""
pares = [
("CARTERA", os.getenv("CARTERA_URL"), os.getenv("CARTERA_KEY")),
("SUPABASE", os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")),
("SUPABASE_PAUTA", os.getenv("SUPABASE_PAUTA_URL"),os.getenv("SUPABASE_PAUTA_KEY")),
]
vistos, out = set(), []
for nombre, url, key in pares:
if _valida(url) and key and (url, key) not in vistos:
vistos.add((url, key))
out.append((nombre, url.strip(), key.strip()))
return out
def main():
from supabase import create_client
candidatos = _candidatos()
if not candidatos:
print("❌ No hay credenciales válidas. Pon CARTERA_URL/CARTERA_KEY en el .env de esta carpeta.")
return
# Buscar en qué proyecto existe la tabla
sb = None
for nombre, url, key in candidatos:
try:
cli = create_client(url, key)
cli.table(TABLA).select("*").limit(1).execute() # prueba
sb = cli
print(f"✅ Tabla '{TABLA}' encontrada en el proyecto: {nombre} ({url})")
break
except Exception as e:
print(f" · {nombre}: no sirvió ({str(e)[:80]})")
if sb is None:
print(f"\n❌ No encontré la tabla '{TABLA}' en ninguno de tus proyectos Supabase.")
print(" Copia CARTERA_URL y CARTERA_KEY (secrets de GitHub) al .env de esta carpeta.")
return
# Traer TODAS las filas paginando de a 1000
filas = []
inicio = 0
while True:
res = sb.table(TABLA).select("*").range(inicio, inicio + PAGINA - 1).execute()
lote = res.data or []
if not lote:
break
filas.extend(lote)
print(f" leídas {len(filas)} filas...")
if len(lote) < PAGINA:
break
inicio += PAGINA
if not filas:
print(f"⚠️ La tabla '{TABLA}' está vacía.")
return
columnas = list(filas[0].keys())
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "cartera_junta"
ws.append(columnas)
for r in filas:
ws.append([r.get(c, "") for c in columnas])
salida = os.path.join(_AQUI, "cartera_junta.xlsx")
wb.save(salida)
print(f"\n✅ Exportado: {salida}")
print(f" Filas: {len(filas)} | Columnas: {', '.join(columnas)}")
if __name__ == "__main__":
main()

11
_run_backend.bat Normal file
View File

@@ -0,0 +1,11 @@
@echo off
REM Lanzador interno del BACKEND de LEADS
cd /d "%~dp0backend"
echo ====================================
echo LEADS - BACKEND (FastAPI) puerto 8001
echo Carpeta: %CD%
echo ====================================
py -3.12 main.py
echo.
echo (El backend termino o fallo. Revisa los mensajes de arriba.)
pause

12
_run_frontend.bat Normal file
View File

@@ -0,0 +1,12 @@
@echo off
cd /d "%~dp0frontend"
echo ====================================
echo LEADS - FRONTEND (Vite) puerto 5174
echo Carpeta: %CD%
echo ====================================
if not exist "node_modules\.bin\vite.cmd" call npm install
echo Iniciando servidor de desarrollo...
call npm run dev
echo.
echo (npm run dev termino o fallo.)
pause

21
_run_tiempos.bat Normal file
View File

@@ -0,0 +1,21 @@
@echo off
REM Ventana que muestra EN VIVO los tiempos de carga del backend de LEADS.
REM Lee el archivo backend\carga_tiempos.log que el backend va escribiendo.
title LEADS - TIEMPOS DE CARGA
cd /d "%~dp0backend"
echo ============================================================
echo LEADS - TIEMPOS DE CARGA (en vivo)
echo Esperando que el backend empiece a cargar...
echo ============================================================
echo.
REM Espera a que exista el log (el backend lo crea al arrancar la precarga)
:esperar
if not exist "carga_tiempos.log" (
timeout /t 1 >nul
goto esperar
)
REM Muestra el log en vivo (se actualiza solo conforme el backend carga)
powershell -NoProfile -Command "Get-Content -Path 'carga_tiempos.log' -Wait -Encoding UTF8"
pause

22
backend/.env Normal file
View File

@@ -0,0 +1,22 @@
PG_HOST=191.98.134.81
PG_DATABASE=chatwoot_production
PG_USER=postgres
PG_PASSWORD=2165$%sd3%DFG
PG_PORT=5432
SQL_SERVER=191.98.134.80
SQL_DATABASE=BDUS_CK000040_0001
SQL_USERNAME=ASEBASTIAN
SQL_PASSWORD=24MY36$z>&Uf
GITHUB_BASE=https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD_LEADS
SUPABASE_URL=https://ogzjtkxnfswpbmnbhnjd.supabase.co
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9nemp0a3huZnN3cGJtbmJobmpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjYzNDk4MywiZXhwIjoyMDkyMjEwOTgzfQ.D7XB6GIs8UvI97lcMdf6Y6-8ON2ENhh0DOaiMro2f9Y
SUPABASE_TABLA_LEADS=datos_unificados
SUPABASE_PAUTA_URL=https://uztqscimtsihrzgybsyb.supabase.co
SUPABASE_PAUTA_KEY=sb_publishable_aJCh5J6UghfKmSz6Swy4iA_orAqeHb7
SUPABASE_TABLA_PAUTA=basebi_programacion
CARTERA_URL=https://uztqscimtsihrzgybsyb.supabase.co
CARTERA_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6dHFzY2ltdHNpaHJ6Z3lic3liIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2MTMyMzQ0MCwiZXhwIjoyMDc2ODk5NDQwfQ.pvqzwJ7fBxggNka3oga7SdhiADIgIVKGEuxGbzHQj0E
SUPABASE_TABLA_CARTERA=cartera_junta
SUPABASE_TABLA_ALIAS=alias_normalizacion
SUPABASE_TABLA_CONJUNTO=conjunto_pauta
META_CSV_URL=https://docs.google.com/spreadsheets/d/e/2PACX-1vQa5BFEeF-p0-6-FuEXDXT4eb_ZK0fkYgLMxK_Ly3EJJPwQBsYDfNsSW09ppMNrH2LIiwVdAnC5q64C/pub?gid=1241390845&single=true&output=csv

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

49
backend/bajar_cartera.py Normal file
View File

@@ -0,0 +1,49 @@
import os, csv
import requests
from dotenv import load_dotenv
from collections import Counter
load_dotenv()
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
assert url and key, "Falta CARTERA_URL / CARTERA_KEY en .env"
sel = "telefono,sede,programa,fecha_creada,asesor,es_origen,canal,origen_base"
paso, desde = 1000, 0
out = []
while True:
hdr = {"apikey": key, "Authorization": f"Bearer {key}",
"Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"}
r = requests.get(f"{url}/rest/v1/{t}",
params={"select": sel, "order": "id.asc"},
headers=hdr, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break # parar SOLO cuando devuelve 0
out.extend(data)
desde += len(data)
print(f" descargadas: {desde}", end="\r")
print(f"\n\nTOTAL filas descargadas: {len(out)}")
ori = Counter(str(x.get("es_origen") or "").strip().upper() for x in out)
print("es_origen:", ori.most_common(5))
ob = Counter(str(x.get("origen_base") or "").strip().upper() for x in out)
print("origen_base:", ob.most_common(5))
sede = Counter(str(x.get("sede") or "").strip().upper() for x in out)
print("por SEDE:", sede.most_common(10))
# Exporta a CSV para comparar en Excel
salida = "cartera_bajada.csv"
with open(salida, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=sel.split(","))
w.writeheader()
for x in out:
w.writerow({k: x.get(k, "") for k in sel.split(",")})
print(f"\nExportado a: {salida}")

View File

@@ -0,0 +1,64 @@
import os, csv
import requests
from dotenv import load_dotenv
from collections import Counter
load_dotenv()
# datos_unificados vive en el proyecto SUPABASE_URL2 / SUPABASE_KEY2 (el del sync)
url = (os.getenv("SUPABASE_URL2") or os.getenv("SUPABASE_URL")
or os.getenv("CARTERA_URL") or "")
key = (os.getenv("SUPABASE_KEY2") or os.getenv("SUPABASE_KEY")
or os.getenv("CARTERA_KEY") or "")
t = "datos_unificados"
assert url and key, "Falta SUPABASE_URL2 / SUPABASE_KEY2 (o SUPABASE_URL/KEY) en .env"
sel = "Ejecutivo,Telefono,Fechacreada,Sede,Programa,Canal,Codigo,Codigo_Vendedor"
paso, desde = 1000, 0
out = []
while True:
hdr = {"apikey": key, "Authorization": f"Bearer {key}",
"Range-Unit": "items", "Range": f"{desde}-{desde + paso - 1}"}
r = requests.get(f"{url}/rest/v1/{t}",
params={"select": sel, "order": "id.asc"},
headers=hdr, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
out.extend(data)
desde += len(data)
print(f" descargadas: {desde}", end="\r")
print(f"\n\nTOTAL filas en datos_unificados: {len(out)}")
def _tel_vacio(v):
s = str(v or "")
for x in ("+51", "+", " ", "-", "(", ")", ".0"):
s = s.replace(x, "")
s = s.strip()
if not s or s in ("-", "nan") or set(s) == {"0"}:
return True
return s.isdigit() and len(s) <= 6
vacios = sum(1 for x in out if _tel_vacio(x.get("Telefono")))
print(f"Filas con teléfono VÁLIDO: {len(out) - vacios}")
print(f"Filas BASURA (sin teléfono): {vacios}")
# desglose por sede (para ver de qué Excel viene la basura)
sede_basura = Counter()
for x in out:
if _tel_vacio(x.get("Telefono")):
sede_basura[str(x.get("Sede") or "").strip().upper()] += 1
print("\nBASURA por SEDE:")
for s, n in sede_basura.most_common(12):
print(f" {s if s else '(vacio)':16} {n}")
salida = "datos_unificados.csv"
with open(salida, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=sel.split(","))
w.writeheader()
for x in out:
w.writerow({k: x.get(k, "") for k in sel.split(",")})
print(f"\nExportado a: {salida}")

View File

@@ -0,0 +1,44 @@
# borrar_branding.py
# Pone en BLANCO el campo 'programa' de todos los conjuntos que tienen BRANDING
# en la tabla conjunto_sede_programa de Supabase. La SEDE se conserva.
# Ejecutar en backend/: python borrar_branding.py
import os, requests
from dotenv import load_dotenv
load_dotenv()
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
T = "conjunto_sede_programa"
H = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
assert URL and KEY, "Falta CARTERA_URL/KEY en .env"
# 1) traer todos y ver cuales tienen BRANDING
r = requests.get(f"{URL}/rest/v1/{T}", params={"select": "*"}, headers=H, timeout=30)
r.raise_for_status()
filas = r.json()
brand = [f for f in filas if "BRANDING" in str(f.get("programa") or "").upper()]
print(f"\nTotal filas: {len(filas)}")
print(f"Con BRANDING en programa: {len(brand)}")
for f in brand[:60]:
print(f" {f.get('conjunto'):45} sede={f.get('sede'):10} prog={f.get('programa')}")
if not brand:
print("\nNo hay nada con BRANDING. Listo.")
raise SystemExit
# 2) actualizar programa='' donde el programa contenga BRANDING
hp = dict(H); hp["Content-Type"] = "application/json"; hp["Prefer"] = "return=minimal"
ok = 0
for f in brand:
co = f.get("conjunto")
rr = requests.patch(f"{URL}/rest/v1/{T}",
params={"conjunto": f"eq.{co}"},
headers=hp, json={"programa": ""}, timeout=30)
if rr.ok:
ok += 1
else:
print(f" ERROR {co}: {rr.status_code} {rr.text[:80]}")
print(f"\nOK -> {ok} conjuntos actualizados (programa BRANDING borrado, sede conservada).")
print("Ahora en el dashboard: reinicia backend o dale 'Actualizar'.")

79
backend/cache_manager.py Normal file
View File

@@ -0,0 +1,79 @@
# backend/cache_manager.py
"""Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas)."""
import time
import threading
from typing import Any, Callable, Dict, Tuple
_CACHE: Dict[str, Tuple[float, Any]] = {}
_LOCK = threading.Lock()
_DEFAULT_TTL = 900 # 15 min
def _make_key(prefix: str, args: tuple) -> str:
return prefix + ":" + "|".join(str(a) for a in args)
def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], ttl: int = _DEFAULT_TTL) -> Any:
key = _make_key(prefix, args)
now = time.time()
with _LOCK:
hit = _CACHE.get(key)
if hit and (now - hit[0] < ttl):
return hit[1]
value = loader()
with _LOCK:
_CACHE[key] = (now, value)
return value
def cache_keys():
with _LOCK:
return list(_CACHE.keys())
def cache_refresh_existing(loader_for_key):
for key in cache_keys():
try:
nuevo = loader_for_key(key)
if nuevo is not None:
with _LOCK:
_CACHE[key] = (time.time(), nuevo)
except Exception as e:
print(f"[cache refresh] {key}: {e}")
def cache_invalidate(prefix: str = None) -> None:
with _LOCK:
if prefix is None:
_CACHE.clear()
else:
for k in list(_CACHE.keys()):
if k.startswith(prefix + ":"):
del _CACHE[k]
def cache_stats() -> dict:
with _LOCK:
return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())}
_background_started = False
def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 900):
global _background_started
if _background_started:
return
_background_started = True
def _loop():
while True:
time.sleep(interval)
try:
refresh_fn()
except Exception as e:
print(f"[background refresh] error: {e}")
t = threading.Thread(target=_loop, daemon=True)
t.start()
print(f"[cache] refresco en segundo plano cada {interval}s iniciado")

30
backend/carga_tiempos.log Normal file
View File

@@ -0,0 +1,30 @@
============================================================
>>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL
CARGA DEL DASHBOARD LEADS — 17/08/2026 16:57:12
============================================================
>>> Hilo 1: LEADS (PostgreSQL + SQL Server)
[carga] Plantillas Google Sheet 1.21s (9117 filas)
[carga] Leads (mensajes) PostgreSQL 21.85s (58986 filas)
[carga] Cursos SQL Server 0.00s (289 filas)
[carga] Cartera Supabase 20.88s (106389 filas)
[carga] Matriculas SQL Server 0.34s (3267 filas)
[carga] Leads asignados/respuesta PostgreSQL 4.18s (15885 filas)
[carga] Otros General 2026-8 cache 5.65s (4 filas)
[carga] Otros General 2026-7 cache 0.30s (4 filas)
>>> Hilo 2 (Cartera/Otros) LISTO.
[carga] Apartado Leads 2026-8 cache 4.06s (6 filas)
[carga] Apartado Leads 2026-7 cache 0.38s (6 filas)
[carga] Apartado Vendedores cache 2.47s (13 filas)
[carga] Apartado ROAS cache 0.00s (2 filas)
>>> Hilo 1 (Leads) LISTO.
============================================================
RESUMEN — tiempo listo para abrir cada apartado
============================================================
Leads 4.45s -> listo tras 4.4s
Vendedores 2.47s -> listo tras 2.5s
Otros General 5.96s -> listo tras 6.0s
ROAS 0.00s -> instantaneo al abrir
------------------------------------------------------------
Ya precargados: al hacer clic se muestran AL INSTANTE (cache).
============================================================

View File

@@ -0,0 +1,95 @@
# check_choque_campania.py
# Revisa si una campaña NUEVA chocaría (duplicaría leads) contra las existentes.
# Regla de choque (igual que el JOIN LIKE del dashboard):
# - una frase es sub-cadena de la otra (contención de texto), Y
# - los rangos de fecha se solapan.
# Un mensaje que caiga en ambas frases + ambas fechas => 2 filas => lead duplicado.
#
# Edita NUEVA abajo y ejecuta: python check_choque_campania.py
import os, requests
from datetime import date
from dotenv import load_dotenv
load_dotenv()
# ── CAMPAÑA NUEVA A PROBAR (edita aquí) ──
NUEVA = {
"frase": "🎟️ Me interesa el programa de Aire Acondicionado",
"inicio": "2026-08-10",
"fin": "2026-10-10",
}
# ─────────────────────────────────────────
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
def traer_todo():
filas, paso, desde = [], 1000, 0
while True:
hdr = dict(HEAD); hdr["Range-Unit"] = "items"; hdr["Range"] = f"{desde}-{desde+paso-1}"
r = requests.get(f"{URL}/rest/v1/{TABLA}", params={"select": "*", "order": "id"},
headers=hdr, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
filas.extend(data); desde += len(data)
if len(data) < paso:
break
return filas
def _d(s):
try:
return date.fromisoformat(str(s)[:10])
except Exception:
return None
def _solapan(i1, f1, i2, f2):
if not (i1 and f1 and i2 and f2):
return False
return i1 <= f2 and i2 <= f1 # rangos [i1,f1] y [i2,f2] se cruzan
def _norm(t):
return " ".join(str(t or "").lower().split())
filas = traer_todo()
ni, nf = _norm(NUEVA["frase"]), None
n_ini, n_fin = _d(NUEVA["inicio"]), _d(NUEVA["fin"])
print("\n" + "="*90)
print(f"CAMPAÑA NUEVA: \"{NUEVA['frase']}\"")
print(f"FECHAS: {NUEVA['inicio']}{NUEVA['fin']}")
print("="*90)
choques = []
for f in filas:
fr = _norm(f.get("frase_busqueda"))
if not fr:
continue
ei, ef = _d(f.get("fecha_inicio")), _d(f.get("fecha_fin"))
contiene = (fr in ni) or (ni in fr) # una es sub-cadena de la otra
fechas = _solapan(n_ini, n_fin, ei, ef)
if contiene and fechas:
choques.append((f, fr))
if not choques:
print("\n✅ SIN CHOQUES. Ninguna campaña existente comparte texto contenido")
print(" y fechas solapadas. Tu campaña NO duplicaría leads.\n")
else:
print(f"\n⚠️ {len(choques)} CHOQUE(S) DETECTADO(S) — estas campañas duplicarían leads:\n")
for f, fr in choques:
rel = "existente ⊂ nueva" if fr in ni else "nueva ⊂ existente"
print(f" id {f.get('id')} cod {f.get('codigo')} [{rel}]")
print(f" frase: \"{f.get('frase_busqueda')}\"")
print(f" fechas: {str(f.get('fecha_inicio'))[:10]}{str(f.get('fecha_fin'))[:10]}")
print()
print(" Un mensaje que contenga ambas frases dentro de estas fechas se")
print(" contaría 2 veces (2 códigos de pauta para el mismo lead).\n")
print("="*90)

271
backend/data_manager.py Normal file
View File

@@ -0,0 +1,271 @@
# backend/data_manager.py
"""
Capa de acceso a datos del módulo LEADS.
- PostgreSQL (Chatwoot): leads de pauta (consolidado en UNA sola consulta).
- SQL Server (Académico): matrículas y cursos.
- GitHub: mapa de campañas (frases -> programa/sede), config editable.
"""
import os
import json
import requests
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
class DataManager:
def __init__(self):
# PostgreSQL (Chatwoot)
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
self.pg_db = os.getenv("PG_DATABASE", "chatwoot_production")
self.pg_user = os.getenv("PG_USER", "postgres")
self.pg_pass = os.getenv("PG_PASSWORD", "")
self.pg_port = os.getenv("PG_PORT", "5432")
# SQL Server (Académico)
self.sql_server = os.getenv("SQL_SERVER", "191.98.134.80")
self.sql_db = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
self.sql_user = os.getenv("SQL_USERNAME", "")
self.sql_pass = os.getenv("SQL_PASSWORD", "")
# GitHub
base = os.getenv("GITHUB_BASE", "")
self.github_campanias_url = f"{base}/campanias.json" if base else ""
self.campanias = []
self._cargar_campanias()
# ── Conexiones ──────────────────────────────────────────────
def pg_conn(self):
import psycopg2
return psycopg2.connect(
host=self.pg_host, dbname=self.pg_db, user=self.pg_user,
password=self.pg_pass, port=self.pg_port,
)
def sql_conn(self):
import pyodbc
conn_str = (
f"DRIVER={{SQL Server}};SERVER={self.sql_server};"
f"DATABASE={self.sql_db};UID={self.sql_user};PWD={self.sql_pass}"
)
return pyodbc.connect(conn_str)
# ── Config de campañas (GitHub, opcional) ───────────────────
def _cargar_campanias(self):
if not self.github_campanias_url:
self.campanias = []
return
try:
r = requests.get(self.github_campanias_url, timeout=5)
r.raise_for_status()
data = r.json()
self.campanias = data.get("campanias", []) if isinstance(data, dict) else data
except Exception:
self.campanias = []
# ════════════════════════════════════════════════════════════
# CHATWOOT — UN SOLO QUERY CONSOLIDADO DE LEADS
# Reemplaza los 4 queries del PBI (procesados, asignados,
# contactados, etiquetas) por uno solo. El resto se calcula
# en Python (Cantidad_Veces, Ultima_Etiqueta, Estado/Objeción).
# ════════════════════════════════════════════════════════════
def traer_leads_chatwoot(self):
"""Leads de PAUTA (igual que el PBI): un mensaje cuenta como lead solo si su
texto coincide con una FRASE de campaña y está dentro del rango de fechas de
esa campaña. El mapa de campañas está incrustado (como en el Power BI)."""
sql = """
SELECT DISTINCT ON (m.id)
REGEXP_REPLACE(c.phone_number, '[^0-9]', '', 'g') AS telefono,
u.name AS asesor,
(m.created_at - INTERVAL '5 hours') AS fecha_creada,
cv.cached_label_list AS etiquetas,
map.cargo AS programa,
map.sede AS sede,
map.codigo AS codigo,
map.origen AS origen
FROM messages m
JOIN conversations cv ON m.conversation_id = cv.id
JOIN contacts c ON cv.contact_id = c.id
LEFT JOIN users u ON cv.assignee_id = u.id
JOIN (VALUES
('📚 Me interesa el', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-10-01', '2025-12-31'),
('📚 Me interesa el', 'TEAC', '227', 'Lima', 'Domingo', 'Pauta_wsp', '2026-01-15', '2026-03-30'),
('Hola! 🚨', 'TEAC', '191', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 💯 Me interesa', 'TERC', '193', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 💼', 'TEAC', '195', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
('Hola! 💸', 'TEAC', '197', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('Hola! ⚡', 'TEAC', '198', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
('Hola! 🦺', 'TERC', '199', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('Hola! 🍾', 'TEAC', '201', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('¡Hola! 📚', 'TERC', '200', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
('Hola! 🥶', 'TEAC', '202', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🎯', 'TEAC', '203', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('Hola! 🌞', 'TERC', '204', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🗿', 'TERC', '205', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('Hola! 😎', 'TEAC', '206', 'Piura', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
('Hola! 🎓', 'TEAC', '207', 'Arequipa', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-01-31'),
('Hola! 👀', 'TEAC', '208', 'Trujillo', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🎮', 'TERC', '209', 'Piura', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
('Hola! 👾', 'TERC', '210', 'Arequipa', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-02-25'),
('Hola! 🕹️', 'TERC', '211', 'Trujillo', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-04-01'),
('Hola! 🚀', 'TEAC', '212', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🛸', 'TERC', '213', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🦾', 'Amoniaco', '63a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
('Hola! 🛠️', 'Diseño Chillers', '64a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-04-30'),
('¡Hola! 🥽', 'VRF', '65a', 'Lima', '-', 'Pauta_wsp', '2025-09-01', '2026-02-28'),
('¡Hola! ❄️ Estoy interesado Seminario', 'Cámaras', '66a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-05-28'),
('¡Hola! 🤩', 'Diseño de Sistemas', '67a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
('¡Hola! 🙌🏼', 'Metrado y Costeo', '68a', 'Lima', '-', 'Pauta_wsp', '2026-02-01', '2026-12-31'),
('¡Hola! 🥽', 'VRF', '69a', 'Lima', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
(' 🧊 Más información del Seminario', 'VRF', '71a', 'Arequipa', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
(' 📐 Más información del Seminario', 'VRF', '72a', 'Trujillo', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
(' 👾 Más información del Seminario en Instalación', 'VRF', '73a', 'Piura', '-', 'Pauta_wsp', '2026-03-01', '2026-06-30'),
(' 🖥️ Estoy interesado en sus Seminarios', 'Seminarios', '74a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
(' 🙋 Deseo más info. del Diplomado ', 'Diplomado REF', '75a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
('💸 Deseo más info. del Seminario', 'CAD', '76a', 'Lima', '-', 'Pauta_wsp', '2026-04-01', '2026-07-30'),
('🦾 Estoy interesado Instalación de Sistemas', 'VRF', '77a', 'Lima', '-', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
('🙀 Estoy interesado Seminario', 'SUPER.OBRAS', '78a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
('🛠️ más info del Seminario Diseño de Chillers', 'Diseño', '79a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
('👾Estoy interesado en el Seminario Refrigeración', 'CO2', '80a', 'Lima', '-', 'Pauta_wsp', '2026-05-15', '2026-07-30'),
('Hola! 👉', 'TEAC', '214', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🤝', 'TEAC', '215', 'Lima', 'Domingo', 'Pauta_wsp', '2025-12-01', '2026-03-15'),
('🆕 Me interesa', 'TERC', '217', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🌬️', 'TEAC', '218', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🌍', 'TERC', '219', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🎟️', 'TEAC', '220', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-03-31'),
('Hola! 🌈', 'TERC', '221', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
('¡Hola! ❄️ Me interesa el programa', 'TEAC', '222', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('Hola! 🔝', 'TERC', '223', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('🔋 Me interesa', 'TEAC', '224', 'Lima', 'Sábado', 'Pauta_wsp', '2025-09-01', '2026-04-15'),
('¡Hola! 🌡️', 'TEAC', '225', 'Lima', 'Domingo', 'Pauta_wsp', '2025-09-01', '2026-12-31'),
('¡Hola! ☃️', 'TERC', '226', 'Lima', 'Sábado', 'Pauta_wsp', '2026-01-01', '2026-04-15'),
('¡Hola! 📚', 'TERC', '227', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-10'),
('¡Hola! 🗺️', 'TEAC', '228', 'Arequipa','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-05'),
('¡Hola! 🌤️', 'TEAC', '229', 'Piura', 'Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
('¡Hola! ⚡', 'TEAC', '230', 'Trujillo','Semipresencial', 'Pauta_wsp', '2026-02-01', '2026-05-07'),
('¡Hola! 🎤', 'TERC', '231', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
('¡Hola! 👾 Me interesa el programa', 'TEAC', '232', 'Lima', 'Domingo', 'Pauta_wsp', '2026-02-26', '2026-05-30'),
('¡Hola! 🍨', 'TEAC', '233', 'Lima', 'Sábado', 'Pauta_wsp', '2026-02-01', '2026-05-30'),
('¡Hola! 🐶 Me interesa', 'TEAC', '234', 'Piura', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('¡Hola! 💸 Me interesa', 'TERC', '235', 'Arequipa', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('¡Hola! 🗿 Me interesa', 'TERC', '236', 'Trujillo', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('¡Hola! 🦺 Me interesa', 'TEAC', '237', 'Lima', 'Sábado', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('¡Hola! 🍾 Me interesa', 'TEAC', '238', 'Lima', 'Domingo', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('💡 deseo más info del programa', 'TERC', '239', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-03-01', '2026-05-30'),
('¡Hola! 🎯 Me interesa el programa', 'TERC', '240', 'Lima', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
('¡Hola! 🤝 Me interesa el programa', 'TERC', '241', 'Lima', 'Sábado', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
('🪐 Me interesa el programa', 'TERC', '242', 'Piura', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-05-30'),
('🎓 Me interesa el programa', 'TEAC', '243', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
('💼 Me interesa el programa ', 'TEAC', '244', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-04-01', '2026-06-30'),
('☃️ Me interesa el programa', 'TEAC', '245', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
('🔋 Me interesa el programa', 'TERC', '246', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
('😎 Me interesa el programa', 'TEAC', '247', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
('🌈Me interesa el programa', 'TEAC', '248', 'Lima', 'Sabado', 'Pauta_wsp', '2026-04-16', '2026-07-30'),
('⭐ Me interesa el programa', 'TEAC', '249', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
('🕵️ Me interesa el programa', 'TEAC', '250', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
('📈 Me interesa el programa', 'TEAC', '251', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
('🗺️ Me interesa el programa', 'TEAC', '252', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-06', '2026-07-30'),
('🎮 Me interesa el programa', 'TEAC', '253', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-07-30'),
('🏞️ Me interesa el programa', 'TERC', '254', 'Arequipa', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
('⚙️ Me interesa el programa', 'TERC', '255', 'Piura', 'Domingo', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
('⚡ Me interesa el programa', 'TERC', '256', 'Trujillo', 'Domingo', 'Pauta_wsp', '2026-05-08', '2026-12-31'),
('🎟️ Me interesa el programa', 'TERC', '257', 'Trujillo', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
('🕑 Me interesa el', 'TERC', '258', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-01', '2026-12-31'),
('📚 Me interesa el programa', 'TEAC', '259', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
('🤗 Me interesa el programa', 'TEAC', '260', 'Lima', 'Semipresencial', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
('🕹️ Me interesa el programa', 'TERC', '261', 'Lima', 'Domingo', 'Pauta_wsp', '2026-05-11', '2026-12-31'),
('🐶 Me interesa el programa', 'TEAC', '262', 'Piura', 'Sabado', 'Pauta_wsp', '2026-05-11', '2026-08-30'),
('🍨 Me interesa el programa', 'TERC', '263', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
('⛱️ Me interesa el programa de Aire Acondicionado', 'TEAC', '264', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
('🌤️ Me interesa el programa', 'TEAC', '265', 'Arequipa', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
('⛱️ Me interesa el programa de Refrigeración comercial', 'TERC', '266', 'Lima', 'Sabado', 'Pauta_wsp', '2026-05-15', '2026-08-30'),
('¡Hola! Vengo de su web, quiero saber', 'TEAC', '0', 'Lima', '-', 'Web_Whatsapp', '2025-11-01', '2026-12-31')
) AS map(frase_busqueda, cargo, codigo, sede, dia, origen, fecha_inicio, fecha_fin)
ON m.content LIKE '%' || map.frase_busqueda || '%'
AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP)
AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day'
WHERE m.sender_type = 'Contact'
ORDER BY m.id ASC, m.created_at ASC
"""
conn = self.pg_conn()
cur = conn.cursor()
cur.execute(sql)
cols = [d[0] for d in cur.description]
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
return filas
# ════════════════════════════════════════════════════════════
# SQL SERVER — CURSOS (igual que el PBI: Fact_SQL_Base_Cursos)
# ════════════════════════════════════════════════════════════
def traer_cursos(self):
sql = """
SELECT
rp.num_indice,
rp.dsc_det_programa,
p.dsc_programa,
rp.cod_frecuencia,
rp.fch_inicio,
rp.cod_estado,
(SELECT COUNT(*) FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
AND m.cod_estado NOT IN ('ANU')) AS Inscritos_Totales,
(SELECT COUNT(*) FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
AND m.cod_estado = 'RET') AS Inscritos_Retirados,
(SELECT COUNT(*) FROM sgeca_matricula m
WHERE m.cod_periodo = rp.cod_detalle AND m.num_indice = rp.num_indice
AND m.cod_estado NOT IN ('ANU','RET','SUS')) AS Inscritos_Activos
FROM sgede_RP_programa rp
INNER JOIN sgeca_programa p ON rp.cod_programa = p.cod_programa
WHERE YEAR(rp.fch_inicio) IN (2025, 2026)
ORDER BY rp.fch_inicio ASC
"""
conn = self.sql_conn()
cur = conn.cursor()
cur.execute(sql)
cols = [d[0] for d in cur.description]
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
return filas
# ════════════════════════════════════════════════════════════
# SQL SERVER — MATRÍCULAS (Fact_SQL_Base_Matriculas, resumido)
# ════════════════════════════════════════════════════════════
def traer_matriculas(self):
sql = """
SELECT
sgeca_matricula.num_matricula,
sgeca_matricula.num_indice,
sgeca_matricula.fch_matricula,
sgeca_matricula.cod_estado AS estado_matricula,
REPLACE(sgema_alumno.dsc_telefono_1,' ','') AS dsc_telefono_1,
sgeca_programa.dsc_programa,
sgede_RP_programa.dsc_det_programa AS dsc_promocion,
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1
FROM sgeca_matricula
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
AND sgeca_matricula.fch_matricula BETWEEN '01-11-2024 00:00:00.000' AND '31-12-2026 23:59:00.000'
AND sgeca_matricula.cod_estado <> 'ANU'
"""
conn = self.sql_conn()
cur = conn.cursor()
cur.execute(sql)
cols = [d[0] for d in cur.description]
filas = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
return filas
# --- fin data_manager ---

1040
backend/data_manager_v2.py Normal file

File diff suppressed because it is too large Load Diff

30
backend/diag_asesores.py Normal file
View File

@@ -0,0 +1,30 @@
# diag_asesores.py — Valores UNICOS de la columna 'asesor' en cartera_junta.
import os, requests
from collections import Counter
from dotenv import load_dotenv
load_dotenv()
url = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
key = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
t = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
h = {"apikey": key, "Authorization": f"Bearer {key}"}
# traer solo la columna asesor (paginado)
cont = Counter()
desde, paso = 0, 1000
while True:
r = requests.get(f"{url}/rest/v1/{t}", params={"select": "asesor", "offset": str(desde), "limit": str(paso)},
headers=h, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
for row in data:
cont[(row.get("asesor") or "").strip()] += 1
if len(data) < paso:
break
desde += paso
print(f"Valores distintos de 'asesor' en cartera_junta: {len(cont)}\n")
for val, n in cont.most_common():
print(f" {n:>6} {val or '(vacio)'}")

View File

@@ -0,0 +1,37 @@
# diag_asignados_diana_hoy.py
# Leads ASIGNADOS a DIANA el dia 13 (hoy) de agosto 2026, con telefono, hora y
# si respondio o no. Fuente: mensajes de asignacion de Chatwoot.
from datetime import datetime
from data_manager_v2 import DataManager
ANO, MES, DIA = 2026, 8, 13
NOMBRE = "DIANA"
dm = DataManager()
filas = []
for r in dm.traer_asignacion_respuesta():
if NOMBRE not in (r.get("user_name") or "").upper():
continue
t1 = r.get("created_at")
if not isinstance(t1, datetime):
continue
if t1.year == ANO and t1.month == MES and t1.day == DIA:
filas.append(r)
print(f"\n=== DIANA — leads ASIGNADOS el {DIA:02d}/{MES:02d}/{ANO} ===\n")
print(f"Total asignados hoy: {len(filas)}\n")
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} estado")
print("-"*55)
resp = 0; sinr = 0
for i, r in enumerate(sorted(filas, key=lambda x: x['created_at']), 1):
t1 = r['created_at']; t2 = r.get('respuesta_fecha')
tel = r.get('telefono', '')
if isinstance(t2, datetime):
resp += 1
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} respondio")
else:
sinr += 1
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} SIN RESPONDER")
print("-"*55)
print(f"Respondidos: {resp} | Sin responder: {sinr} | Total: {len(filas)}")

86
backend/diag_base.json Normal file
View File

@@ -0,0 +1,86 @@
{
"2026|2|TODOS|TODOS|TODOS": {
"leads_recibidos": 6186,
"leads_procesados": 1489,
"total_matriculados": 117,
"cursos_programados": 7,
"pauta_total_imp": 4255.16,
"always_total_imp": 1730.51,
"webform_total_rec": 151,
"webform_total_matr": 15,
"asignados_total": 1390,
"matriz_cursos_filas": 6
},
"2026|2|TODOS|TEAC|LIMA": {
"leads_recibidos": 2198,
"leads_procesados": 499,
"total_matriculados": 53,
"cursos_programados": 3,
"pauta_total_imp": 4255.16,
"always_total_imp": 810.03,
"webform_total_rec": 94,
"webform_total_matr": 9,
"asignados_total": 1390,
"matriz_cursos_filas": 2
},
"2026|2|TODOS|TERC|AREQUIPA": {
"leads_recibidos": 325,
"leads_procesados": 64,
"total_matriculados": 10,
"cursos_programados": 1,
"pauta_total_imp": 4255.16,
"always_total_imp": 0,
"webform_total_rec": 5,
"webform_total_matr": 0,
"asignados_total": 1390,
"matriz_cursos_filas": 1
},
"2026|1|TODOS|TODOS|TODOS": {
"leads_recibidos": 7417,
"leads_procesados": 2370,
"total_matriculados": 128,
"cursos_programados": 9,
"pauta_total_imp": 4499.9,
"always_total_imp": 776.0,
"webform_total_rec": 143,
"webform_total_matr": 15,
"asignados_total": 1529,
"matriz_cursos_filas": 5
},
"2026|1|TODOS|SEMINARIOS|LIMA": {
"leads_recibidos": 946,
"leads_procesados": 342,
"total_matriculados": 22,
"cursos_programados": 1,
"pauta_total_imp": 4499.9,
"always_total_imp": 0,
"webform_total_rec": 1,
"webform_total_matr": 0,
"asignados_total": 1529,
"matriz_cursos_filas": 0
},
"2026|7|TODOS|TODOS|TODOS": {
"leads_recibidos": 1664,
"leads_procesados": 1435,
"total_matriculados": 59,
"cursos_programados": 16,
"pauta_total_imp": 2212.41,
"always_total_imp": 1540.57,
"webform_total_rec": 34,
"webform_total_matr": 1,
"asignados_total": 2275,
"matriz_cursos_filas": 16
},
"2026|7|TODOS|TEAC|PIURA": {
"leads_recibidos": 104,
"leads_procesados": 102,
"total_matriculados": 1,
"cursos_programados": 1,
"pauta_total_imp": 2212.41,
"always_total_imp": 172.48,
"webform_total_rec": 0,
"webform_total_matr": 0,
"asignados_total": 2275,
"matriz_cursos_filas": 1
}
}

38
backend/diag_base.py Normal file
View File

@@ -0,0 +1,38 @@
# diag_base.py — Captura los numeros ACTUALES (antes de optimizar) para comparar despues.
import json
import services as S
combos = [
("2026","2","TODOS","TODOS","TODOS"),
("2026","2","TODOS","TEAC","LIMA"),
("2026","2","TODOS","TERC","AREQUIPA"),
("2026","1","TODOS","TODOS","TODOS"),
("2026","1","TODOS","SEMINARIOS","LIMA"),
("2026","7","TODOS","TODOS","TODOS"),
("2026","7","TODOS","TEAC","PIURA"),
]
def resumen(d):
k = d["kpis"]
return {
"leads_recibidos": k["leads_recibidos"],
"leads_procesados": k["leads_procesados"],
"total_matriculados": k["total_matriculados"],
"cursos_programados": k["cursos_programados"],
"pauta_total_imp": d["tabla_pauta"]["total"].get("importe"),
"always_total_imp": d["matriz_always"]["total"]["importe"],
"webform_total_rec": d["matriz_webform"]["total"]["recibidos"],
"webform_total_matr": d["matriz_webform"]["total"]["matriculas"],
"asignados_total": d["matriz_asignados"]["total"],
"matriz_cursos_filas": len(d["matriz_cursos"]["filas"]),
}
out = {}
for c in combos:
d = S.leads_dashboard(*c)
out["|".join(c)] = resumen(d)
print("|".join(c), "->", out["|".join(c)])
with open("diag_base.json", "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
print("\nGuardado: diag_base.json (linea base ANTES de optimizar)")

View File

@@ -0,0 +1,58 @@
# diag_buscar_numero.py - Rastrea un telefono en cartera_junta: como y donde se cuenta.
# Ejecutar en backend/: python diag_buscar_numero.py
import os, requests
from dotenv import load_dotenv
load_dotenv()
TELEFONO = "18099166706" # <-- numero a buscar (tal cual y normalizado)
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
T = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def _norm(v):
"""Misma normalizacion de la cartera: quita +51/+ y el 51 inicial de celulares PE."""
s = str(v or "")
for x in ("+51","+"," ","-","(",")"): s=s.replace(x,"")
s=s.strip()
if s.isdigit() and 51900000000 <= int(s) <= 51999999999: s=s[2:]
return s
# variantes a buscar: el numero tal cual y su version normalizada
variantes = {TELEFONO, _norm(TELEFONO)}
print(f"Buscando telefono: {TELEFONO} (variantes: {variantes})\n")
encontrado = False
for tel in variantes:
r = requests.get(f"{URL}/rest/v1/{T}",
params={"select":"*", "telefono": f"eq.{tel}"},
headers=HEAD, timeout=60)
r.raise_for_status()
filas = r.json()
if not filas:
continue
encontrado = True
print(f"=== {len(filas)} fila(s) con telefono = {tel} ===")
for i,f in enumerate(filas,1):
print(f"\n Fila {i}:")
for campo in ["telefono","es_origen","canal","origen_base","asesor","sede",
"programa","codigo","fecha_creada","solo_contacto"]:
if campo in f:
print(f" {campo:14}= {f.get(campo)}")
# mostrar cualquier otro campo extra
otros=[k for k in f.keys() if k not in
("telefono","es_origen","canal","origen_base","asesor","sede",
"programa","codigo","fecha_creada","solo_contacto")]
for k in otros:
print(f" {k:14}= {f.get(k)}")
if not encontrado:
print("El telefono NO aparece en cartera_junta (ni tal cual ni normalizado).")
print("=> No se esta contando en ninguna pauta/origen de la cartera.")
else:
print("\n--- Como leer esto ---")
print(" es_origen=SI -> esta fila es la que CUENTA como origen del telefono.")
print(" canal -> COPITO / PAUTA_WSP / WEB_FORMULARIO / etc.")
print(" origen_base -> POSTGRE (Chatwoot) o SUPABASE (tu Excel).")
print(" codigo -> la pauta/campania a la que esta asociado (si tiene).")

View File

@@ -0,0 +1,61 @@
"""
Diagnostico: revisa la tabla 'campanias' y detecta cruces = misma frase_busqueda
(mismo emoji/texto) con rangos de fecha [fecha_inicio, fecha_fin] que se SOLAPAN.
Un mensaje que caiga en el solape haria match con 2 campanias a la vez.
"""
import data_manager_v2 as dm
import leads_logic as L
# _campanias_supabase() trae tuplas (frase, cargo, codigo, sede, dia, origen, fi, ff)
crudas = dm._campanias_supabase()
campanias = [{"frase_busqueda": t[0], "cargo": t[1], "codigo": t[2], "sede": t[3],
"dia": t[4], "origen": t[5], "fecha_inicio": t[6], "fecha_fin": t[7]}
for t in crudas]
print(f"Total campanias: {len(campanias)}\n")
# agrupar por frase_busqueda
por_frase = {}
for c in campanias:
frase = " ".join(str(c.get("frase_busqueda") or "").split())
por_frase.setdefault(frase, []).append(c)
def _rango(c):
fi = L._to_date(str(c.get("fecha_inicio") or "")[:10])
ff = L._to_date(str(c.get("fecha_fin") or "")[:10])
return fi, ff
cruces = 0
print("=== CRUCES (misma frase + fechas solapadas) ===\n")
for frase, lista in por_frase.items():
if len(lista) < 2:
continue
# comparar cada par
for i in range(len(lista)):
for j in range(i + 1, len(lista)):
a, b = lista[i], lista[j]
fia, ffa = _rango(a)
fib, ffb = _rango(b)
if not (fia and ffa and fib and ffb):
continue
# solapan si a.inicio <= b.fin y b.inicio <= a.fin
if fia <= ffb and fib <= ffa:
cruces += 1
print(f"FRASE: {frase!r}")
print(f" A) cod={a.get('codigo')} sede={a.get('sede')} cargo={a.get('cargo')} "
f"[{fia} -> {ffa}]")
print(f" B) cod={b.get('codigo')} sede={b.get('sede')} cargo={b.get('cargo')} "
f"[{fib} -> {ffb}]")
print(f" SOLAPE: {max(fia,fib)} -> {min(ffa,ffb)}\n")
if cruces == 0:
print("No se encontraron cruces (ninguna frase repetida con fechas solapadas).")
else:
print(f"TOTAL de cruces detectados: {cruces}")
# tambien listar frases repetidas (aunque no solapen) por si acaso
print("\n=== Frases que se repiten en varias campanias ===")
rep = {f: l for f, l in por_frase.items() if len(l) > 1}
for f, l in rep.items():
print(f" {len(l)}x {f!r}")
if not rep:
print(" (ninguna frase repetida)")

28
backend/diag_canal.py Normal file
View File

@@ -0,0 +1,28 @@
# diag_canal.py — Revisa num_indice 1161: pauta en basebi vs conjuntos.
import services as S
NI = "1161"
pm = S._pauta_cruda() # {num_indice: {pauta, estado, contar}}
cp = S._conjunto_pauta() # {pauta: [conjuntos]}
info = pm.get(NI)
print(f"num_indice {NI} en basebi_programacion:", info)
cod = info.get("pauta") if info else None
print(f" -> pauta (cod): {cod!r}")
print(f" -> conjuntos por esa pauta: {cp.get(str(cod).strip()) if cod else '(sin cod)'}")
# Buscar en la matriz esa fila
d = S.leads_dashboard("2026","5","TODOS","TODOS","TODOS")["matriz_cursos"]
for c in d["filas"]:
if str(c["num_indice"]) == NI:
print(f"\nEn la matriz -> pauta={c['pauta']!r} conjuntos={c.get('conjuntos')}")
break
else:
print(f"\n(num_indice {NI} no esta en la matriz de mayo)")
# Buscar en conjunto_pauta si algun conjunto tiene ese nombre TEAC_TRUJILLO_MAY...
print("\nBuscando conjunto 'TEAC_TRUJILLO_MAY' en conjunto_pauta:")
for pauta, cjs in cp.items():
for cj in cjs:
if "TEAC_TRUJILLO_MAY" in str(cj).upper():
print(f" pauta={pauta!r} -> {cj}")

15
backend/diag_cart.py Normal file
View File

@@ -0,0 +1,15 @@
import services as S
from collections import Counter
rows = S._cartera_rows_cache()
print("TOTAL filas cartera:", len(rows))
si = [r for r in rows if str(r.get("es_origen") or "").strip().upper() == "SI"]
print("Filas es_origen=SI:", len(si))
print("Valores es_origen:", Counter(str(r.get("es_origen")) for r in rows).most_common(10))
ase = S._asesores(); amap = ase["map"]
cnt_si = sum(1 for r in si if S._norm_asesor(r.get("asesor"), amap) == "ALMENDRA PERALTA")
cnt_all = sum(1 for r in rows if S._norm_asesor(r.get("asesor"), amap) == "ALMENDRA PERALTA")
print("Almendra en es_origen=SI :", cnt_si)
print("Almendra en TODA cartera :", cnt_all)

62
backend/diag_caso_469.py Normal file
View File

@@ -0,0 +1,62 @@
# diag_caso_469.py - Investiga un telefono puntual: asignacion, respuesta, y
# actividad de la asesora ese dia (para saber si estuvo o no de descanso).
from datetime import datetime
from data_manager_v2 import DataManager
TEL = "923306769" # telefono a investigar
NOMBRE = "DIANA"
DIA_OBJ = (2026, 8, 10)
dm = DataManager()
# 1) Todos los mensajes de ese contacto (asignaciones y respuestas), en hora Peru
conn = dm.pg_conn(); cur = conn.cursor()
cur.execute("""
SELECT (m.created_at - INTERVAL '5 hours') AS fecha, m.id, m.sender_id, m.sender_type,
LEFT(m.content, 70)
FROM messages m
JOIN conversations cv ON m.conversation_id = cv.id
JOIN contacts c ON cv.contact_id = c.id
WHERE REPLACE(REPLACE(c.phone_number,'+51',''),'+','') LIKE %s
ORDER BY m.id ASC
""", ('%'+TEL,))
rows = cur.fetchall()
print(f"\n=== Mensajes del contacto {TEL} (hora Peru) ===\n")
print(f"{'fecha':20} {'msg_id':>9} {'sender_id':>9} {'tipo':8} contenido")
print("-"*90)
for fecha, mid, sid, stype, cont in rows:
marca = " <- ASESOR" if (sid is not None and 15 <= sid <= 25) else ""
stype = str(stype or "-"); sid_s = str(sid if sid is not None else "-")
print(f"{str(fecha):26} {str(mid):>9} {sid_s:>9} {stype:8} {str(cont or '')[:40]}{marca}")
# 2) Que trajo el query de asignacion+respuesta para este telefono
print(f"\n=== Lo que el dashboard tomo como T1 (asignacion) y T2 (respuesta) ===")
for r in dm.traer_asignacion_respuesta():
if str(r.get('telefono','')).endswith(TEL[-9:]):
print(f" T1 asignacion = {r.get('created_at')}")
print(f" T2 respuesta = {r.get('respuesta_fecha')}")
print(f" asesor = {r.get('user_name')}")
# 3) Actividad de Diana ese dia (para saber si trabajo o descanso)
print(f"\n=== Actividad de {NOMBRE} el {DIA_OBJ[2]:02d}/{DIA_OBJ[1]:02d}/{DIA_OBJ[0]} (mensajes enviados) ===")
asig = dm.traer_asignacion_respuesta()
uid_diana = None
for r in asig:
if NOMBRE in (r.get('user_name') or '').upper() and r.get('user_id') is not None:
uid_diana = r['user_id']; break
if uid_diana is None:
print(" (no se hallo user_id de Diana)")
else:
hrs = []
for a in dm.traer_actividad_asesores():
ts = a.get('created_at')
if a.get('user_id')==uid_diana and isinstance(ts,datetime) and \
(ts.year,ts.month,ts.day)==DIA_OBJ:
hrs.append(ts.strftime('%H:%M'))
if not hrs:
print(f" SIN actividad ese dia -> probablemente DESCANSO / no trabajo.")
else:
hrs.sort()
print(f" {len(hrs)} mensajes. Primera: {hrs[0]} Ultima: {hrs[-1]}")
print(f" (si hay actividad en la tarde 13-18 = turno 1; si en la noche = turno 2)")
conn.close()

View File

@@ -0,0 +1,53 @@
# diag_comparar_asignados_diana.py
# Compara, para DIANA en un mes, los asignados por dia entre:
# FUENTE A = traer_leads_asignados (la de la TABLA de Otros General)
# FUENTE B = traer_asignacion_respuesta (la de la GRAFICA de Vendedores)
# Muestra el conteo por dia y que telefonos difieren.
from datetime import datetime
from data_manager_v2 import DataManager
ANO, MES = 2026, 8
NOMBRE = "DIANA"
dm = DataManager()
def _to_date(v):
if isinstance(v, datetime): return v.date()
return None
# FUENTE A: tabla Otros General
A = {} # dia -> set(telefonos)
for r in dm.traer_leads_asignados():
nom = (r.get("user_name") or "")
if NOMBRE not in nom.upper(): continue
f = _to_date(r.get("created_at"))
if not f or f.year!=ANO or f.month!=MES: continue
A.setdefault(f.day, set()).add(str(r.get("telefono") or "").strip())
# FUENTE B: grafica Vendedores
B = {} # dia -> set(telefonos)
for r in dm.traer_asignacion_respuesta():
nom = (r.get("user_name") or "")
if NOMBRE not in nom.upper(): continue
f = _to_date(r.get("created_at"))
if not f or f.year!=ANO or f.month!=MES: continue
B.setdefault(f.day, set()).add(str(r.get("telefono") or "").strip())
dias = sorted(set(list(A.keys())+list(B.keys())))
print(f"\n=== DIANA — asignados por dia: TABLA(A) vs GRAFICA(B) — {MES}/{ANO} ===\n")
print(f"{'DIA':>4} {'TABLA(A)':>9} {'GRAFICA(B)':>11} {'DIF':>5}")
print("-"*34)
for d in dias:
a=len(A.get(d,set())); b=len(B.get(d,set()))
marca = " <==" if a!=b else ""
print(f"{d:>4} {a:>9} {b:>11} {a-b:>5}{marca}")
print(f"\n=== Detalle de los dias que DIFIEREN ===")
for d in dias:
sa=A.get(d,set()); sb=B.get(d,set())
if sa==sb: continue
solo_A = sa - sb # estan en tabla pero NO en grafica
solo_B = sb - sa # estan en grafica pero NO en tabla
print(f"\n DIA {d}:")
if solo_A: print(f" solo en TABLA (A), no en grafica: {sorted(solo_A)}")
if solo_B: print(f" solo en GRAFICA (B), no en tabla: {sorted(solo_B)}")

View File

@@ -0,0 +1,57 @@
"""
Diagnostico: contactos que SI tienen mensajes, pero NINGUNO de campaña.
Muestra sus mensajes (content) para revisar por que no hicieron match.
"""
import os
import psycopg2
from dotenv import load_dotenv
import data_manager_v2 as dm # reutiliza las frases de campaña
load_dotenv()
PG_HOST = os.getenv("PG_HOST")
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
PG_USER = os.getenv("PG_USER")
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
PG_PORT = os.getenv("PG_PORT", "5432")
FRASES = [c[0] for c in dm._campanias_supabase()]
likes = " OR ".join(["m.content LIKE %s"] * len(FRASES))
params = [f"%{fr}%" for fr in FRASES]
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
password=PG_PASS, port=PG_PORT, connect_timeout=30)
cur = conn.cursor()
# Buscar contactos con >=1 mensaje pero 0 de campaña (traemos algunos pocos)
sql = f"""
SELECT c.id, c.phone_number, c.created_at,
COUNT(m.id) AS total_msjes,
SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) AS msjes_campania
FROM contacts c
JOIN conversations cv ON cv.contact_id = c.id
JOIN messages m ON m.conversation_id = cv.id AND m.sender_type = 'Contact'
GROUP BY c.id, c.phone_number, c.created_at
HAVING COUNT(m.id) >= 1 AND SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) = 0
ORDER BY c.created_at DESC
LIMIT 5
"""
cur.execute(sql, params + params)
contactos = cur.fetchall()
for cid, ph, creado, tot, camp in contactos:
print("=" * 80)
print(f"CONTACTO id={cid} tel={ph} creado={creado} #msjes={tot} campaña={camp}")
# Mostrar sus mensajes (content) del contacto
cur.execute("""
SELECT m.created_at, LEFT(m.content, 90)
FROM messages m
JOIN conversations cv ON m.conversation_id = cv.id
WHERE cv.contact_id = %s AND m.sender_type = 'Contact'
ORDER BY m.created_at ASC
LIMIT 10
""", (cid,))
for fecha, txt in cur.fetchall():
print(f" [{fecha}] {txt!r}")
conn.close()

43
backend/diag_content.py Normal file
View File

@@ -0,0 +1,43 @@
# diag_content.py — Revisa que valores toma content_attributes (para replicar ISBLANK).
# Ejecutar dentro de backend/: python diag_content.py
from data_manager_v2 import DataManager
dm = DataManager()
conn = dm.pg_conn()
cur = conn.cursor()
# Distribucion de content_attributes en los mensajes de plantilla
sql = """
SELECT
CASE
WHEN m.content_attributes IS NULL THEN '(NULL)'
WHEN m.content_attributes::text = '{}' THEN '(vacio {})'
WHEN m.content_attributes::text = 'null' THEN "(texto 'null')"
ELSE 'CON DATOS'
END AS tipo,
COUNT(*) AS n
FROM messages m
WHERE m.sender_type = 'User'
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC
"""
cur.execute(sql)
print("== content_attributes en mensajes de plantilla ==")
for tipo, n in cur.fetchall():
print(f" {tipo:16} {n}")
# Muestra 3 ejemplos de cada tipo con datos
print("\n== ejemplos de content_attributes CON DATOS (primeros 3) ==")
cur.execute("""
SELECT m.additional_attributes -> 'template_params' ->> 'name', m.content_attributes::text
FROM messages m
WHERE m.sender_type='User'
AND m.additional_attributes -> 'template_params' ->> 'name' IS NOT NULL
AND m.content_attributes IS NOT NULL
AND m.content_attributes::text NOT IN ('{}','null')
LIMIT 3
""")
for nombre, ca in cur.fetchall():
print(f" {nombre}: {ca[:120]}")
conn.close()

View File

@@ -0,0 +1,32 @@
"""
Diagnostico: para num_indices dados, muestra las columnas de la matriz
'Detalle por Curso' del apartado LEADS (importe pauta, cartera, leads, matriculas...).
Filtro por defecto: TODOS (sin mes). Cambia ANO/MES/DIA si quieres un periodo.
"""
import services as S
NUM_INDICES = ["1157", "1174"]
ANO, MES, DIA = "TODOS", "TODOS", "TODOS" # cambia si quieres filtrar (ej "2026","7","TODOS")
d = S.leads_dashboard(ANO, MES, DIA, "TODOS", "TODOS")
matriz = d.get("matriz_cursos", {})
filas = matriz.get("filas", []) if isinstance(matriz, dict) else matriz
def _ni(v):
s = str(v or "").strip()
return s[:-2] if s.endswith(".0") else s
objetivo = set(NUM_INDICES)
encontrados = [f for f in filas if _ni(f.get("num_indice")) in objetivo]
if not encontrados:
print("No se encontraron esos num_indice en la matriz. num_indices disponibles (muestra):")
print([_ni(f.get("num_indice")) for f in filas[:40]])
else:
for f in encontrados:
print("=" * 70)
print(f"NUM_INDICE: {_ni(f.get('num_indice'))} | {f.get('personalizado') or f.get('programa') or ''}")
print("-" * 70)
for k, v in f.items():
print(f" {k:32}: {v}")
print()

110
backend/diag_dayana_6_7.py Normal file
View File

@@ -0,0 +1,110 @@
# diag_dayana_6_7.py
# Dias 6 y 7 de AGOSTO 2026 de DAYANA: turno, tiempo asignacion, respuesta y minutos.
# min = tiempo LABORAL. Los del refrigerio (13-15 turno1) llevan -45 si superan 45 (marcado).
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES = 2026, 8
DIAS = [6, 7]
NOMBRE = "DAYANA"
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid=a.get("user_id"); ts=a.get("created_at")
if uid not in uid2name or not isinstance(ts,datetime): continue
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
if d.weekday()==6: continue
if d.weekday()==5:
if 9<=h<13: act[key]["sab_manana"]+=1
elif 14<=h<18: act[key]["sab_tarde"]+=1
else:
if 13<=h<18: act[key]["tarde"]+=1
elif 18<=h<22: act[key]["noche"]+=1
def turno_dia(nom,d):
if d.weekday()==6: return None
fr=act.get((nom,d))
if not fr: return None
if d.weekday()==5:
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t=fr.get("tarde",0); n=fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def es_t1(nom,d):
fr=act.get((nom,d))
if not fr or d.weekday()>=5: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
return (not (n>t)) and (t>0 or n>0)
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
def mins_laborales(nom,t1,t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; s=0
while cur<t2 and s<60:
d=cur.date(); bl=turno_dia(nom,d)
if not bl: cur=_dt(d,24); s+=1; continue
for (hi,hf) in bl:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total+=(b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d: cur=_dt(d,24); s+=1
return round(total)
for DIA in DIAS:
d1=date(ANO,MES,DIA)
filas=[]
for r in asig:
if NOMBRE not in (r.get("user_name") or "").upper(): continue
t1=r.get("created_at")
if isinstance(t1,datetime) and t1.year==ANO and t1.month==MES and t1.day==DIA:
filas.append(r)
nom_up=(filas[0].get("user_name") or "").upper() if filas else NOMBRE
tt=turno_dia(nom_up,d1)
if tt is None: turno="LIBRE/DESCANSO"
elif tt==[(9,13),(14,18)]: turno="TURNO 1 (9-18)"
elif tt==[(9,13),(18,22)]: turno="TURNO 2 (9-13+18-22)"
else: turno=str(tt)
print(f"\n{'='*70}")
print(f"DAYANA — {DIA:02d}/{MES:02d}/{ANO} -> {turno} (asignados: {len(filas)})")
print(f"{'='*70}")
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} {'min':>5} nota")
print("-"*60)
resp=0; sinr=0; suma=0
for i,r in enumerate(sorted(filas,key=lambda x:x['created_at']),1):
t1=r['created_at']; t2=r.get('respuesta_fecha'); tel=r.get('telefono','')
h=t1.hour+t1.minute/60.0
refri = es_t1(nom_up,d1) and (13<=h<15)
if not isinstance(t2,datetime):
sinr+=1
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} {'':>5}")
continue
if refri:
dif=(t2-t1).total_seconds()/60.0
m=round(dif-45) if dif>45 else round(dif)
if m<0: m=0
nota=" refrig(-45 si >45)"
else:
m=mins_laborales(nom_up,t1,t2); nota=""
resp+=1; suma+=m
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} {m:>5}{nota}")
print("-"*60)
print(f"Respondidos: {resp} | Sin responder: {sinr} | Promedio: {round(suma/resp) if resp else 0} min")

View File

@@ -0,0 +1,102 @@
# diag_dayana_grafica_6_7.py
# Replica EXACTA de las dos series del grafico para DAYANA dias 6 y 7:
# NARANJA = por fecha de ASIGNACION (todos, con -45 refrigerio)
# AZUL = por fecha de RESPUESTA (solo respondidos, -45 refrigerio, y EXCLUYE >960 min)
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES = 2026, 8
DIAS = [6, 7]
NOMBRE = "DAYANA"
LIMITE = 960
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {r["user_id"]: (r.get("user_name") or "") for r in asig if r.get("user_id") is not None}
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid=a.get("user_id"); ts=a.get("created_at")
if uid not in uid2name or not isinstance(ts,datetime): continue
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
if d.weekday()==6: continue
if d.weekday()==5:
if 9<=h<13: act[key]["sab_manana"]+=1
elif 14<=h<18: act[key]["sab_tarde"]+=1
else:
if 13<=h<18: act[key]["tarde"]+=1
elif 18<=h<22: act[key]["noche"]+=1
def turno_dia(nom,d):
if d.weekday()==6: return None
fr=act.get((nom,d))
if not fr: return None
if d.weekday()==5:
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t=fr.get("tarde",0); n=fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def es_t1(nom,d):
fr=act.get((nom,d))
if not fr or d.weekday()>=5: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
return (not (n>t)) and (t>0 or n>0)
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
def mins_lab(nom,t1,t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; s=0
while cur<t2 and s<60:
d=cur.date(); bl=turno_dia(nom,d)
if not bl: cur=_dt(d,24); s+=1; continue
for (hi,hf) in bl:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total+=(b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d: cur=_dt(d,24); s+=1
return round(total)
def calc_min(nom,t1,t2,d1):
h=t1.hour+t1.minute/60.0
if es_t1(nom,d1) and (13<=h<15):
dif=(t2-t1).total_seconds()/60.0
m=round(dif-45) if dif>45 else round(dif)
return max(m,0)
return mins_lab(nom,t1,t2)
for DIA in DIAS:
d1=date(ANO,MES,DIA)
print(f"\n{'#'*72}")
print(f"DAYANA — DIA {DIA:02d}/{MES:02d}/{ANO}")
print(f"{'#'*72}")
# ---- AZUL: respondidos ese dia (excluye >960) ----
print(f"\n>>> GRAFICO AZUL (por fecha de RESPUESTA): respondidos el {DIA} (excluye >960 min)")
print(f"{'telefono':13} {'asignado':14} {'respondio':14} {'min':>6} nota")
print("-"*60)
az=[]; tard=0
for r in asig:
if NOMBRE not in (r.get("user_name") or "").upper(): continue
t1=r.get("created_at"); t2=r.get("respuesta_fecha")
if not isinstance(t1,datetime) or not isinstance(t2,datetime): continue
if t2.date()!=d1: continue
m=calc_min((r.get('user_name') or '').upper(),t1,t2,t1.date())
nota = " (asignado otro dia)" if t1.date()!=d1 else ""
if m>LIMITE:
tard+=1
print(f"{r.get('telefono',''):13} {t1.strftime('%d/%m %H:%M'):14} {t2.strftime('%d/%m %H:%M'):14} {m:>6} TARDIO(>960) fuera{nota}")
continue
az.append(m)
print(f"{r.get('telefono',''):13} {t1.strftime('%d/%m %H:%M'):14} {t2.strftime('%d/%m %H:%M'):14} {m:>6}{nota}")
print("-"*60)
print(f" {len(az)} respondidos | promedio {round(sum(az)/len(az)) if az else 0} min | tardios(>960): {tard}")

View File

@@ -0,0 +1,114 @@
# diag_diana_8_9_10.py
# Dias 8, 9 y 10 de AGOSTO 2026 de DIANA: turno detectado (o LIBRE) y tiempos.
# Aplica la regla del refrigerio (13-15 turno1: diferencia directa, -45 si supera 45).
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES = 2026, 8
DIAS = [8, 9, 10]
NOMBRE = "DIANA"
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid = a.get("user_id"); ts = a.get("created_at")
if uid not in uid2name or not isinstance(ts, datetime): continue
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
key=(nom,d)
if d.weekday()==6: continue
if d.weekday()==5:
if 9<=h<13: act[key]["sab_manana"]+=1
elif 14<=h<18: act[key]["sab_tarde"]+=1
else:
if 13<=h<18: act[key]["tarde"]+=1
elif 18<=h<22: act[key]["noche"]+=1
def turno_dia(nom,d):
if d.weekday()==6: return None
fr=act.get((nom,d))
if not fr: return None
if d.weekday()==5:
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t=fr.get("tarde",0); n=fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def es_t1(nom,d):
fr=act.get((nom,d))
if not fr or d.weekday()>=5: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
return (not (n>t)) and (t>0 or n>0)
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
def mins_laborales(nom,t1,t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; s=0
while cur<t2 and s<60:
d=cur.date(); bl=turno_dia(nom,d)
if not bl: cur=_dt(d,24); s+=1; continue
for (hi,hf) in bl:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total+=(b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d: cur=_dt(d,24); s+=1
return round(total)
for DIA in DIAS:
d1=date(ANO,MES,DIA)
filas=[]
for r in asig:
if NOMBRE not in (r.get("user_name") or "").upper(): continue
t1=r.get("created_at")
if not isinstance(t1,datetime): continue
if t1.year==ANO and t1.month==MES and t1.day==DIA: filas.append(r)
# turno del dia (por actividad de Diana)
nom_up = (filas[0].get("user_name") or "").upper() if filas else NOMBRE
tt=turno_dia(nom_up,d1)
if tt is None:
turno="LIBRE / DESCANSO (sin actividad ese dia)"
elif tt==[(9,13),(14,18)]: turno="TURNO 1 (9-18)"
elif tt==[(9,13),(18,22)]: turno="TURNO 2 (9-13 + 18-22)"
else: turno=str(tt)
print(f"\n{'='*66}")
print(f"DIANA — {DIA:02d}/{MES:02d}/{ANO} -> {turno}")
print(f"Total asignados: {len(filas)}")
print(f"{'='*66}")
print(f"{'#':3} {'telefono':13} {'asignado':11} {'respondio':11} {'min':>5} nota")
print("-"*60)
resp=0; sinr=0; suma=0
for i,r in enumerate(sorted(filas,key=lambda x:x['created_at']),1):
t1=r['created_at']; t2=r.get('respuesta_fecha'); tel=r.get('telefono','')
h=t1.hour+t1.minute/60.0
refri = es_t1(nom_up,d1) and (13<=h<15)
if not isinstance(t2,datetime):
sinr+=1
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {'':11} {'':>5}")
continue
if refri:
dif=(t2-t1).total_seconds()/60.0
m=round(dif-45) if dif>45 else round(dif)
if m<0: m=0
nota=" refrigerio (-45 si >45)"
else:
m=mins_laborales(nom_up,t1,t2); nota=""
resp+=1; suma+=m
print(f"{i:3} {tel:13} {t1.strftime('%H:%M:%S'):11} {t2.strftime('%H:%M:%S'):11} {m:>5}{nota}")
print("-"*60)
print(f"Respondidos: {resp} | Sin responder: {sinr} | Promedio: {round(suma/resp) if resp else 0} min")

View File

@@ -0,0 +1,100 @@
# diag_diana_resp_ago11.py
# NUEVO grafico (por FECHA DE RESPUESTA): leads que DIANA RESPONDIO el 11/08/2026,
# sin importar cuando se asignaron. Muestra asignacion, respuesta y minutos.
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES, DIA_RESP = 2026, 8, 11 # dia de RESPUESTA
NOMBRE = "DIANA"
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid=a.get("user_id"); ts=a.get("created_at")
if uid not in uid2name or not isinstance(ts,datetime): continue
nom=uid2name[uid].upper(); d=ts.date(); h=ts.hour+ts.minute/60.0; key=(nom,d)
if d.weekday()==6: continue
if d.weekday()==5:
if 9<=h<13: act[key]["sab_manana"]+=1
elif 14<=h<18: act[key]["sab_tarde"]+=1
else:
if 13<=h<18: act[key]["tarde"]+=1
elif 18<=h<22: act[key]["noche"]+=1
def turno_dia(nom,d):
if d.weekday()==6: return None
fr=act.get((nom,d))
if not fr: return None
if d.weekday()==5:
m=fr.get("sab_manana",0); t=fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t=fr.get("tarde",0); n=fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def es_t1(nom,d):
fr=act.get((nom,d))
if not fr or d.weekday()>=5: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
return (not (n>t)) and (t>0 or n>0)
def _dt(d,hh): return datetime(d.year,d.month,d.day)+timedelta(hours=hh)
def mins_laborales(nom,t1,t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; s=0
while cur<t2 and s<60:
d=cur.date(); bl=turno_dia(nom,d)
if not bl: cur=_dt(d,24); s+=1; continue
for (hi,hf) in bl:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total+=(b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d: cur=_dt(d,24); s+=1
return round(total)
# filtrar Diana + RESPUESTA el 11
filas=[]
for r in asig:
if NOMBRE not in (r.get("user_name") or "").upper(): continue
t1=r.get("created_at"); t2=r.get("respuesta_fecha")
if not isinstance(t1,datetime) or not isinstance(t2,datetime): continue # sin respuesta -> fuera
if t2.year==ANO and t2.month==MES and t2.day==DIA_RESP:
filas.append(r)
print(f"\n=== DIANA — leads RESPONDIDOS el {DIA_RESP:02d}/{MES:02d}/{ANO} (grafico por fecha respuesta) ===\n")
print(f"Total respondidos ese dia: {len(filas)}\n")
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
print("-"*74)
suma=0
for i,r in enumerate(sorted(filas, key=lambda x: x['respuesta_fecha']),1):
t1=r['created_at']; t2=r['respuesta_fecha']; tel=r.get('telefono','')
d1=t1.date(); h=t1.hour+t1.minute/60.0
refri = es_t1((r.get('user_name') or '').upper(), d1) and (13<=h<15)
if refri:
dif=(t2-t1).total_seconds()/60.0
m=round(dif-45) if dif>45 else round(dif)
if m<0: m=0
nota=" refrig(-45)"
else:
m=mins_laborales((r.get('user_name') or '').upper(),t1,t2); nota=""
# marca si se asigno OTRO dia
if t1.date()!=t2.date(): nota += f" (asignado {t1.strftime('%d/%m')})"
suma+=m
print(f"{i:3} {tel:13} {t1.strftime('%d/%m %H:%M'):17} {t2.strftime('%d/%m %H:%M'):17} {m:>5}{nota}")
print("-"*74)
print(f"Promedio del dia {DIA_RESP} (por fecha respuesta): {round(suma/len(filas)) if filas else 0} min")

View File

@@ -0,0 +1,44 @@
# diag_diff_asignados_diana13.py
# Compara las 2 fuentes de "leads asignados" para DIANA el dia 13/08/2026:
# A = traer_leads_asignados (la de la TARJETA)
# B = traer_asignacion_respuesta (la del GRAFICO)
# Muestra los telefonos de cada una y cuales difieren.
from datetime import datetime
from data_manager_v2 import DataManager
ANO, MES, DIA = 2026, 8, 13
NOMBRE = "DIANA"
dm = DataManager()
def _d(v): return v.date() if isinstance(v, datetime) else None
A = {} # telefono -> hora asignacion (TARJETA)
for r in dm.traer_leads_asignados():
if NOMBRE not in (r.get("user_name") or "").upper(): continue
f = r.get("created_at")
if _d(f) and f.year==ANO and f.month==MES and f.day==DIA:
A[str(r.get("telefono") or "").strip()] = f
B = {} # telefono -> hora asignacion (GRAFICO)
for r in dm.traer_asignacion_respuesta():
if NOMBRE not in (r.get("user_name") or "").upper(): continue
f = r.get("created_at")
if _d(f) and f.year==ANO and f.month==MES and f.day==DIA:
B[str(r.get("telefono") or "").strip()] = f
print(f"\n=== DIANA dia {DIA} — comparacion de fuentes ===\n")
print(f"A) TARJETA (traer_leads_asignados): {len(A)} telefonos")
print(f"B) GRAFICO (traer_asignacion_respuesta): {len(B)} telefonos\n")
print("Telefonos en A (tarjeta):")
for t in sorted(A): print(f" {t} asignado {A[t].strftime('%H:%M:%S')}")
print("\nTelefonos en B (grafico):")
for t in sorted(B): print(f" {t} asignado {B[t].strftime('%H:%M:%S')}")
solo_A = set(A) - set(B)
solo_B = set(B) - set(A)
print("\n--- DIFERENCIAS ---")
if solo_A: print(f" Solo en TARJETA (A), no en grafico: {sorted(solo_A)}")
if solo_B: print(f" Solo en GRAFICO (B), no en tarjeta: {sorted(solo_B)}")
if not solo_A and not solo_B:
print(" Los telefonos son IDENTICOS. La diferencia debe ser cache (reinicia backend).")

View File

@@ -0,0 +1,42 @@
"""
Diagnostico: baja el Excel de Arequipa de SharePoint y muestra que hojas
y que tablas nombradas detecta openpyxl, y cuantas filas tiene cada una.
Asi sabemos por que el sync cae al fallback (10000 filas).
"""
import os, io, openpyxl, requests, msal
CLIENT_ID = os.environ["MS_CLIENT_ID"]
TENANT_ID = os.environ["MS_TENANT_ID"]
REFRESH_TOKEN = os.environ["MS_REFRESH_TOKEN"]
SHAREPOINT_SITE = "escuelarefrigeracion.sharepoint.com"
SITE_PATH = "/sites/ASESORASCOMERCIALES"
SUBFOLDER = "2. BASE PROSPECTOS/BASE GENERAL"
SCOPES = ["Sites.Read.All", "Files.Read.All"]
ARCHIVOS = ["Base Sede Arequipa.xlsx", "Base Diana Chavez.xlsx"] # uno malo + uno bueno para comparar
app = msal.PublicClientApplication(CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}")
tok = app.acquire_token_by_refresh_token(REFRESH_TOKEN, scopes=SCOPES)["access_token"]
h = {"Authorization": f"Bearer {tok}"}
sid = requests.get(f"https://graph.microsoft.com/v1.0/sites/{SHAREPOINT_SITE}:{SITE_PATH}", headers=h).json()["id"]
drives = requests.get(f"https://graph.microsoft.com/v1.0/sites/{sid}/drives", headers=h).json()["value"]
did = next((d["id"] for d in drives if "document" in d["name"].lower() or "compartid" in d["name"].lower()), drives[0]["id"])
items = requests.get(f"https://graph.microsoft.com/v1.0/drives/{did}/root:/{SUBFOLDER}:/children", headers=h).json()["value"]
for nombre in ARCHIVOS:
it = next((x for x in items if x["name"] == nombre), None)
print("\n" + "="*60)
if not it:
print(f"❌ NO ENCONTRADO en SharePoint: '{nombre}'")
print(" Archivos disponibles:", [x["name"] for x in items if x["name"].endswith(".xlsx")])
continue
print(f"📄 {nombre}")
cont = requests.get(f"https://graph.microsoft.com/v1.0/drives/{did}/items/{it['id']}/content", headers=h).content
wb = openpyxl.load_workbook(io.BytesIO(cont), data_only=True)
for ws in wb.worksheets:
print(f" Hoja: '{ws.title}' dims={ws.dimensions} max_row={ws.max_row}")
tbls = list(ws.tables.keys())
print(f" Tablas nombradas: {tbls}")
for tn, tb in ws.tables.items():
print(f" · {tn} -> ref={tb.ref}")

View File

@@ -0,0 +1,45 @@
"""
Diagnostico: para una lista de telefonos, muestra la fecha de creacion del
contacto (contacts.created_at) y la fecha del primer/ultimo mensaje.
"""
import os
import psycopg2
from dotenv import load_dotenv
load_dotenv()
PG_HOST = os.getenv("PG_HOST")
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
PG_USER = os.getenv("PG_USER")
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
PG_PORT = os.getenv("PG_PORT", "5432")
TELEFONOS = ["948400152", "953932854", "959735396", "972132288"]
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
password=PG_PASS, port=PG_PORT, connect_timeout=30)
cur = conn.cursor()
print(f"{'TELEFONO':14} {'phone_number':16} {'CONTACTO_CREADO':22} {'1ER_MSJE':22} {'ULT_MSJE':22}")
print("-" * 100)
for tel in TELEFONOS:
cur.execute("""
SELECT c.phone_number, c.created_at,
MIN(m.created_at) AS primer_msje,
MAX(m.created_at) AS ultimo_msje
FROM contacts c
LEFT JOIN conversations cv ON cv.contact_id = c.id
LEFT JOIN messages m ON m.conversation_id = cv.id
WHERE REPLACE(REPLACE(c.phone_number, '+51',''), '+','') LIKE %s
GROUP BY c.phone_number, c.created_at
ORDER BY c.created_at ASC
""", (f"%{tel}",))
rows = cur.fetchall()
if not rows:
print(f"{tel:14} {'(no encontrado)':16}")
continue
for ph, creado, primer, ultimo in rows:
print(f"{tel:14} {str(ph):16} {str(creado):22} {str(primer):22} {str(ultimo):22}")
conn.close()

12
backend/diag_importe.py Normal file
View File

@@ -0,0 +1,12 @@
# diag_importe.py — Verifica matriz_asignados (pivot asesor x dia).
import services as S
d = S.matriz_asignados("2026", "7", "TODOS")
print("MES 7 2026 TOTAL asignados:", d["total"])
print("dias (columnas):", d["dias"][:5], "...", d["dias"][-3:])
print("\nPor asesor (total):")
for f in d["filas"]:
dias_con = {k:v for k,v in f["por_dia"].items() if v}
print(f" {f['asesor']:22} total={f['total']:4} dias con datos: {len(dias_con)}")
print("\ntotal_por_dia (primeros 10):", {k:v for k,v in list(d['total_por_dia'].items())[:10]})
print("OK.")

View File

@@ -0,0 +1,58 @@
# diag_matri_50_vs_51.py
# Busca la matricula de diferencia (dashboard 51 vs cmd 50) en TEAC agosto.
# Replica EXACTAMENTE el bloque de la matriz del dashboard y compara.
from datetime import datetime
from collections import defaultdict
import services as S
import leads_logic as L
ANO, MES = 2026, 8
ase = S._asesores(); amap = ase["map"]; lista = ase["lista"]
# ---- REPLICA EXACTA del bloque de matriz del dashboard ----
GRUPOS = ["TEAC","TERC","SEMINARIOS","OTROS"]
prog_tot = defaultdict(int)
detalle = defaultdict(list) # grupo -> [matriculas]
for m in S._matriculas_crudas():
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
if not vend:
continue
estado = str(m.get("estado_matricula") or "").strip().upper()
if estado == "ANU":
continue
fm = L._to_date(m.get("fch_matricula"))
if not L._en_periodo(fm, str(ANO), str(MES), "TODOS"):
continue
grupo = L.grupo_programa_curso(m.get("dsc_programa"))
prog_tot[grupo] += 1
detalle[grupo].append(m)
print("=== Conteo por grupo (replica dashboard) ===")
tot=0
for g in GRUPOS:
print(f" {g:12} {prog_tot[g]}")
tot+=prog_tot[g]
print(f" TOTAL {tot}")
# ---- Detalle de TEAC: mostrar TODAS con num_matricula para ver duplicados/algo raro ----
print(f"\n=== TEAC — {len(detalle['TEAC'])} matriculas (con num_matricula y num_indice) ===")
for m in sorted(detalle['TEAC'], key=lambda x: str(x.get('fch_matricula'))):
fm = L._to_date(m.get('fch_matricula'))
print(f" mat={m.get('num_matricula')} idx={m.get('num_indice')} "
f"{S._norm_asesor(m.get('dsc_vendedor'),amap):16} {str(fm)[:10]} "
f"est={m.get('estado_matricula')} | {str(m.get('dsc_programa'))[:40]}")
# ---- Ver si hay matriculas cuyo asesor NO cae en los 8 (por eso el dashboard podria contar distinto) ----
print(f"\n=== Matriculas agosto con asesor que NO es de los 8 (quedan fuera del cmd) ===")
n_fuera=0
for m in S._matriculas_crudas():
estado = str(m.get("estado_matricula") or "").strip().upper()
if estado == "ANU": continue
fm = L._to_date(m.get("fch_matricula"))
if not (fm and fm.year==ANO and fm.month==MES): continue
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
if not vend:
n_fuera += 1
print(f" mat={m.get('num_matricula')} asesor_original='{m.get('dsc_vendedor')}' "
f"grupo={L.grupo_programa_curso(m.get('dsc_programa'))} est={estado}")
print(f" -> {n_fuera} matriculas con asesor fuera de los 8")

View File

@@ -0,0 +1,51 @@
# diag_matri_51_perdida.py
# Busca la matricula #51: revisa TODAS las de agosto (incluso ANU/RET) para ver
# cual pudo aparecer/desaparecer. Va directo a SQL Server (fresco, sin cache).
from datetime import datetime
import services as S
import leads_logic as L
ANO, MES = 2026, 8
ase = S._asesores(); amap = ase["map"]
# TODAS las matriculas de agosto (sin excluir estado), directo de traer_matriculas
todas = []
for m in S.get_dm().traer_matriculas():
fm = L._to_date(m.get("fch_matricula"))
if not (fm and fm.year==ANO and fm.month==MES): continue
todas.append(m)
print(f"=== TODAS las matriculas de agosto {ANO} (cualquier estado): {len(todas)} ===\n")
from collections import Counter
est = Counter(str(m.get("estado_matricula") or "").strip().upper() for m in todas)
print("Por estado:", dict(est))
# las que cuentan (no ANU, asesor de los 8)
cuentan = 0
no_asesor = 0
anu = 0
for m in todas:
e = str(m.get("estado_matricula") or "").strip().upper()
if e == "ANU":
anu += 1; continue
v = S._norm_asesor(m.get("dsc_vendedor"), amap)
if not v:
no_asesor += 1
print(f" [FUERA 8] mat={m.get('num_matricula')} asesor='{m.get('dsc_vendedor')}' est={e} "
f"grupo={L.grupo_programa_curso(m.get('dsc_programa'))}")
continue
cuentan += 1
print(f"\nCuentan (no ANU + asesor de los 8): {cuentan}")
print(f"Anuladas (ANU): {anu}")
print(f"Con asesor fuera de los 8: {no_asesor}")
# mostrar TODAS con su estado para ver si hay algo raro (RET, SUS, etc.)
print(f"\n=== Detalle: matriculas con estado != ALU/PRE (posibles casos raros) ===")
for m in todas:
e = str(m.get("estado_matricula") or "").strip().upper()
if e not in ("ALU","PRE"):
v = S._norm_asesor(m.get("dsc_vendedor"), amap)
fm = L._to_date(m.get("fch_matricula"))
print(f" mat={m.get('num_matricula')} est={e} asesor={v or m.get('dsc_vendedor')} "
f"{str(fm)[:10]} grupo={L.grupo_programa_curso(m.get('dsc_programa'))}")

View File

@@ -0,0 +1,51 @@
# diag_matriculas_agosto_detalle.py
# Las 51 matriculas de AGOSTO 2026 (todos los asesores de los 8), con su
# dsc_det_programa (detalle del programa) y grupo. Mismo criterio del dashboard:
# dsc_vendedor de los 8, estado != ANU, fch_matricula en agosto.
from datetime import datetime
from collections import defaultdict, Counter
import services as S
import leads_logic as L
ANO, MES = 2026, 8
ase = S._asesores(); amap = ase["map"]
filas = []
for m in S._matriculas_crudas():
vend = S._norm_asesor(m.get("dsc_vendedor"), amap)
if not vend:
continue
estado = str(m.get("estado_matricula") or "").strip().upper()
if estado == "ANU":
continue
fm = L._to_date(m.get("fch_matricula"))
if not fm or fm.year != ANO or fm.month != MES:
continue
filas.append({
"vendedor": vend,
"num_matricula": m.get("num_matricula"),
"estado": estado,
"fch": fm.strftime("%d/%m/%Y"),
"dsc_programa": str(m.get("dsc_programa") or ""),
"dsc_det": str(m.get("dsc_promocion") or ""), # dsc_det_programa
"grupo": L.grupo_programa_curso(m.get("dsc_programa")),
})
print(f"\n=== MATRICULAS AGOSTO {ANO} — todos los asesores (total: {len(filas)}) ===\n")
print(f"{'#':3} {'vendedor':16} {'estado':6} {'fch':11} {'grupo':10} dsc_det_programa")
print("-"*100)
for i, f in enumerate(sorted(filas, key=lambda x: (x['grupo'], x['vendedor'])), 1):
print(f"{i:3} {f['vendedor'][:16]:16} {f['estado']:6} {f['fch']:11} {f['grupo']:10} {f['dsc_det'][:45]}")
print("-"*100)
print("\n=== RESUMEN por dsc_det_programa (cuantas matriculas de cada uno) ===")
cnt = Counter(f["dsc_det"] for f in filas)
for prog, n in sorted(cnt.items(), key=lambda x: -x[1]):
print(f" {n:3} {prog}")
print("\n=== RESUMEN por GRUPO (debe cuadrar con la matriz del dashboard) ===")
gr = Counter(f["grupo"] for f in filas)
for g in ["TEAC","TERC","SEMINARIOS","OTROS"]:
print(f" {g:12} {gr.get(g,0)}")
print(f" {'TOTAL':12} {len(filas)}")

22
backend/diag_multi.py Normal file
View File

@@ -0,0 +1,22 @@
# diag_multi.py — Revisa programa_pautas y la suma por num_indice.
import services as S
from cache_manager import cache_invalidate
# forzar leer fresco de supabase
cache_invalidate("programa_pautas")
cache_invalidate("pautas_de_indice")
pp = S._programa_pautas() # {num_indice: [pautas]} SOLO de la tabla nueva
print("== Tabla programa_pautas (lo que se guardo) ==")
if not pp:
print(" (VACIA - no se guardo nada, o no se lee)")
for ni, ps in pp.items():
print(f" num_indice {ni} -> {ps}")
ppi = S._pautas_de_indice() # combinado basebi + nueva
print("\n== num_indices con VARIAS pautas (basebi + nueva) ==")
multi = {ni: ps for ni, ps in ppi.items() if len(ps) > 1}
for ni, ps in list(multi.items())[:15]:
print(f" num_indice {ni} -> {ps}")
if not multi:
print(" (ninguno con varias)")

View File

@@ -0,0 +1,54 @@
# diag_numero_chatwoot.py - Ver los mensajes de un numero en Chatwoot y que campania hace match.
import os
from dotenv import load_dotenv
import psycopg2
load_dotenv()
TEL = "8099166706" # buscar por terminacion (sin prefijo pais)
dm_host=os.getenv("PG_HOST","191.98.134.81"); dm_db=os.getenv("PG_DATABASE","chatwoot_production")
dm_user=os.getenv("PG_USER","postgres"); dm_pass=os.getenv("PG_PASSWORD",""); dm_port=os.getenv("PG_PORT","5432")
conn=psycopg2.connect(host=dm_host,dbname=dm_db,user=dm_user,password=dm_pass,port=dm_port)
cur=conn.cursor()
# mensajes del contacto (por terminacion del telefono)
cur.execute("""
SELECT c.phone_number, m.id, m.created_at, m.sender_type, LEFT(m.content, 90)
FROM messages m
JOIN conversations cv ON m.conversation_id=cv.id
JOIN contacts c ON cv.contact_id=c.id
WHERE c.phone_number LIKE %s
ORDER BY m.id ASC
""", ('%'+TEL,))
rows=cur.fetchall()
print(f"\n=== Mensajes en Chatwoot del numero terminado en {TEL} ===\n")
if not rows:
print(" (no se encontro el numero en Chatwoot)")
for ph,mid,fecha,stype,cont in rows:
print(f" [{ph}] {fecha} {stype:8} | {cont}")
# ¿el primer mensaje del contacto hace match con alguna campania por texto?
print("\n=== Campanias cuyo texto aparece en algun mensaje de este numero ===")
cur.execute("""
SELECT map.codigo, map.cargo, map.fecha_inicio, map.fecha_fin, map.frase
FROM (VALUES
(' 🖥️ Estoy interesado en sus Seminarios','Seminarios','74a','2026-04-01','2026-07-30'),
('👾Estoy interesado en el Seminario Refrigeración','CO2','80a','2026-05-15','2026-07-30')
) AS map(frase, cargo, codigo, fecha_inicio, fecha_fin)
WHERE EXISTS (
SELECT 1 FROM messages m
JOIN conversations cv ON m.conversation_id=cv.id
JOIN contacts c ON cv.contact_id=c.id
WHERE c.phone_number LIKE %s AND m.sender_type='Contact'
AND m.content LIKE '%%'||map.frase||'%%'
)
""", ('%'+TEL,))
mt=cur.fetchall()
if not mt:
print(" (ninguna de las 2 frases de ejemplo hizo match textual - revisar frase exacta)")
for cod,cargo,fi,ff,frase in mt:
print(f" codigo={cod} cargo={cargo} rango={fi}..{ff} frase='{frase}'")
conn.close()
print("\nNOTA: si el match textual SI existe pero la fecha del mensaje esta fuera")
print("del rango de la campania, por eso NO cuenta como pauta (queda Copito).")

View File

@@ -0,0 +1,12 @@
# diag_plantillas.py — Verifica otros_general (endpoint liviano).
import time, services as S
for mes in ["TODOS", "1", "2"]:
t0 = time.time()
d = S.otros_general("2026", mes, "TODOS")
seg = time.time() - t0
p = d["matriz_plantillas"]["total"]
print(f"MES {mes:6} ({seg:.1f}s) plantillas_env={p['enviadas']} matric={p['matriculas']} "
f"always_filas={len(d['matriz_always']['filas'])} webform_filas={len(d['matriz_webform']['filas'])} "
f"asignados_total={d['matriz_asignados']['total']}")
print("OK. (2da vez el mismo mes debe ser instantaneo por cache)")

View File

@@ -0,0 +1,58 @@
"""
Diagnostico ROAS: conjuntos del Meta CSV (enero) que NO se suman en la matriz
por sede, porque no tienen pauta ligada o su pauta no tiene sede en campanias.
"""
import services as S
import leads_logic as L
ANO, MES = "2026", "1" # enero 2026
SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"}
conj_pauta = S._conjunto_pauta() # {pauta: [conjunto,...]}
camp = S._campanias_sede_cargo() # {pauta: {sede, cargo}}
conj_sede = {}
conj_pauta_map = {}
for pauta, conjuntos in conj_pauta.items():
sp = camp.get(str(pauta).strip())
sede = " ".join(str((sp or {}).get("sede") or "").upper().split())
for co in conjuntos:
k = " ".join(str(co).split()).upper()
conj_pauta_map[k] = str(pauta).strip()
if sp and sede in SEDES:
conj_sede[k] = sede
sin_sumar = {}
suma_ok = 0.0
suma_falta = 0.0
for f in S._meta_filas():
fx = L._to_date(f.get("inicio"))
if not L._en_periodo(fx, ANO, MES, "TODOS"):
continue
k = " ".join(str(f.get("conjunto")).split()).upper()
imp = float(f.get("importe") or 0.0)
res = int(float(f.get("resultados") or 0.0))
if k in conj_sede:
suma_ok += imp
continue
if k not in conj_pauta_map:
motivo = "SIN PAUTA"
else:
p = conj_pauta_map[k]
sp = camp.get(p)
motivo = (f"pauta '{p}' SIN campania" if not sp
else f"pauta '{p}' sede='{sp.get('sede')}' (fuera de las 4)")
d = sin_sumar.setdefault(k, {"importe": 0.0, "resultados": 0, "motivo": motivo})
d["importe"] += imp
d["resultados"] += res
suma_falta += imp
print("=== ENERO 2026 — conjuntos que NO se suman en ROAS ===\n")
print(f"{'CONJUNTO':45} {'IMPORTE':>12} {'RESULTADOS':>12} MOTIVO")
print("-"*100)
for k, d in sorted(sin_sumar.items(), key=lambda x: -x[1]["importe"]):
print(f"{k[:45]:45} {('$'+format(d['importe'],',.2f')):>12} {d['resultados']:>12,} {d['motivo']}")
print("-"*100)
print(f"{'IMPORTE QUE SÍ SUMA (4 sedes)':45} {('$'+format(suma_ok,',.2f')):>12}")
print(f"{'IMPORTE QUE NO SUMA (faltante)':45} {('$'+format(suma_falta,',.2f')):>12}")
print(f"Conjuntos distintos sin sumar: {len(sin_sumar)}")

View File

@@ -0,0 +1,73 @@
"""
Diagnostico ROAS por MES (2026): para cada mes, los conjuntos del Meta CSV que
NO se suman en la matriz por sede (sin pauta / sin sede valida), con su importe.
"""
import services as S
import leads_logic as L
ANO = "2026"
SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"}
MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO",
"SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]
conj_pauta = S._conjunto_pauta() # {pauta: [conjunto,...]}
camp = S._campanias_sede_cargo() # {pauta: {sede, cargo}}
conj_sede = {}
conj_pauta_map = {}
for pauta, conjuntos in conj_pauta.items():
sp = camp.get(str(pauta).strip())
sede = " ".join(str((sp or {}).get("sede") or "").upper().split())
for co in conjuntos:
k = " ".join(str(co).split()).upper()
conj_pauta_map[k] = str(pauta).strip()
if sp and sede in SEDES:
conj_sede[k] = sede
filas = S._meta_filas()
for mi in range(1, 13):
mes = str(mi)
sin_sumar = {}
suma_ok = 0.0
suma_falta = 0.0
for f in filas:
fx = L._to_date(f.get("inicio"))
if not L._en_periodo(fx, ANO, mes, "TODOS"):
continue
k = " ".join(str(f.get("conjunto")).split()).upper()
imp = float(f.get("importe") or 0.0)
res = int(float(f.get("resultados") or 0.0))
if k in conj_sede:
suma_ok += imp
continue
if k not in conj_pauta_map:
motivo = "SIN PAUTA"
else:
p = conj_pauta_map[k]
sp = camp.get(p)
motivo = (f"pauta '{p}' SIN campania" if not sp
else f"pauta '{p}' sede='{sp.get('sede')}' (fuera de las 4)")
d = sin_sumar.setdefault(k, {"importe": 0.0, "resultados": 0, "motivo": motivo})
d["importe"] += imp
d["resultados"] += res
suma_falta += imp
# solo imprimir meses con algo de data
if suma_ok == 0 and not sin_sumar:
continue
print("=" * 100)
print(f" {MESES[mi-1]} {ANO}")
print("=" * 100)
if sin_sumar:
print(f"{'CONJUNTO':45} {'IMPORTE':>12} {'RESULTADOS':>12} MOTIVO")
print("-" * 100)
for k, d in sorted(sin_sumar.items(), key=lambda x: -x[1]["importe"]):
print(f"{k[:45]:45} {('$'+format(d['importe'],',.2f')):>12} {d['resultados']:>12,} {d['motivo']}")
else:
print(" (sin conjuntos faltantes)")
print("-" * 100)
print(f"{'IMPORTE QUE SÍ SUMA (4 sedes)':45} {('$'+format(suma_ok,',.2f')):>12}")
print(f"{'IMPORTE QUE NO SUMA (faltante)':45} {('$'+format(suma_falta,',.2f')):>12}")
print(f" Conjuntos distintos sin sumar: {len(sin_sumar)}\n")

View File

@@ -0,0 +1,56 @@
"""
Diagnostico: contactos de Chatwoot que NO tienen mensaje de campaña
(no entran por el query de Postgre de la cartera).
Muestra: telefono, fecha del contacto (contacts.created_at),
fecha del primer y ultimo mensaje (si tiene), y si tiene algun mensaje.
"""
import os
import psycopg2
from dotenv import load_dotenv
import data_manager_v2 as dm # para reutilizar las campañas (frases de campaña)
load_dotenv()
PG_HOST = os.getenv("PG_HOST")
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
PG_USER = os.getenv("PG_USER")
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
PG_PORT = os.getenv("PG_PORT", "5432")
# Frases de campaña (mismas que usa la cartera / dashboard)
FRASES = [c[0] for c in dm._campanias_supabase()]
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
password=PG_PASS, port=PG_PORT, connect_timeout=30)
cur = conn.cursor()
# Construir condicion: contacto que NO tiene NINGUN mensaje que haga match con alguna frase
likes = " OR ".join(["m.content LIKE %s"] * len(FRASES))
params = [f"%{fr}%" for fr in FRASES]
sql = f"""
SELECT c.phone_number, c.created_at,
COUNT(m.id) AS total_msjes,
MIN(m.created_at) AS primer_msje,
MAX(m.created_at) AS ultimo_msje,
SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) AS msjes_campania
FROM contacts c
LEFT JOIN conversations cv ON cv.contact_id = c.id
LEFT JOIN messages m ON m.conversation_id = cv.id AND m.sender_type = 'Contact'
GROUP BY c.phone_number, c.created_at
HAVING SUM(CASE WHEN ({likes}) THEN 1 ELSE 0 END) = 0 -- SIN mensaje de campaña
ORDER BY c.created_at DESC
LIMIT 100
"""
cur.execute(sql, params + params)
rows = cur.fetchall()
print(f"Contactos SIN mensaje de campaña (primeros 100):")
print(f"{'phone_number':16} {'contacto_creado':22} {'#msjes':>7} {'primer_msje':22} {'ultimo_msje':22}")
print("-" * 95)
for ph, creado, tot, primer, ultimo, _camp in rows:
print(f"{str(ph):16} {str(creado):22} {tot:>7} {str(primer):22} {str(ultimo):22}")
print(f"\nTotal mostrados: {len(rows)} (limite 100)")
conn.close()

View File

@@ -0,0 +1,102 @@
# diag_tiempo_diana_ago10.py
# Detalle del 10 de AGOSTO 2026 para DIANA CHAVEZ: asignados, hora de asignacion,
# hora de respuesta y minutos laborales (misma logica del dashboard). Promedio al final.
# Ejecutar en backend/: python diag_tiempo_diana_ago10.py
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES, DIA = 2026, 8, 10
NOMBRE = "DIANA" # user_name contiene esto
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid = a.get("user_id"); ts = a.get("created_at")
if uid not in uid2name or not isinstance(ts, datetime): continue
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
key = (nom, d)
if d.weekday() == 6: continue
if d.weekday() == 5:
if 9 <= h < 13: act[key]["sab_manana"] += 1
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
else:
if 13 <= h < 18: act[key]["tarde"] += 1
elif 18 <= h < 22: act[key]["noche"] += 1
def turno_dia(nom, d):
if d.weekday() == 6: return None
fr = act.get((nom, d))
if not fr: return None
if d.weekday() == 5:
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t = fr.get("tarde",0); n = fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
def mins_laborales(nom, t1, t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; saltos=0
while cur < t2 and saltos < 60:
d=cur.date(); bloques=turno_dia(nom,d)
if not bloques:
cur=_dt(d,24); saltos+=1; continue
for (hi,hf) in bloques:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total += (b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d:
cur=_dt(d,24); saltos+=1
return round(total)
# filtrar Diana + 10 agosto por fecha de asignacion
filas=[]
for r in asig:
nom=(r.get("user_name") or "")
if NOMBRE not in nom.upper(): continue
t1=r.get("created_at")
if not isinstance(t1,datetime): continue
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
filas.append(r)
d1=date(ANO,MES,DIA)
turno=None
if filas:
tt=turno_dia((filas[0].get("user_name") or "").upper(), d1)
turno="Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
print(f"Turno detectado ese dia: {turno}")
print(f"Total asignados ese dia: {len(filas)}\n")
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5}")
print("-"*62)
resp=0; sinr=0; suma=0
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
t1=r['created_at']; t2=r.get('respuesta_fecha')
nom_up=(r.get('user_name') or '').upper()
tel=r.get('telefono',''); a_str=t1.strftime('%H:%M:%S')
if not isinstance(t2,datetime):
sinr+=1
print(f"{i:3} {tel:13} {a_str:17} {'':17} {'':>5}") # sin respuesta -> vacio
continue
m=mins_laborales(nom_up,t1,t2); resp+=1; suma+=m
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}")
print("-"*62)
print(f"Respondidos: {resp} | Sin responder: {sinr}")
print(f"Promedio (solo respondidos): {round(suma/resp) if resp else 0} min")

View File

@@ -0,0 +1,110 @@
# diag_tiempo_diana_ago3.py
# Detalle del 3 de AGOSTO 2026 para DIANA CHAVEZ: asignados, hora de asignacion,
# hora de respuesta y minutos. Marca los asignados en refrigerio (13:00-14:00 turno1),
# que son los que la GRAFICA excluye.
from datetime import datetime, timedelta, date
from data_manager_v2 import DataManager
ANO, MES, DIA = 2026, 8, 3
NOMBRE = "DIANA"
dm = DataManager()
asig = dm.traer_asignacion_respuesta()
act_rows = dm.traer_actividad_asesores()
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid = a.get("user_id"); ts = a.get("created_at")
if uid not in uid2name or not isinstance(ts, datetime): continue
nom = uid2name[uid].upper(); d = ts.date(); h = ts.hour + ts.minute/60.0
key = (nom, d)
if d.weekday() == 6: continue
if d.weekday() == 5:
if 9 <= h < 13: act[key]["sab_manana"] += 1
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
else:
if 13 <= h < 18: act[key]["tarde"] += 1
elif 18 <= h < 22: act[key]["noche"] += 1
def turno_dia(nom, d):
if d.weekday() == 6: return None
fr = act.get((nom, d))
if not fr: return None
if d.weekday() == 5:
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t = fr.get("tarde",0); n = fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def es_t1(nom, d):
fr = act.get((nom,d))
if not fr or d.weekday()>=5: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
return (not (n>t)) and (t>0 or n>0)
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
def mins_laborales(nom, t1, t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; saltos=0
while cur < t2 and saltos < 60:
d=cur.date(); bloques=turno_dia(nom,d)
if not bloques:
cur=_dt(d,24); saltos+=1; continue
for (hi,hf) in bloques:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total += (b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d:
cur=_dt(d,24); saltos+=1
return round(total)
filas=[]
for r in asig:
nom=(r.get("user_name") or "")
if NOMBRE not in nom.upper(): continue
t1=r.get("created_at")
if not isinstance(t1,datetime): continue
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
filas.append(r)
d1=date(ANO,MES,DIA)
tt=turno_dia((filas[0].get("user_name") or "").upper(), d1) if filas else None
turno="Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
print(f"Turno detectado ese dia: {turno}")
print(f"Total asignados ese dia: {len(filas)}\n")
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
print("-"*72)
resp=0; sinr=0; suma=0; refri=0
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
t1=r['created_at']; t2=r.get('respuesta_fecha')
nom_up=(r.get('user_name') or '').upper()
tel=r.get('telefono',''); a_str=t1.strftime('%H:%M:%S')
h=t1.hour+t1.minute/60.0
en_refri = es_t1(nom_up,d1) and (13<=h<14)
nota = " REFRIGERIO (grafica lo excluye)" if en_refri else ""
if en_refri: refri+=1
if not isinstance(t2,datetime):
sinr+=1
print(f"{i:3} {tel:13} {a_str:17} {'':17} {'':>5}{nota}")
continue
m=mins_laborales(nom_up,t1,t2); resp+=1; suma+=m
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}{nota}")
print("-"*72)
print(f"Respondidos: {resp} | Sin responder: {sinr} | En refrigerio (13-14): {refri}")
print(f"Total tabla (todos): {len(filas)} | Total grafica (sin refrigerio): {len(filas)-refri}")
print(f"Promedio (solo respondidos): {round(suma/resp) if resp else 0} min")

View File

@@ -0,0 +1,131 @@
# diag_tiempo_diana_jul1.py
# Detalle del 1 de JULIO 2026 para DIANA CHAVEZ: los asignados, hora de asignacion,
# si respondieron, hora de respuesta y minutos laborales (misma logica del dashboard).
# Ejecutar en backend/: python diag_tiempo_diana_jul1.py
from datetime import datetime, timedelta
from data_manager_v2 import DataManager
ANO, MES, DIA = 2026, 7, 1
NOMBRE = "DIANA" # busca por apellido/nombre que contenga esto en user_name
dm = DataManager()
# --- 1) asignacion + respuesta (mismo query del dashboard) ---
asig = dm.traer_asignacion_respuesta()
# --- 2) actividad de asesores (para detectar turno) ---
act_rows = dm.traer_actividad_asesores()
# mapa user_id -> user_name
uid2name = {}
for r in asig:
if r.get("user_id") is not None:
uid2name[r["user_id"]] = r.get("user_name") or ""
# actividad por (user_name_upper, fecha) -> franja -> conteo
from collections import defaultdict
act = defaultdict(lambda: defaultdict(int))
for a in act_rows:
uid = a.get("user_id"); ts = a.get("created_at")
if uid not in uid2name or not isinstance(ts, datetime):
continue
nom = uid2name[uid].upper()
d = ts.date(); h = ts.hour + ts.minute/60.0
key = (nom, d)
if d.weekday() == 6:
continue
if d.weekday() == 5:
if 9 <= h < 13: act[key]["sab_manana"] += 1
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
else:
if 13 <= h < 18: act[key]["tarde"] += 1
elif 18 <= h < 22: act[key]["noche"] += 1
def turno_dia(nom, d):
if d.weekday() == 6: return None
fr = act.get((nom, d))
if not fr: return None
if d.weekday() == 5:
m = fr.get("sab_manana",0); t = fr.get("sab_tarde",0)
if m==0 and t==0: return None
return [(9,13)] if m>=t else [(14,18)]
t = fr.get("tarde",0); n = fr.get("noche",0)
if t==0 and n==0: return None
return [(9,13),(18,22)] if n>t else [(9,13),(14,18)]
def _dt(d, hh): return datetime(d.year,d.month,d.day) + timedelta(hours=hh)
def mins_laborales(nom, t1, t2):
if not isinstance(t1,datetime) or not isinstance(t2,datetime) or t2<=t1: return 0
total=0.0; cur=t1; saltos=0
while cur < t2 and saltos < 60:
d=cur.date(); bloques=turno_dia(nom,d)
if not bloques:
cur=_dt(d,24); saltos+=1; continue
for (hi,hf) in bloques:
ini=_dt(d,hi); fin=_dt(d,hf)
if t2<=ini: break
if cur>=fin: continue
a=max(cur,ini); b=min(t2,fin)
if b>a: total += (b-a).total_seconds()/60.0
cur=fin
if cur>=t2: break
if cur.date()==d:
cur=_dt(d,24); saltos+=1
return round(total)
def es_refri_t1(nom, ts):
d=ts.date()
if d.weekday()>=5: return False
fr=act.get((nom,d))
if not fr: return False
t=fr.get("tarde",0); n=fr.get("noche",0)
es_t1 = not (n>t) and (t>0 or n>0)
h=ts.hour+ts.minute/60.0
return es_t1 and (13<=h<14)
# --- filtrar Diana + 1 julio (por fecha de asignacion) ---
filas=[]
for r in asig:
nom = (r.get("user_name") or "")
if NOMBRE not in nom.upper():
continue
t1 = r.get("created_at")
if not isinstance(t1, datetime): continue
if not (t1.year==ANO and t1.month==MES and t1.day==DIA): continue
filas.append(r)
# turno detectado ese dia
from datetime import date
d1 = date(ANO,MES,DIA)
turno = None
if filas:
nom_up = (filas[0].get("user_name") or "").upper()
tt = turno_dia(nom_up, d1)
turno = "Turno 1 (9-18)" if tt==[(9,13),(14,18)] else ("Turno 2 (9-13+18-22)" if tt==[(9,13),(18,22)] else str(tt))
print(f"\n=== DIANA CHAVEZ — {DIA:02d}/{MES:02d}/{ANO} ===")
print(f"Turno detectado ese dia: {turno}")
print(f"Total asignados ese dia: {len(filas)}\n")
print(f"{'#':3} {'telefono':13} {'asignado':17} {'respondio':17} {'min':>5} nota")
print("-"*75)
resp=0; sinr=0; refri=0; suma=0
for i,r in enumerate(sorted(filas, key=lambda x: x['created_at']),1):
t1=r['created_at']; t2=r.get('respuesta_fecha')
nom_up=(r.get('user_name') or '').upper()
tel=r.get('telefono','')
a_str=t1.strftime('%H:%M:%S')
if es_refri_t1(nom_up, t1):
refri+=1
r_str = t2.strftime('%H:%M:%S') if isinstance(t2, datetime) else '(sin resp)'
print(f"{i:3} {tel:13} {a_str:17} {r_str:17} {'-':>5} EXCLUIDO (refrigerio 13-14)")
continue
if not isinstance(t2,datetime):
sinr+=1
print(f"{i:3} {tel:13} {a_str:17} {'(sin resp)':17} {'-':>5} no respondio")
continue
m=mins_laborales(nom_up,t1,t2)
resp+=1; suma+=m
print(f"{i:3} {tel:13} {a_str:17} {t2.strftime('%H:%M:%S'):17} {m:>5}")
print("-"*75)
print(f"Respondidos: {resp} | Sin responder: {sinr} | Excluidos refrigerio: {refri}")
print(f"Suma minutos: {suma} | Promedio: {round(suma/resp) if resp else 0} min")

View File

@@ -0,0 +1,51 @@
"""
Diagnostico: para una lista de telefonos, muestra su cached_label_list crudo
y la Ultima_Etiqueta calculada (misma logica del DAX / leads_logic).
"""
import os
import psycopg2
from dotenv import load_dotenv
import leads_logic as L # reutiliza ultima_etiqueta() ya existente
load_dotenv()
PG_HOST = os.getenv("PG_HOST")
PG_DB = os.getenv("PG_DATABASE") or os.getenv("PG_DB")
PG_USER = os.getenv("PG_USER")
PG_PASS = os.getenv("PG_PASSWORD") or os.getenv("PG_PASS")
PG_PORT = os.getenv("PG_PORT", "5432")
TELEFONOS = """
948400152 953932854 959735396 971615688 972132288 976595677
56953794663 56961258851 56962820196 59173326356 59175648709
900079313 900153292 900610393 900779778 902490667 902671008
902968272 907312317 907818325
""".split()
conn = psycopg2.connect(host=PG_HOST, dbname=PG_DB, user=PG_USER,
password=PG_PASS, port=PG_PORT, connect_timeout=30)
cur = conn.cursor()
print(f"{'TELEFONO':14} {'ULTIMA_ETIQUETA':22} | cached_label_list (crudo)")
print("-" * 90)
for tel in TELEFONOS:
# busca cualquier variante del numero (con o sin 51 adelante)
cur.execute("""
SELECT cv.cached_label_list, m.created_at
FROM messages m
JOIN conversations cv ON m.conversation_id = cv.id
JOIN contacts c ON cv.contact_id = c.id
WHERE REPLACE(REPLACE(c.phone_number, '+51',''), '+','') LIKE %s
ORDER BY m.created_at DESC
LIMIT 1
""", (f"%{tel}",))
row = cur.fetchone()
if row is None:
print(f"{tel:14} {'(no encontrado)':22} |")
continue
crudo = row[0]
ult = L.ultima_etiqueta(crudo)
print(f"{tel:14} {ult:22} | {crudo}")
conn.close()

View File

@@ -0,0 +1,14 @@
# diag_vendedor_sql.py — Verifica el apartado Vendedores.
import services as S
d = S.vendedores_dashboard("2026", "TODOS", "TODOS")
print("Vendedores (filtro):", d["vendedores"])
print(f"\n{'VENDEDOR':24} {'CartTot':>8} {'Copito':>7} {'Otros':>7} {'MatTot':>7} {'Curso':>6} {'Retir':>6}")
print("-"*72)
for f in d["filas"]:
print(f"{f['vendedor']:24} {f['cartera_total']:>8} {f['cartera_copito']:>7} {f['cartera_otros']:>7} "
f"{f['mat_total']:>7} {f['mat_curso']:>6} {f['mat_retiradas']:>6}")
t = d["total"]
print("-"*72)
print(f"{'TOTAL':24} {t['cartera_total']:>8} {t['cartera_copito']:>7} {t['cartera_otros']:>7} "
f"{t['mat_total']:>7} {t['mat_curso']:>6} {t['mat_retiradas']:>6}")

58
backend/diag_webform.py Normal file
View File

@@ -0,0 +1,58 @@
# diag_webform.py — Compara WEB_FORMULARIO enero: datos_unificados vs cartera_junta (local xlsx).
# Usa el export local para cartera (rapido) y baja solo enero de datos_unificados.
import os, requests
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
SUP_URL = os.getenv("SUPABASE_URL"); SUP_KEY = os.getenv("SUPABASE_KEY")
def _norm_tel(v):
s = str(v or "")
for x in ("+51","+"," ","-","(",")"): s=s.replace(x,"")
s=s.strip()
if not s or set(s)=={"0"}: return ""
if s.isdigit() and len(s)<=6: return ""
return s
def _ene(v):
for fmt in ("%d/%m/%Y","%Y-%m-%d"):
try:
d=datetime.strptime(str(v)[:10],fmt).date()
return d.year==2026 and d.month==1
except: pass
return False
# datos_unificados WEB_FORMULARIO enero
h={"apikey":SUP_KEY,"Authorization":f"Bearer {SUP_KEY}"}
out=[]; desde=0
while True:
r=requests.get(f"{SUP_URL}/rest/v1/datos_unificados",
params={"select":"Telefono,Canal,Fechacreada","offset":str(desde),"limit":"1000"},headers=h,timeout=60)
r.raise_for_status(); d=r.json()
if not d: break
out.extend(d)
if len(d)<1000: break
desde+=1000
du=[_norm_tel(r["Telefono"]) for r in out if str(r.get("Canal","")).upper()=="WEB_FORMULARIO" and _ene(r.get("Fechacreada"))]
du=set(t for t in du if t)
print("datos_unificados WEB_FORMULARIO enero (tel unicos):", len(du))
# cartera desde el excel local
import openpyxl, glob
xf=max(glob.glob("cartera_junta_export.xlsx"), default=None)
wb=openpyxl.load_workbook("cartera_junta_export.xlsx", read_only=True); ws=wb.active
rows=list(ws.iter_rows(values_only=True)); hdr=rows[0]; ci={h:i for i,h in enumerate(hdr)}
tel_all=set(str(r[ci["telefono"]]).strip() for r in rows[1:]) # todos los tel de cartera
tel_web_ene=set()
for r in rows[1:]:
if str(r[ci["canal"]]).strip().upper()=="WEB_FORMULARIO" and str(r[ci["fecha_creada"]])[:7]=="2026-01":
tel_web_ene.add(str(r[ci["telefono"]]).strip())
print("cartera WEB_FORMULARIO enero (tel):", len(tel_web_ene))
falt_total=[t for t in du if t not in tel_all]
falt_web=[t for t in du if t not in tel_web_ene]
print(f"\nDe {len(du)} tel de datos_unificados enero:")
print(f" NO estan en cartera por NINGUN canal: {len(falt_total)}")
print(f" estan en cartera pero NO como WEB_FORMULARIO enero: {len(falt_web)-len(falt_total)}")
print(" ejemplos NO en cartera:", falt_total[:10])

View File

@@ -0,0 +1,150 @@
# backend/export_base_junta.py
"""
Une los leads de Postgre (Fact_Leads_Procesados, mapa de campañas) + Supabase,
deduplica por teléfono quedándose con el MÁS ANTIGUO (desempate: Pauta_wsp_face),
y exporta un Excel con: Telefono, Canal, Fecha Creada.
Uso: py -3.12 export_base_junta.py
"""
import os
from datetime import datetime
from dotenv import load_dotenv
from data_manager_v2 import DataManager
load_dotenv()
# Canal preferido en caso de empate de fecha
CANAL_PREFERIDO = "Pauta_wsp_face"
def _norm_tel(v):
s = str(v or "")
for x in ("+51", "+", " ", "-", "(", ")"):
s = s.replace(x, "")
return s.strip()
def _txt(v):
"""Texto en MAYÚSCULAS; '-' o vacío → ''."""
s = str(v or "").strip()
if s == "-":
s = ""
return s.upper()
def _to_dt(v):
if v is None:
return None
if isinstance(v, datetime):
return v
s = str(v)[:19]
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return datetime.strptime(s[:len(fmt) + 2] if "%H" in fmt else s[:10], fmt)
except Exception:
pass
return None
def traer_postgre(dm):
"""Mismos leads que Fact_Leads_Procesados (mapa de campañas): tel, fecha, canal(origen)."""
filas = dm.traer_leads_chatwoot() # ya aplica el mapa de campañas y trae 'origen'
out = []
for f in filas:
tel = _norm_tel(f.get("telefono"))
if not tel:
continue
out.append({
"telefono": tel,
"fecha": _to_dt(f.get("fecha_creada")),
"canal": _txt(f.get("origen")),
"sede": _txt(f.get("sede")),
"programa": _txt(f.get("programa")),
"codigo": _txt(f.get("codigo")),
})
return out
def traer_supabase(dm):
"""Lee la tabla de Supabase con columnas: telefono, fechacreada, canal."""
try:
from supabase import create_client
url = os.getenv("SUPABASE_URL", "")
key = os.getenv("SUPABASE_KEY", "")
if not url or not key:
print("⚠️ Falta SUPABASE_URL / SUPABASE_KEY en .env — se omite Supabase")
return []
sb = create_client(url, key)
tabla = os.getenv("SUPABASE_TABLA_LEADS", "leads") # ajustar nombre real si difiere
res = sb.table(tabla).select("Telefono,Fechacreada,Canal,Sede,Programa,Codigo").execute()
out = []
for r in (res.data or []):
tel = _norm_tel(r.get("Telefono"))
if not tel:
continue
out.append({
"telefono": tel,
"fecha": _to_dt(r.get("Fechacreada")),
"canal": _txt(r.get("Canal")),
"sede": _txt(r.get("Sede")),
"programa": _txt(r.get("Programa")),
"codigo": _txt(r.get("Codigo")),
})
return out
except Exception as e:
print(f"⚠️ Error leyendo Supabase: {e}")
return []
def main():
dm = DataManager()
print("Trayendo Postgre (Fact_Leads_Procesados)...")
pg = traer_postgre(dm)
print(f" Postgre: {len(pg)} filas")
print("Trayendo Supabase...")
sup = traer_supabase(dm)
print(f" Supabase: {len(sup)} filas")
todos = pg + sup
print(f"Total combinado (con repetidos): {len(todos)}")
# Dedup por teléfono: quedarse con el MÁS ANTIGUO.
# Desempate (misma fecha): preferir CANAL_PREFERIDO.
FUTURO = datetime(9999, 1, 1)
mejor = {}
for r in todos:
tel = r["telefono"]
f = r["fecha"] or FUTURO
actual = mejor.get(tel)
if actual is None:
mejor[tel] = r
continue
fa = actual["fecha"] or FUTURO
if f < fa:
mejor[tel] = r
elif f == fa:
# empate de fecha → preferir el canal preferido
if r["canal"] == CANAL_PREFERIDO and actual["canal"] != CANAL_PREFERIDO:
mejor[tel] = r
final = list(mejor.values())
print(f"Teléfonos únicos (sin repetir): {len(final)}")
# Exportar a Excel
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Base_Junta"
ws.append(["Telefono", "Canal", "Fecha Creada", "Sede", "Programa", "Codigo"])
for r in sorted(final, key=lambda x: (x["fecha"] or FUTURO)):
fecha = r["fecha"].strftime("%d/%m/%Y") if r["fecha"] and r["fecha"] != FUTURO else ""
ws.append([r["telefono"], r["canal"], fecha,
r.get("sede", ""), r.get("programa", ""), r.get("codigo", "")])
salida = os.path.join(os.path.dirname(__file__), "base_junta.xlsx")
wb.save(salida)
print(f"\n✅ Exportado: {salida}")
print(f" Filas: {len(final)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
# export_campanias.py — Exporta TODA la tabla campanias de Supabase a Excel/CSV.
# Ejecutar dentro de backend/: python export_campanias.py
import os, csv, requests
from dotenv import load_dotenv
load_dotenv()
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
def traer_todo():
filas, paso, desde = [], 1000, 0
while True:
hdr = dict(HEAD)
hdr["Range-Unit"] = "items"
hdr["Range"] = f"{desde}-{desde + paso - 1}"
r = requests.get(f"{URL}/rest/v1/{TABLA}",
params={"select": "*"},
headers=hdr, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
filas.extend(data)
print(f" descargadas: {len(filas)}", end="\r")
desde += len(data) # avanzar por lo realmente recibido
if len(data) < paso:
break
print()
return filas
print(f"Descargando '{TABLA}' de Supabase...")
filas = traer_todo()
print(f"Total: {len(filas)} filas")
if not filas:
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
raise SystemExit
# columnas = union de todas las claves, en orden de la primera fila
cols = list(filas[0].keys())
for f in filas:
for k in f.keys():
if k not in cols:
cols.append(k)
# 1) CSV siempre
csv_path = "campanias_export.csv"
with open(csv_path, "w", newline="", encoding="utf-8-sig") as fout:
w = csv.DictWriter(fout, fieldnames=cols)
w.writeheader()
for f in filas:
w.writerow({c: f.get(c, "") for c in cols})
print(f"CSV generado: {csv_path}")
# 2) Excel si openpyxl esta disponible
try:
from openpyxl import Workbook
wb = Workbook(); ws = wb.active; ws.title = "campanias"
ws.append(cols)
for f in filas:
ws.append([f.get(c, "") for c in cols])
xlsx_path = "campanias_export.xlsx"
wb.save(xlsx_path)
print(f"Excel generado: {xlsx_path}")
except ImportError:
print("(openpyxl no instalado -> solo CSV. Para Excel: pip install openpyxl)")

64
backend/export_cartera.py Normal file
View File

@@ -0,0 +1,64 @@
# export_cartera.py — Exporta TODA la tabla cartera_junta de Supabase a Excel/CSV.
# Ejecutar dentro de backend/: python export_cartera.py
import os, csv, requests
from dotenv import load_dotenv
load_dotenv()
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
TABLA = os.getenv("SUPABASE_TABLA_CARTERA", "cartera_junta")
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def traer_todo():
filas, paso, desde = [], 1000, 0
while True:
r = requests.get(f"{URL}/rest/v1/{TABLA}",
params={"select": "*", "offset": str(desde), "limit": str(paso)},
headers=HEAD, timeout=120)
r.raise_for_status()
data = r.json()
if not data:
break
filas.extend(data)
print(f" descargadas {len(filas)} filas...")
if len(data) < paso:
break
desde += paso
return filas
print(f"Descargando '{TABLA}' de Supabase...")
filas = traer_todo()
print(f"Total: {len(filas)} filas")
if not filas:
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
raise SystemExit
# columnas = union de todas las claves, en orden de la primera fila
cols = list(filas[0].keys())
for f in filas:
for k in f.keys():
if k not in cols:
cols.append(k)
# 1) CSV siempre
csv_path = "cartera_junta_export.csv"
with open(csv_path, "w", newline="", encoding="utf-8-sig") as fout:
w = csv.DictWriter(fout, fieldnames=cols)
w.writeheader()
for f in filas:
w.writerow({c: f.get(c, "") for c in cols})
print(f"CSV generado: {csv_path}")
# 2) Excel si openpyxl esta disponible
try:
from openpyxl import Workbook
wb = Workbook(); ws = wb.active; ws.title = "cartera_junta"
ws.append(cols)
for f in filas:
ws.append([f.get(c, "") for c in cols])
xlsx_path = "cartera_junta_export.xlsx"
wb.save(xlsx_path)
print(f"Excel generado: {xlsx_path}")
except ImportError:
print("(openpyxl no instalado -> solo CSV. Para Excel: pip install openpyxl)")

View File

@@ -0,0 +1,73 @@
# export_roas_excel.py
# Genera un Excel de la tabla "Inversión por Sede" (ROAS) con el filtro
# Año=2026, Mes=Marzo (3), Día=TODOS — igual que el dashboard.
# Columnas: Sede | Programa | Conjunto | Importe Gastado | Resultados
# Usa la MISMA función roas_dashboard() del dashboard (no reinventa la logica).
# Ejecutar en backend/: python export_roas_excel.py
import services as S
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
ANO, MES, DIA = "2026", "3", "TODOS" # Marzo 2026
data = S.roas_dashboard(ANO, MES, DIA)
filas = data.get("filas", [])
# ── Aplanar a filas: Sede / Programa / Conjunto / Importe / Resultados ──
rows = []
for f in filas:
sede = f.get("sede", "")
# nivel sede (total de la sede)
rows.append([sede, "", "", f.get("importe", 0), f.get("resultados", 0), "sede"])
for p in (f.get("programas") or []):
prog = p.get("programa", "")
# nivel programa (total del programa)
rows.append([sede, prog, "", p.get("importe", 0), p.get("resultados", 0), "prog"])
for c in (p.get("conjuntos") or []):
rows.append([sede, prog, c.get("conjunto", ""),
c.get("importe", 0), c.get("resultados", 0), "conj"])
# ── Excel ──
wb = Workbook()
ws = wb.active
ws.title = "ROAS Marzo 2026"
azul = PatternFill("solid", fgColor="1E3A5F")
blanco = Font(bold=True, color="FFFFFF")
fill_sede = PatternFill("solid", fgColor="DBEAFE")
fill_prog = PatternFill("solid", fgColor="F1F5F9")
bord = Border(*(Side(style="thin", color="D0D7E2"),)*4)
cen = Alignment(horizontal="center", vertical="center")
izq = Alignment(horizontal="left", vertical="center")
cab = ["Sede", "Programa", "Conjunto", "Importe Gastado", "Resultados"]
for j, t in enumerate(cab, 1):
c = ws.cell(1, j, t)
c.fill = azul; c.font = blanco; c.alignment = cen; c.border = bord
r = 2
for sede, prog, conj, imp, res, nivel in rows:
ws.cell(r, 1, sede)
ws.cell(r, 2, prog)
ws.cell(r, 3, conj)
ci = ws.cell(r, 4, round(float(imp or 0), 2)); ci.number_format = '"$"#,##0.00'
cr = ws.cell(r, 5, int(res or 0)); cr.number_format = '#,##0'
for j in range(1, 6):
cell = ws.cell(r, j); cell.border = bord
cell.alignment = izq if j <= 3 else cen
if nivel == "sede":
cell.fill = fill_sede; cell.font = Font(bold=True)
elif nivel == "prog":
cell.fill = fill_prog; cell.font = Font(bold=True, color="475569")
r += 1
# anchos
for col, w in zip("ABCDE", [16, 14, 46, 16, 14]):
ws.column_dimensions[col].width = w
ws.freeze_panes = "A2"
out = "ROAS_Marzo_2026.xlsx"
wb.save(out)
print(f"\nOK -> {out}")
print(f"Filas totales: {len(rows)} (sedes: {len(filas)})")
print("Columnas: Sede | Programa | Conjunto | Importe Gastado | Resultados")

894
backend/leads_logic.py Normal file
View File

@@ -0,0 +1,894 @@
# backend/leads_logic.py
"""
Lógica del módulo LEADS. Traduce las medidas DAX del PBI a Python.
Columnas calculadas replicadas:
- Cantidad_Veces -> 1ª aparición de un teléfono = lead único
- Ultima_Etiqueta / ESTADO/OBJECION -> limpieza de cached_label_list
- Tipo_Programa / Sede -> clasificación por dsc_programa
Medidas replicadas:
- Leads_Totales_Pauta_Unico, Leads_Procesados_Pauta_Unicos,
Leads_Procesados_Contactados_Unicos, % Procesados, % Contactados,
Total_Matriculas, Cant_inscritos_Mes, Ocupabilidad,
Cursos (Inicios / Suspendidos / Ya Iniciados), Matrículas por día,
tabla Estado/Objeción.
"""
from datetime import datetime, date
# Etiquetas de sistema que se eliminan para hallar el estado/objeción real (igual que el DAX)
ETIQUETAS_SISTEMA = {
"atención_humana", "negociación", "supervisor", "grupo_arequipa",
"grupo_trujillo", "sin_respuesta",
}
META_POR_TIPO = {
"PROGRAMAS TEAC": 22, "PROGRAMAS TERC": 22,
"PROVINCIA TEAC": 18, "PROVINCIA TERC": 18,
"SEMINARIOS": 15,
}
# ── Helpers de fecha ────────────────────────────────────────────
def _to_date(v):
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
s = str(v)[:10]
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).date()
except Exception:
pass
return None
def _en_periodo(d, ano, mes, dia):
"""True si la fecha cae en el filtro (ano/mes/dia; 'TODOS' = sin filtrar)."""
if d is None:
return False
if ano not in ("TODOS", None) and d.year != int(ano):
return False
if mes not in ("TODOS", None) and d.month != int(mes):
return False
if dia not in ("TODOS", None) and d.day != int(dia):
return False
return True
# ── Clasificadores (igual que columnas calculadas DAX) ──────────
def clasificar_tipo_programa(dsc_programa):
up = str(dsc_programa or "").upper()
otros = ["CERTIFICACIÓN", "CERTIFICACION", "CURSO A MEDIDA", "MASTERCLASS",
"TALLER DE REFRIGERACIÓN DOM", "GESTIÓN DE VENTA", "GESTION DE VENTA"]
for o in otros:
if o.upper() in up:
return "OTROS"
return None # el tipo PROGRAMAS/PROVINCIA se arma con sede+programa abajo
def clasificar_sede(dsc_programa):
up = str(dsc_programa or "").upper()
if "AREQUIPA" in up: return "AREQUIPA"
if "TRUJILLO" in up: return "TRUJILLO"
if "PIURA" in up: return "PIURA"
return "LIMA"
def sede_por_codigo_map():
"""{codigo: SEDE} tomado del mapa de campañas (la sede que le corresponde a
cada código, SIN reasignar por asesor). Usado por el filtro SEDE de leads,
para que coincida con 'sede del código' (no con sede_act)."""
from data_manager_v2 import CAMPANIAS
out = {}
for fila in CAMPANIAS:
cod = str(fila[2]).strip() # índice 2 = codigo
sede = str(fila[3]).strip().upper() # índice 3 = sede
if cod:
out[cod] = sede
return out
# ── Grupo de PROGRAMA para el filtro: TEAC / TERC / SEMINARIOS / OTROS ──
def grupo_programa_curso(dsc_programa):
"""Reduce el tipo detallado a 4 grupos para el filtro PROGRAMA (cursos/matrículas).
TEAC = PROGRAMAS TEAC + PROVINCIA TEAC
TERC = PROGRAMAS TERC + PROVINCIA TERC
SEMINARIOS = SEMINARIOS
OTROS = OTROS + CARRERA"""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROVINCIA TEAC"):
return "TEAC"
if tp in ("PROGRAMAS TERC", "PROVINCIA TERC"):
return "TERC"
if tp == "SEMINARIOS":
return "SEMINARIOS"
return "OTROS" # OTROS + CARRERA
def grupo_programa_lead(cargo):
"""Grupo de programa para un LEAD, según su 'cargo' (del mapa de campañas).
TEAC → TEAC, TERC → TERC, resto (VRF/CO2/DIPLOMADO/etc.) → SEMINARIOS."""
c = str(cargo or "").strip().upper()
if c == "TEAC":
return "TEAC"
if c == "TERC":
return "TERC"
return "SEMINARIOS"
def grupo_tipo_cohorte(tp):
"""Reduce 'PROGRAMAS TEAC'/'PROVINCIA TEAC'/... a los 3 grupos de la tabla por Sede:
TEAC = PROGRAMAS/PROVINCIA TEAC, TERC = PROGRAMAS/PROVINCIA TERC, resto = SEMINARIOS."""
t = str(tp or "").upper()
if "TEAC" in t:
return "TEAC"
if "TERC" in t:
return "TERC"
return "SEMINARIOS"
# Réplica del DAX Sede_Act: la sede del lead se decide por el ASESOR;
# si el asesor no está en la lista, usa la sede de la campaña.
_SEDE_POR_ASESOR = {
"almendra peralta": "Arequipa",
"juan carlos aguilar": "Piura",
"diego lázaro": "Trujillo",
"diego lazaro": "Trujillo",
"verónica la rosa": "Lima",
"veronica la rosa": "Lima",
"dayana balabarca": "Lima",
"milagros vargas": "Lima",
"carmen montoya": "Lima",
"diana chávez": "Lima",
"diana chavez": "Lima",
"copito rivera": "Lima",
}
def sede_act(asesor, sede_campania):
return _SEDE_POR_ASESOR.get(str(asesor or "").strip().lower(), sede_campania)
def tipo_programa_cohorte(sede, programa_cat):
"""Igual al DAX 'Tipo Programa': combina sede + (TEAC/TERC) -> categoría."""
s = str(sede or "").upper()
p = str(programa_cat or "").upper()
if s == "LIMA" and p == "TEAC": return "PROGRAMAS TEAC"
if s == "LIMA" and p == "TERC": return "PROGRAMAS TERC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TEAC": return "PROVINCIA TEAC"
if s in ("TRUJILLO", "PIURA", "AREQUIPA") and p == "TERC": return "PROVINCIA TERC"
return "SEMINARIOS"
def tipo_programa_curso(dsc_programa):
"""Réplica EXACTA del DAX Tipo_Programa (columna calculada de Fact_SQL_Base_Cursos).
Usa CONTAINSSTRING en el mismo ORDEN que el PBI (el orden importa)."""
p = str(dsc_programa or "")
def has(s): # CONTAINSSTRING es sensible a may/min en DAX; comparamos tal cual
return s in p
# 1) OTROS
if (has("CERTIFICACIÓN") or has("CURSO A MEDIDA") or has("MASTERCLASS")
or has("TALLER DE REFRIGERACIÓN DOM")
or has("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN")):
return "OTROS"
if (has("CERTIFICACION FUNDAMENTOS DE CHILLER MODULAR INVERT- LG")
or has("PIURA - CERTIFICACIÓN MIDEA: TECNOLOGIA INVERTER")
or has("AREQUIPA - CERTIFICACIÓN MIDEA - TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN: OPERACIÓN Y MANTENIMIENTO DE REFRIGERADORES MIDEA")
or has("TRUJILLO - CERTIFICACIÓN MIDEA: TECNOLOGÍA INVERTER")
or has("CERTIFICACIÓN MIDEA: AIRE ACOND INVERTER (INTROD., FUNC., INST. Y MANT.)")):
return "OTROS"
# 2) CARRERA
if has("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION"):
return "CARRERA"
# 3) PROVINCIA TEAC
if (has("TRUJILLO - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("PIURA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("AREQUIPA - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TRUJILLO - VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROVINCIA TEAC"
# 4) PROVINCIA TERC
if (has("AREQUIPA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("PIURA - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")
or has("TRUJILLO - TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL")):
return "PROVINCIA TERC"
# 5) SEMINARIOS
if (has("CO2") or has("CERTIF") or has("SEM:") or has("MASTERCLASS") or has("DISEÑO")
or has("DIPLOMADO") or has("SEM.") or has("DUCTOS") or has("SEMINARIO") or has("SEMINARIOS")):
return "SEMINARIOS"
# 6) PROGRAMAS TEAC (Lima)
if (has("MANTENIMIENTO EN AIRE ACONDICIONADO")
or has("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("TALLER - TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN AA (4M)")
or has("VIRTUAL TECNICO ESPECIALISTA EN AIRE ACONDICIONADO")):
return "PROGRAMAS TEAC"
# 7) PROGRAMAS TERC (Lima)
if (has("INSTALACION EN REFRIGERACION COMERCIAL")
or has("MANTENIMIENTO EN REFRIGERACIÓN COMERCIAL")
or has("CONTINUIDAD-TECNICO ESPECIALISTA EN REFRIGERACIÓN COMERCIAL (4M)")
or has("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL")):
return "PROGRAMAS TERC"
return "SEMINARIOS"
def meta_curso(dsc_programa):
"""Meta por curso según Tipo_Programa (igual que el DAX Meta_Curso):
PROGRAMAS TEAC/TERC=22, PROVINCIA TEAC/TERC=18, resto=15."""
tp = tipo_programa_curso(dsc_programa)
if tp in ("PROGRAMAS TEAC", "PROGRAMAS TERC"):
return 22
if tp in ("PROVINCIA TEAC", "PROVINCIA TERC"):
return 18
return 15
# ── ESTADO/OBJECION (réplica EXACTA del DAX: cadena de SUBSTITUTE) ──
# El DAX quita comas y espacios (pega todas las etiquetas), elimina las de
# sistema, y luego reduce combinaciones concatenadas a un estado final.
_SUST = [
("atención_humana", ""), ("negociación", ""), ("supervisor", ""),
("grupo_arequipa", ""), ("grupo_trujillo", ""), ("sin_respuesta", ""),
("interesadovendido", "vendido"), ("próximo_inicio", "proxima_fecha"),
("inicio", ""), ("aprobación", ""), ("negociacion", ""), ("no_acepta", ""),
("pendiente", ""),
("revisando_informaciónsólo_consulta", "sólo_consulta"),
("contacto_iniciadovendido", "vendido"),
("revisando_informacióninteresado", "interesado"),
("revisando_informaciónvendido", "vendido"),
("sólo_consultainteresado", "interesado"),
("revisando_informaciónno_califica", "no_califica"),
("interesadopor_pagar", "por_pagar"),
("revisando_informaciónprecio_elevado", "precio_elevado"),
("proxima_fechavendido", "vendido"),
("sólo_consultavendido", "vendido"),
("vendidointeresado", "vendido"),
("revisando_informaciónproxima_fecha", "proxima_fecha"),
("contacto_iniciadosólo_consulta", "sólo_consulta"),
]
def estado_objecion(etiquetas):
"""Réplica del DAX ESTADO/OBJECION (versión de 28 sustituciones del PBI)."""
if etiquetas is None:
return "no trabajado"
s = str(etiquetas).replace(",", "").replace(" ", "")
for buscar, reemplazar in _SUST:
s = s.replace(buscar, reemplazar)
s = s.strip()
return s if s else "no trabajado"
# ── Ultima_Etiqueta (réplica EXACTA del DAX, usada para "Total Contactados") ──
_ULTIMA_NO_TRABAJADO = {
"", "(en blanco)", "aprobación", "atencion humana", "atención humana",
"grupo arequipa", "grupo trujillo", "importacion masiva", "inicio",
"negociacion", "sin respuesta", "pendiente",
}
def ultima_etiqueta(etiquetas):
if etiquetas is None or str(etiquetas).strip() == "":
return "NO TRABAJADO"
partes = str(etiquetas).split(", ")
limpia = partes[-1].replace("_", " ").strip()
if limpia in _ULTIMA_NO_TRABAJADO:
return "NO TRABAJADO"
return limpia if limpia else "NO TRABAJADO"
# ════════════════════════════════════════════════════════════════
# PROCESAMIENTO DE LEADS (calcula Cantidad_Veces y agrupa)
# ════════════════════════════════════════════════════════════════
def procesar_leads(filas_chatwoot):
"""Marca el lead único (1ª vez de cada teléfono) y arma estructura limpia.
Ordenamos por fecha para que la 1ª aparición de cada teléfono sea la 'única'."""
# Ordenar por fecha ascendente (el query puede venir ordenado por id)
filas_chatwoot = sorted(filas_chatwoot, key=lambda f: (_to_date(f.get("fecha_creada")) or date.min))
# Teléfonos de prueba a excluir (igual que el PBI: 924374783)
EXCLUIR = {"924374783"}
vistos = set()
out = []
for f in filas_chatwoot:
tel = str(f.get("telefono") or "").strip()
if not tel or tel in EXCLUIR:
continue
es_unico = tel not in vistos
vistos.add(tel)
fecha = _to_date(f.get("fecha_creada"))
asesor = (f.get("asesor") or "").strip()
sede_campania = (f.get("sede") or "").strip() or "SIN SEDE"
sede = sede_act(asesor, sede_campania) # réplica DAX Sede_Act (reasigna por asesor)
cargo = (f.get("programa") or "").strip() # TEAC / TERC / VRF / etc. (del mapa campañas)
out.append({
"telefono": tel,
"asesor": asesor,
"fecha": fecha,
"estado": estado_objecion(f.get("etiquetas")),
"ultima": ultima_etiqueta(f.get("etiquetas")),
"sede": sede,
"cargo": cargo, # TEAC / TERC / VRF / CO2 / etc.
"tipo_programa": tipo_programa_cohorte(sede, cargo), # PROGRAMAS/PROVINCIA/SEMINARIOS
"codigo": (f.get("codigo") or "").strip() or "-", # código de campaña
"es_unico": es_unico,
})
return out
# ════════════════════════════════════════════════════════════════
# KPIs DEL MÓDULO LEADS
# ════════════════════════════════════════════════════════════════
def kpis_leads(leads, cursos, matriculas, ano, mes, dia):
# Leads en el periodo (solo únicos = Cantidad_Veces == 1)
leads_periodo = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
recibidos = len(leads_periodo)
procesados = sum(1 for l in leads_periodo if l["asesor"])
contactados = sum(1 for l in leads_periodo if l["asesor"] and l["ultima"] != "NO TRABAJADO")
pct_procesados = (procesados / recibidos) if recibidos > 0 else 0.0
pct_contactados = (contactados / procesados) if procesados > 0 else 0.0
# ── Cursos del periodo (por fecha de inicio) ──
cursos_periodo = [c for c in cursos if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)]
cursos_programados = len(cursos_periodo)
suspendidos = sum(1 for c in cursos_periodo if str(c.get("cod_estado", "")).strip().upper() == "SUS")
hoy = date.today()
iniciados = sum(1 for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and (_to_date(c.get("fch_inicio")) or hoy) <= hoy)
# ── Total matriculados (= medida Matriculas_Totales del PBI) ──
# Cuenta matrículas ALU/PRE cuya FECHA DE MATRÍCULA cae en el mes,
# excluyendo 2 vendedoras. El Calendario del PBI va por fch_matricula.
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
total_matriculados = sum(
1 for m in matriculas
if str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and str(m.get("dsc_vendedor", "")).strip() not in VENDEDORES_EXCLUIDOS
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)
)
# Ocupabilidad (PBI): SUM(Inscritos) / SUM(Meta).
# Excluye cursos suspendidos (SUS) y num_indice excluidos manualmente.
# (Ver FUTUROS_CAMBIOS.md: mover esta lista a GitHub/Supabase)
CURSOS_EXCLUIDOS = {"1154", "1121"}
cursos_ocup = [c for c in cursos_periodo
if str(c.get("cod_estado", "")).strip().upper() != "SUS"
and str(c.get("num_indice", "")).strip() not in CURSOS_EXCLUIDOS]
sum_meta = sum(meta_curso(c.get("dsc_programa")) for c in cursos_ocup)
sum_inscritos_cursos = sum(int(c.get("Inscritos_Totales", 0) or 0) for c in cursos_ocup)
# ── Matrículas en los cursos del mes ──
# Matrículas ALU/PRE que: (1) se hicieron en el mes (fch_matricula) Y
# (2) son de un curso que INICIA en el mes (dsc_promocion = dsc_det_programa
# de los cursos del periodo). Réplica de Cant_inscritos_Mes del PBI.
promos_periodo = {str(c.get("dsc_det_programa", "")).strip()
for c in cursos_periodo if str(c.get("dsc_det_programa", "")).strip()}
mats_mes = [m for m in matriculas
if str(m.get("dsc_promocion", "")).strip() in promos_periodo
and str(m.get("estado_matricula", "")).strip().upper() in ("ALU", "PRE")
and _en_periodo(_to_date(m.get("fch_matricula")), ano, mes, dia)]
matriculas_mes = len({str(m.get("num_matricula")) for m in mats_mes})
ocupabilidad = (sum_inscritos_cursos / sum_meta) if sum_meta > 0 else 0.0
return {
"leads_recibidos": recibidos,
"leads_procesados": procesados,
"leads_contactados": contactados,
"pct_procesados": round(pct_procesados * 100, 2),
"pct_contactados": round(pct_contactados * 100, 2),
"total_matriculados": total_matriculados,
"matriculas_mes": matriculas_mes,
"ocupabilidad": round(ocupabilidad * 100, 2),
"cursos_programados": cursos_programados,
"cursos_reprogramados": 0, # requiere SharePoint (fase 2)
"cursos_suspendidos": suspendidos,
"cursos_iniciados": iniciados,
}
# ── Tabla Estado/Objeción (agrupa por Ultima_Etiqueta + desglose por asesor) ──
def tabla_estado_objecion(leads, ano, mes, dia):
leads_periodo = [l for l in leads if l["es_unico"]
and l["asesor"] and _en_periodo(l["fecha"], ano, mes, dia)]
agg = {} # estado -> total
por_asesor = {} # estado -> {asesor: cantidad}
telefonos = {} # (estado, asesor) -> [telefonos]
for l in leads_periodo:
est = l["ultima"] or "NO TRABAJADO"
ase = l["asesor"] or "SIN ASESOR"
agg[est] = agg.get(est, 0) + 1
por_asesor.setdefault(est, {})
por_asesor[est][ase] = por_asesor[est].get(ase, 0) + 1
telefonos.setdefault((est, ase), []).append(l["telefono"])
filas = []
for estado in sorted(agg.keys(), key=lambda x: -agg[x]): # mayor a menor por cantidad
asesores = sorted(por_asesor[estado].items(), key=lambda x: -x[1])
filas.append({
"estado": estado,
"cantidad": agg[estado],
"asesores": [
{"asesor": a, "cantidad": c,
"telefonos": sorted(telefonos.get((estado, a), []))}
for a, c in asesores
],
})
total = sum(agg.values())
return {"filas": filas, "total": total}
# ── Matrículas por día (gráfico de línea) ──
# Cuenta TODAS las matrículas del día (misma base que Total Matriculados):
# estado ALU/PRE, excluyendo las 2 vendedoras, por día de fch_matricula.
def matriculas_por_dia(matriculas, cursos, ano, mes, dia):
VENDEDORES_EXCLUIDOS = {"CALDERON S. LISSA GENA", "CRUZ G. FIORELLA MELISSA"}
conteo = {} # dia -> total
por_tipo = {} # dia -> {tipo_programa: cantidad}
for m in matriculas:
if str(m.get("estado_matricula", "")).strip().upper() not in ("ALU", "PRE"):
continue
if str(m.get("dsc_vendedor", "")).strip() in VENDEDORES_EXCLUIDOS:
continue
d = _to_date(m.get("fch_matricula"))
if not _en_periodo(d, ano, mes, dia):
continue
conteo[d.day] = conteo.get(d.day, 0) + 1
tp = tipo_programa_curso(m.get("dsc_programa")) # TEAC/TERC/PROVINCIA.../SEMINARIOS/OTROS
por_tipo.setdefault(d.day, {})
por_tipo[d.day][tp] = por_tipo[d.day].get(tp, 0) + 1
def _detalle(day):
items = sorted(por_tipo.get(day, {}).items(), key=lambda x: -x[1])
return [{"tipo": t, "cantidad": c} for t, c in items]
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
return [{"dia": d, "cantidad": conteo.get(d, 0), "detalle": _detalle(d)}
for d in range(1, ndias + 1)]
return [{"dia": d, "cantidad": conteo[d], "detalle": _detalle(d)}
for d in sorted(conteo.keys())]
# ── Gráfico "Leads por Programa": serie diaria Totales vs Procesados ──
def leads_por_dia(leads, ano, mes, dia):
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
tot = {} # dia -> totales
proc = {} # dia -> procesados (con asesor)
for l in lp:
d = l["fecha"].day
tot[d] = tot.get(d, 0) + 1
if l["asesor"]:
proc[d] = proc.get(d, 0) + 1
import calendar
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
ndias = calendar.monthrange(int(ano), int(mes))[1]
dias = range(1, ndias + 1)
else:
dias = sorted(set(list(tot.keys()) + list(proc.keys())))
return [{"dia": d, "totales": tot.get(d, 0), "procesados": proc.get(d, 0)} for d in dias]
# ── Tabla por Sede_Act -> Tipo_Programa con 4 medidas (cohorte de leads) ──
def tabla_pauta(leads, matriculas, ano, mes, dia, importe_por_pauta_periodo=None,
resultados_por_pauta_periodo=None, campanias_sede_cargo=None):
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
resultados_por_pauta_periodo = resultados_por_pauta_periodo or {}
campanias_sede_cargo = campanias_sede_cargo or {}
# 1) Leads únicos del periodo con su teléfono, fecha, sede y tipo
lp = [l for l in leads if l["es_unico"] and _en_periodo(l["fecha"], ano, mes, dia)]
# mapa teléfono -> fecha del lead (para "matrícula posterior al lead")
fecha_lead = {}
for l in lp:
fecha_lead.setdefault(l["telefono"], l["fecha"])
# 2) Matrículas que cruzan por teléfono y son POSTERIORES a la fecha del lead
# (réplica de Matriculas_Cohorte_Lead e Inversion_x_Pauta_Cohorte)
mat_por_tel = {} # telefono -> {"mats": set(num_matricula), "inv": float}
for m in matriculas:
tel = str(m.get("dsc_telefono_1") or "").strip()
if tel not in fecha_lead:
continue
fm = _to_date(m.get("fch_matricula"))
fl = fecha_lead[tel]
if not fm or not fl or not (fm > fl):
continue
# INV_NETA_FINAL: si DOL, ×3.34
try: inv = float(m.get("INV_NETA", 0) or 0)
except: inv = 0.0
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
inv *= 3.34
e = mat_por_tel.setdefault(tel, {"mats": set(), "inv": 0.0})
e["mats"].add(str(m.get("num_matricula")))
e["inv"] += inv
# 3) Agrupar por Sede -> Tipo_Programa -> Código
def _nuevo():
return {"recibidos": 0, "procesados": 0, "mats": set(), "inv": 0.0}
grupos = {} # sede -> tipo -> codigo -> métricas
for l in lp:
sede = l["sede"]; tp = grupo_tipo_cohorte(l["tipo_programa"]); cod = l.get("codigo", "-"); tel = l["telefono"]
g = grupos.setdefault(sede, {}).setdefault(tp, {}).setdefault(cod, _nuevo())
g["recibidos"] += 1
if l["asesor"]:
g["procesados"] += 1
if tel in mat_por_tel:
g["mats"] |= mat_por_tel[tel]["mats"]
g["inv"] += mat_por_tel[tel]["inv"]
def _fila(d):
return {"recibidos": d["recibidos"], "procesados": d["procesados"],
"matriculas": len(d["mats"]), "inversion": round(d["inv"], 0)}
# IMPORTE y RESULTADOS por PAUTA -> se asignan a su (sede, tipo) de CAMPANIAS,
# una sola vez por pauta (no por sede del lead), para NO duplicar.
# imp_st[(sede,tipo)][cod] = importe ; res_st[(sede,tipo)][cod] = resultados
imp_st = {}
res_st = {}
for cod, imp_val in importe_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
imp_st.setdefault((se, tp), {})[str(cod).strip()] = float(imp_val or 0.0)
for cod, res_val in resultados_por_pauta_periodo.items():
info = campanias_sede_cargo.get(str(cod).strip())
if not info:
continue
se = str(info.get("sede") or "").upper()
tp = grupo_tipo_cohorte(tipo_programa_cohorte(se, info.get("cargo")))
res_st.setdefault((se, tp), {})[str(cod).strip()] = int(res_val or 0)
filas = []
for sede in sorted(grupos.keys()):
subfilas = []
s_rec = s_proc = 0; s_mats = set(); s_inv = 0.0; s_imp = 0.0; s_res = 0
for tp in sorted(grupos[sede].keys()):
cods = grupos[sede][tp]
imp_map = imp_st.get((sede.upper(), tp), {}) # importe por pauta de esta sede/tipo
res_map = res_st.get((sede.upper(), tp), {})
# métricas del tipo (sumando sus códigos)
t_rec = t_proc = 0; t_mats = set(); t_inv = 0.0; t_imp = 0.0; t_res = 0
codigos = []
for cod in sorted(cods.keys()):
d = cods[cod]
imp_cod = float(imp_map.get(str(cod).strip(), 0.0))
res_cod = int(res_map.get(str(cod).strip(), 0))
codigos.append({"codigo": cod, **_fila(d), "importe": round(imp_cod, 2),
"resultados": res_cod})
t_rec += d["recibidos"]; t_proc += d["procesados"]
t_mats |= d["mats"]; t_inv += d["inv"]; t_imp += imp_cod; t_res += res_cod
subfilas.append({
"tipo": tp, "recibidos": t_rec, "procesados": t_proc,
"matriculas": len(t_mats), "inversion": round(t_inv, 0),
"importe": round(t_imp, 2), "resultados": t_res, "codigos": codigos,
})
s_rec += t_rec; s_proc += t_proc; s_mats |= t_mats; s_inv += t_inv; s_imp += t_imp; s_res += t_res
filas.append({
"sede": sede, "recibidos": s_rec, "procesados": s_proc,
"matriculas": len(s_mats), "inversion": round(s_inv, 0),
"importe": round(s_imp, 2), "resultados": s_res, "subfilas": subfilas,
})
# Totales
t_rec = sum(f["recibidos"] for f in filas)
t_proc = sum(f["procesados"] for f in filas)
t_inv = round(sum(f["inversion"] for f in filas), 0)
# Total de importe/resultados: suma de TODAS las pautas que estan en campanias
# (una vez cada una), aunque no tengan fila con leads. Asi el total no se pierde.
t_imp = round(sum(float(v or 0.0) for cod, v in importe_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo), 2)
t_res = sum(int(v or 0) for cod, v in resultados_por_pauta_periodo.items()
if str(cod).strip() in campanias_sede_cargo)
# matrículas total: unión global
all_mats = set()
for tel, e in mat_por_tel.items():
all_mats |= e["mats"]
return {"filas": filas, "total": {"recibidos": t_rec, "procesados": t_proc,
"matriculas": len(all_mats), "inversion": t_inv,
"importe": t_imp, "resultados": t_res}}
# ── Personalizado: replica la columna Fact_SQL_Base_Cursos[Personalizado] (M) ──
# Base = dsc_det_programa, limpiando saltos de línea, luego cadena de reemplazos.
_PERS_REEMPLAZOS = [
("TECNICO ESPECIALISTA EN AIRE ACONDICIONADO", "TEAC"),
("TECNICO ESPECIALISTA EN REFRIGERACION COMERCIAL", "TERC"),
("TECNICO ESPECIALISTA EN REFRIGERACION DOMÉSTICA Y COMERCIAL", "TERC"),
("CARRERA TECNICA DE AIRE ACONDICIONADO Y REFRIGERACION ", "CARRERA"),
("VIRTUAL SEMINARIO SUPERVISION DE OBRAS EN AIRE ACONDICIONADO", "SUPERVISION DE OBRAS"),
("SEMINARIO APLICACIÓN DE VARIADORES DE FRECUENCIA EN SISTEMAS HVAC", "VARIADORES DE FRECUENCIA"),
("VIRTUAL - SISTEMAS DE REFRIGERACION CON CO2 - FASE SUBCRITICA Y TRANSCRITICA", "REFRIGERACION CON CO2"),
("VIRTUAL SEMINARIO DISEÑO DE CAMARAS DE REFRIGERACION CON SISTEMAS CON FREON", "DISEÑO DE CAMARAS"),
("VIRTUAL SEM. DETERMINACIÓN DE CAPACIDAD DE EQUIPOS DE AIRE ACONDICIONADO- CARGAS TÉRMICAS -(A/A)", "CARGAS TERMICAS"),
("VIRTUAL SEMINARIO DIBUJO TECNICO Y DISEÑO ASISTIDO POR COMPUTADORA (CAD) PARA HVAC -(A/A)", "DIBUJO TECNICO (CAD)"),
("VIRTUAL SEMINARIO: SISTEMAS DE REFRIGERACIÓN INDUSTRIAL POR AMONIACO (NH3)", "AMONIACO (NH3)"),
("SEMINARIO VIRTUAL METRADO , COSTEO Y PRESUPUESTOS DE HVAC (AA)", "METRADO, COSTEO Y PRESUPUESTOS"),
("CARRERA TECNICA LIMA GESTION DE EMPRESAS", "GESTION DE EMPRESAS"),
("VIRTUAL SEMINARIO DISEÑO DE CHILLERS PARA PROCESOS INDUSTRIALES DE REFRIGERACIÓN", "DISEÑO DE CHILLERS"),
("VIRTUAL SEMINARIO PRACTICO VENTILACION DE SOTANO Y PREZURIZACION DE ESCALERA -(A/A)", "VENTILACION DE SOTANO"),
("SEMINARIO USO DE SOFTWARE EN CÁLCULOS DE AIRE ACONDICIONADO", "SOFTWARE EN CALCULOS DE A/A"),
("DISEÑO DE SISTEMAS DE AIRE ACONDICIONADO", "DISEÑO DE SISTEMAS DE A/A"),
("SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE VRV/VRF - SAB", "VOLUMEN VARIABLE VRF"),
# Seminarios VRF por sede y CO2 → nombre corto (la frecuencia se conserva al final)
("PIURA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "PIURA - VRF"),
("AREQUIPA - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "AREQUIPA - VRF"),
("TRUJILLO - SEMINARIO INSTALACIÓN DE SISTEMAS DE A/A DE VOLUMEN VARIABLE - VRF", "TRUJILLO - VRF"),
("VIRTUAL - DISEÑO Y OPERACIÓN DE SISTEMAS DE REFRIGERACIÓN CON CO2 EN FASE SUBCRITICA Y TRANSCRITICA", "CO2"),
# Reemplazos sobre Nombre_Programa_Detalle (bloque ductos y otros)
("VIRTUAL - DIMENSIONAMIENTO DE", ""),
("METÁLICOS EN AIRE ACONDICIONADO Y VENTILACIÓN", ""),
("MASTERCLASS: PROGRAMACIÓN DE CONTROLADORES PARA REFRIGERACIÓN", "PROGRAMACION DE CONTROLADORES"),
("DIPLOMADO INTERNACIONAL DE AIRE ACONDICIONADO", "DIPLOMADO DE A/A"),
# Reemplazos finales (sedes/carreras/masterclass)
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "AREQUIPA - CARRERA - TARDE"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "PIURA - CARRERA - TARDE"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - TAR", "TRUJILLO - CARRERA - TARDE"),
("AREQUIPA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "AREQUIPA - CARRERA - MAÑANA"),
("PIURA - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "PIURA - CARRERA - MAÑANA"),
("TRUJILLO - CARRERA TÉCNICA DE AIRE ACONDICIONADO Y REFRIGERACIÓN - MAN", "TRUJILLO - CARRERA - MAÑANA"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - NOC", "GESTIÓN DE VENTA - NOC"),
("MASTERCLASS SELECCIÓN E INSTALACIÓN DE TARJETAS ELECTRÓNICAS UNIVERSALES EN EQUIPOS DE A/A .INVERTER - MAN", "MASTERCLASS TARJETAS UNIVERSALES - MAN"),
("MASTERCLASS: SISTEMAS DE A/A CON VRF - SAB", "MASTERCLASS: A/A CON VRF - SAB"),
("GESTIÓN DE VENTA DE EQUIPOS DE AIRE ACONDICIONADO Y EQUIPOS DE REFRIGERACIÓN - TAR", "GESTIÓN DE VENTA - TAR"),
]
def personalizado_curso(dsc_programa, cod_frecuencia=""):
"""Replica Fact_SQL_Base_Cursos[Personalizado] del PBI:
base = dsc_programa & ' - ' & cod_frecuencia (NO dsc_det_programa)."""
s = f"{str(dsc_programa or '')} - {str(cod_frecuencia or '')}"
# Text.Clean + reemplazar saltos de línea por espacio, luego Trim
s = s.replace("\r", " ").replace("\n", " ")
s = "".join(ch for ch in s if ch >= " " or ch == " ") # Text.Clean (quita control)
s = s.strip()
for buscar, reemplazo in _PERS_REEMPLAZOS:
s = s.replace(buscar, reemplazo)
# colapsar espacios múltiples que pudieran quedar de reemplazos vacíos
s = " ".join(s.split())
return s
# ── Matriz por Curso (num_indice): Personalizado + métricas de leads por pauta ──
# NOTA: las métricas de leads cruzan por el CÓDIGO DE PAUTA del curso, que viene de
# SharePoint (no disponible aún). Por eso salen en 0 hasta conectar esa fuente.
# Ver FUTUROS_CAMBIOS.md punto 5. Lo que sí se calcula: Personalizado y Fecha Inicio.
def matriz_cursos(cursos, leads, ano, mes, dia, pauta_map=None,
campanias_map=None, cartera_all=None, cartera_asig=None,
mat_por_indice=None, cartera_tels=None, cartera_origen=None,
importe_por_pauta=None, importe_por_pauta_periodo=None,
cartera_canal=None, conjunto_por_pauta=None, pautas_por_indice=None):
pauta_map = pauta_map or {}
pautas_por_indice = pautas_por_indice or {} # {num_indice: [pautas]} (muchos-a-muchos)
campanias_map = campanias_map or {}
cartera_all = cartera_all or {}
cartera_asig = cartera_asig or {}
mat_por_indice = mat_por_indice or {}
cartera_tels = cartera_tels or set()
cartera_origen = cartera_origen or {}
importe_por_pauta = importe_por_pauta or {}
importe_por_pauta_periodo = importe_por_pauta_periodo or {}
conjunto_por_pauta = conjunto_por_pauta or {} # {pauta: [conjuntos de anuncios]}
# cartera_canal = {"PAUTA": {"all","asig","tels"}, "OTROS": {...}} para el desglose por canal
cartera_canal = cartera_canal or {}
CURSOS_EXCLUIDOS = {"1121", "1154"}
def _excluir(c):
ni = str(c.get("num_indice", ""))
if ni in CURSOS_EXCLUIDOS:
return True
info = pauta_map.get(ni)
if not info:
return False # sin info en Supabase → no se excluye
# Excluir SUSPENDIDO o contar = NO
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
cursos_periodo = [c for c in cursos
if _en_periodo(_to_date(c.get("fch_inicio")), ano, mes, dia)
and not _excluir(c)]
# Leads únicos agrupados por codigo (= Pauta). Históricos y del mes (filtro fecha).
leads_unicos = [l for l in leads if l.get("es_unico")]
hist_por_cod = {} # codigo → set teléfonos
hist_ases_cod = {} # codigo → set teléfonos con asesor
mes_por_cod = {} # codigo → set teléfonos (en periodo filtrado)
mes_ases_cod = {} # codigo → set teléfonos con asesor (en periodo)
for l in leads_unicos:
cod = l.get("codigo", "-")
tel = l["telefono"]
hist_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
hist_ases_cod.setdefault(cod, set()).add(tel)
if _en_periodo(l["fecha"], ano, mes, dia):
mes_por_cod.setdefault(cod, set()).add(tel)
if l.get("asesor"):
mes_ases_cod.setdefault(cod, set()).add(tel)
# Construir filas con sus medidas
pre = []
for c in sorted(cursos_periodo, key=lambda x: (_to_date(x.get("fch_inicio")) or date.min)):
nombre = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
fch = _to_date(c.get("fch_inicio"))
ni = str(c.get("num_indice", "")).strip()
# LISTA de pautas del num_indice (muchos-a-muchos). Si no hay en la tabla nueva,
# usa la de basebi (compatibilidad).
cods = pautas_por_indice.get(ni)
if not cods:
_info = pauta_map.get(ni)
cods = [_info.get("pauta")] if _info and _info.get("pauta") else []
cods = [str(x).strip() for x in cods if x]
# Leads = union de telefonos de TODAS las pautas (sin duplicar)
def _union(mapa):
s = set()
for cd in cods:
s |= mapa.get(cd, set())
return len(s)
nuevos = _union(hist_por_cod)
nuevos_a = _union(hist_ases_cod)
nuevos_m = _union(mes_por_cod)
nuevos_ma = _union(mes_ases_cod)
pre.append({
"num_indice": ni,
"personalizado": nombre,
"fch": fch,
"pauta": cods[0] if cods else None, # pauta principal (para mostrar)
"pautas": cods, # todas las pautas (para sumar)
"fecha_inicio": fch.strftime("%d/%m/%Y") if fch else "",
"leads_nuevos": nuevos,
"leads_nuevos_asesor": nuevos_a,
"leads_nuevos_mes": nuevos_m,
"leads_nuevos_mes_asesor": nuevos_ma,
})
# Índice HISTÓRICO completo (TODOS los cursos, sin filtro de fecha) por
# personalizado → lista de (fecha, leads_nuevos). Para el acumulado.
hist_cursos = {}
for c in cursos:
if _excluir(c):
continue
nom = personalizado_curso(c.get("dsc_programa"), c.get("cod_frecuencia"))
f = _to_date(c.get("fch_inicio"))
info = pauta_map.get(str(c.get("num_indice", "")))
cd = info.get("pauta") if info else None
nv = len(hist_por_cod.get(cd, ())) if cd else 0
hist_cursos.setdefault(nom, []).append((f, nv))
# Leads_Acumulados_Historico: suma de leads_nuevos de TODOS los cursos del
# MISMO personalizado con fecha <= la del curso actual (histórico completo).
filas = []
for r in pre:
acum = sum(nv for (f, nv) in hist_cursos.get(r["personalizado"], [])
if f and r["fch"] and f <= r["fch"])
# CARTERA TOTAL: teléfonos únicos de cartera_junta con la misma SEDE+PROGRAMA
# del curso (según su pauta → campanias) cuya fecha_creada <= fch_inicio del curso.
# cartera_total = todos; cartera_total_asig = solo con asesor asignado.
cartera_total = 0
cartera_total_asig = 0
cod = r.get("pauta")
cods = r.get("pautas") or ([cod] if cod else [])
if cods and r["fch"]:
# Cartera = union de telefonos de la sede+programa de TODAS las pautas del num_indice
tels_all = {}; tels_asig = {}
for cd in cods:
sp = campanias_map.get(str(cd).upper())
if not sp:
continue
for tel, fx in cartera_all.get(sp, {}).items():
if fx and (tel not in tels_all or fx < tels_all[tel]):
tels_all[tel] = fx
for tel, fx in cartera_asig.get(sp, {}).items():
if fx and (tel not in tels_asig or fx < tels_asig[tel]):
tels_asig[tel] = fx
cartera_total = sum(1 for fx in tels_all.values() if fx <= r["fch"])
cartera_total_asig = sum(1 for fx in tels_asig.values() if fx <= r["fch"])
# MATRÍCULAS NO IDENTIFICADAS: matrículas de este curso (num_indice) cuyo teléfono
# (tel_1, o tel_2 si el 1 está vacío) NO aparece en cartera_junta. Sin filtro de fecha.
_ni = r["num_indice"]
_ni = _ni[:-2] if _ni.endswith(".0") else _ni
_mats = mat_por_indice.get(_ni, []) # lista de (telefono, fecha_matricula)
_tels_no_iden = [ph for (ph, fm) in _mats if ph not in cartera_tels]
matriculas_no_iden = len(_tels_no_iden)
# MATRICULAS LEADS NUEVOS: matrícula hecha dentro de 45 días DESPUÉS del origen
# del lead (registro es_origen=SI de ese teléfono en la cartera).
mat_leads_nuevos = 0
mat_leads_antiguos = 0
_tels_nuevos = []
_tels_antiguos = []
for (ph, fm) in _mats:
if not ph or not fm:
continue
forig = cartera_origen.get(ph)
if forig is None:
continue
dias = (fm - forig).days
if 0 <= dias <= 45:
mat_leads_nuevos += 1
_tels_nuevos.append(ph)
else:
# >45 días, o matrícula anterior al origen del lead (dias<0) → Antiguo
mat_leads_antiguos += 1
_tels_antiguos.append(ph)
# IMPORTE PAUTA: gasto de Meta Ads sumando TODAS las pautas del num_indice.
importe_pauta = round(sum(float(importe_por_pauta.get(str(cd), 0.0)) for cd in cods), 2)
# IMPORTE PAUTA EN EL MES: igual, pero solo del periodo filtrado.
importe_pauta_mes = round(sum(float(importe_por_pauta_periodo.get(str(cd), 0.0)) for cd in cods), 2)
# ── SUBFILAS por CANAL (PAUTA / OTROS) ── mismo criterio que la fila padre
# (SEDE+PROGRAMA vía campanias, misma fecha) + filtro de canal desde cartera.
def _subfila(clave):
cc = cartera_canal.get(clave, {})
c_all = cc.get("all", {}); c_asig = cc.get("asig", {}); c_tels = cc.get("tels", set())
ct = ct_asig = 0
l_rec = l_proc = 0
l_rec_mes = l_proc_mes = 0
if cod and r["fch"]:
sp = campanias_map.get(str(cod).upper())
if sp:
fa = c_all.get(sp, {})
fg = c_asig.get(sp, {})
# Cartera Total/Asig del canal: fecha_creada <= inicio del curso (igual que padre)
ct = sum(1 for fx in fa.values() if fx and fx <= r["fch"])
ct_asig = sum(1 for fx in fg.values() if fx and fx <= r["fch"])
# L. Recibidos/Procesados del canal = misma cartera del canal (mismo corte)
l_rec = ct
l_proc = ct_asig
# "del Mes" = fecha_creada en el periodo filtrado
l_rec_mes = sum(1 for fx in fa.values() if _en_periodo(fx, ano, mes, dia))
l_proc_mes = sum(1 for fx in fg.values() if _en_periodo(fx, ano, mes, dia))
# Matrículas del curso, clasificadas por canal:
# - NO identificadas (tel NO en cartera) -> todas a OTROS.
# - identificadas -> al canal de su telefono (c_tels), regla 45 dias.
mt_noiden = mt_nuevos = mt_antiguos = 0
for (ph, fm) in _mats:
if not ph:
continue
en_cartera = ph in cartera_tels
if not en_cartera:
# no identificada: solo cuenta en la subfila OTROS
if clave == "OTROS":
mt_noiden += 1
continue
# identificada: solo cuenta si su tel pertenece a ESTE canal
if ph not in c_tels:
continue
forig = cartera_origen.get(ph)
if fm and forig:
d = (fm - forig).days
if 0 <= d <= 45: mt_nuevos += 1
else: mt_antiguos += 1
return {"canal": clave, "cartera_total": ct, "cartera_total_asig": ct_asig,
"leads_nuevos": l_rec, "leads_nuevos_asesor": l_proc,
"leads_nuevos_mes": l_rec_mes, "leads_nuevos_mes_asesor": l_proc_mes,
"matriculas_no_iden": mt_noiden, "mat_leads_nuevos": mt_nuevos,
"mat_leads_antiguos": mt_antiguos}
subfilas = [_subfila("PAUTA"), _subfila("WEB"), _subfila("OTROS")] if cartera_canal else []
filas.append({
"num_indice": r["num_indice"],
"personalizado": r["personalizado"],
"fecha_inicio": r["fecha_inicio"],
"pauta": r.get("pauta"),
"importe_pauta": importe_pauta,
"importe_pauta_mes": importe_pauta_mes,
"cartera_total": cartera_total,
"cartera_total_asig": cartera_total_asig,
"matriculas_no_iden": matriculas_no_iden,
"matriculas_no_iden_tels": _tels_no_iden,
"mat_leads_nuevos": mat_leads_nuevos,
"mat_leads_antiguos": mat_leads_antiguos,
"mat_leads_nuevos_tels": _tels_nuevos,
"mat_leads_antiguos_tels": _tels_antiguos,
"leads_acumulados": acum,
"leads_nuevos": r["leads_nuevos"],
"leads_nuevos_asesor": r["leads_nuevos_asesor"],
"leads_nuevos_mes": r["leads_nuevos_mes"],
"leads_nuevos_mes_asesor": r["leads_nuevos_mes_asesor"],
"subfilas_canal": subfilas,
"conjuntos": [cj for cd in cods for cj in conjunto_por_pauta.get(str(cd).strip(), [])],
"contar": (pauta_map.get(str(r["num_indice"]).strip()) or {}).get("contar") or "SI",
})
return {"filas": filas}

275
backend/main.py Normal file
View File

@@ -0,0 +1,275 @@
# backend/main.py
"""API REST del Dashboard de LEADS (FastAPI)."""
from fastapi import FastAPI, Query, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime
import uvicorn
import services
from cache_manager import start_background_refresh, cache_stats
app = FastAPI(title="Dashboard Leads API", version="1.0")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
)
@app.on_event("startup")
def _startup():
# Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server.
import threading
def _precarga_bg():
print("[startup] Precargando datos de Leads (2do plano)...")
try:
services.precargar_todo()
print("[startup] Precarga completa.")
except Exception as e:
print(f"[startup] Precarga falló: {e}")
threading.Thread(target=_precarga_bg, daemon=True).start()
# Otros General en un hilo aparte (sus consultas son mas pesadas)
def _precarga_otros_bg():
try:
services.precargar_otros_general()
except Exception as e:
print(f"[startup] Precarga Otros falló: {e}")
threading.Thread(target=_precarga_otros_bg, daemon=True).start()
start_background_refresh(services.refrescar_todo, interval=900)
@app.get("/api/health")
def health():
return {"status": "ok", "hora": datetime.now().isoformat()}
@app.get("/api/cache/stats")
def stats():
return cache_stats()
@app.post("/api/cache/refresh")
def refresh():
services.refrescar_todo()
return {"status": "refrescado"}
@app.get("/api/leads/filtros")
def get_filtros():
try:
return services.opciones_filtros()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/leads")
def get_leads(
ano: str = Query("TODOS"),
mes: str = Query("TODOS"),
dia: str = Query("TODOS"),
programa: str = Query("TODOS"),
sede: str = Query("TODOS"),
):
try:
return services.leads_dashboard(ano, mes, dia, programa, sede)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/vendedores")
def get_vendedores(
ano: str = Query("TODOS"),
mes: str = Query("TODOS"),
dia: str = Query("TODOS"),
):
try:
return services.vendedores_dashboard(ano, mes, dia)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/roas")
def get_roas(
ano: str = Query("TODOS"),
mes: str = Query("TODOS"),
dia: str = Query("TODOS"),
):
try:
return services.roas_dashboard(ano, mes, dia)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/conjuntos-sin-sede")
def get_conjuntos_sin_sede():
try:
return services.conjuntos_sin_sede()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/conjuntos-sin-sede/guardar")
def post_conjunto_sede(body: dict = Body(...)):
try:
cambios = body.get("cambios") or []
return services.guardar_conjunto_sede(cambios)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/conjuntos-sin-sede/borrar")
def post_borrar_conjunto_sede(body: dict = Body(...)):
try:
return services.borrar_conjunto_sede(body.get("conjunto") or "")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/otros-general")
def get_otros_general(
ano: str = Query("TODOS"),
mes: str = Query("TODOS"),
dia: str = Query("TODOS"),
):
try:
return services.otros_general(ano, mes, dia)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/conjuntos-sin-pauta")
def get_conjuntos_sin_pauta():
try:
return {"conjuntos": services.conjuntos_sin_pauta()}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/programas-ocultos")
def get_programas_ocultos():
try:
return services.programas_ocultos()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/programas-ocultos/encender")
def post_encender_programas(body: dict = Body(...)):
try:
nis = body.get("num_indices") or []
return services.encender_programas(nis)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/programas-disponibles")
def get_programas_disponibles():
try:
return services.programas_disponibles()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/leyenda-anuncios")
def get_leyenda():
try:
return services.leyenda_anuncios()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/leyenda-anuncios/guardar")
def post_leyenda(body: dict = Body(...)):
try:
cambios = body.get("cambios") or []
return services.guardar_leyenda(cambios)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
@app.get("/api/campanias")
def get_campanias():
try:
return {"filas": services.campanias_listar()}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/campanias/agregar")
def post_campania(body: dict = Body(...)):
try:
return services.campania_agregar(body or {})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/campanias/editar")
def post_editar_campania(body: dict = Body(...)):
try:
cid = body.get("id")
return services.campania_editar(cid, body or {})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/campanias/borrar")
def post_borrar_campania(body: dict = Body(...)):
try:
return services.campania_borrar(body.get("id"))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/pauta/uso")
def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")):
try:
return services.uso_de_pauta(pauta, excluir)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/curso/guardar-pauta")
def post_guardar_pauta(body: dict = Body(...)):
try:
ni = str(body.get("num_indice", "")).strip()
pa = str(body.get("pauta", "")).strip()
cj = body.get("conjunto") or None
contar = body.get("contar") # "SI" / "NO" / None
if not ni:
raise HTTPException(status_code=400, detail="num_indice es obligatorio")
return services.guardar_edicion_curso(ni, pa, cj, contar)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ultima-actualizacion")
def get_ultima_actualizacion():
try:
return services.ultima_actualizacion()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/leads/alertas")
def get_alertas():
"""Valores de programa/sede en cartera_junta que NO están en el diccionario."""
try:
return services.alertas()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/debug/cursos")
def debug_cursos(ano: str = "2026", mes: str = "1"):
"""Diagnóstico: lista cursos del periodo con inscritos, tipo y meta."""
try:
return services.debug_cursos(ano, mes)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)

70
backend/medir_tiempos.py Normal file
View File

@@ -0,0 +1,70 @@
# medir_tiempos.py
# Mide cuánto tarda CADA parte que carga el dashboard de Leads al iniciar.
# Simula lo mismo que hace el backend en su precarga, pero cronometrando cada paso.
# Ejecutar en backend/: python medir_tiempos.py
import time
import services as S
def cron(nombre, fn):
t0 = time.perf_counter()
ok = "OK"
extra = ""
try:
r = fn()
try:
if isinstance(r, (list, dict)):
extra = f"({len(r)} items)"
except Exception:
pass
except Exception as e:
ok = "ERROR"
extra = str(e)[:60]
dt = time.perf_counter() - t0
print(f" {nombre:38} {dt:7.2f}s {ok} {extra}")
return dt
periodos = S._periodos_actual_anterior()
a0, m0 = periodos[0]
print("\n" + "="*70)
print(" MEDICIÓN DE TIEMPOS DE CARGA — Dashboard LEADS")
print("="*70)
print("\n[1] INSUMOS CRUDOS (consultas base, se cargan 1 vez)")
tot1 = 0
tot1 += cron("Leads (Chatwoot/PostgreSQL)", S._leads_crudos)
tot1 += cron("Cursos (SQL Server)", S._cursos_crudos)
tot1 += cron("Matriculas (SQL Server)", S._matriculas_crudas)
tot1 += cron("Leads asignados/respuesta", S._leads_asignados_crudos)
tot1 += cron("Cartera (Supabase)", S._cartera_rows_cache)
tot1 += cron("Plantillas", S._plantillas_crudas)
print(f"\n --> Subtotal insumos: {tot1:.2f}s")
print(f"\n[2] APARTADO LEADS (periodo {a0}-{m0}, filtros TODOS)")
tot2 = 0
tot2 += cron("leads_dashboard", lambda: S.leads_dashboard(a0, m0, "TODOS", "TODOS", "TODOS"))
tot2 += cron("leads_filtros", S.opciones_filtros)
print(f"\n --> Subtotal Leads: {tot2:.2f}s")
print(f"\n[3] APARTADO VENDEDORES (periodo {a0}-{m0})")
tot3 = 0
tot3 += cron("vendedores_dashboard", lambda: S.vendedores_dashboard(a0, m0, "TODOS"))
print(f"\n --> Subtotal Vendedores: {tot3:.2f}s")
print(f"\n[4] APARTADO OTROS GENERAL (periodo {a0}-{m0}) — el mas pesado")
tot4 = 0
tot4 += cron("otros_general", lambda: S.otros_general(a0, m0, "TODOS"))
print(f"\n --> Subtotal Otros General: {tot4:.2f}s")
print(f"\n[5] APARTADO ROAS (periodo {a0}-{m0})")
tot5 = 0
tot5 += cron("roas_dashboard", lambda: S.roas_dashboard(a0, m0, "TODOS"))
print(f"\n --> Subtotal ROAS: {tot5:.2f}s")
total = tot1 + tot2 + tot3 + tot4 + tot5
print("\n" + "="*70)
print(f" TIEMPO TOTAL (todo en frio, 1a vez): {total:.2f}s")
print(" Nota: en el arranque real, [1]+[2] van en un hilo y [4] en otro,")
print(" asi que corren EN PARALELO. El tiempo real es ~ el mas lento de los dos.")
print("="*70 + "\n")

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi
uvicorn
python-dotenv
pyodbc
psycopg2-binary
requests

1768
backend/services.py Normal file

File diff suppressed because it is too large Load Diff

55
backend/ver_campanias.py Normal file
View File

@@ -0,0 +1,55 @@
# ver_campanias.py — Muestra en pantalla TODA la tabla campanias de Supabase.
# Ejecutar dentro de backend/: python ver_campanias.py
import os, requests
from dotenv import load_dotenv
load_dotenv()
URL = os.getenv("CARTERA_URL") or os.getenv("SUPABASE_PAUTA_URL", "")
KEY = os.getenv("CARTERA_KEY") or os.getenv("SUPABASE_PAUTA_KEY", "")
TABLA = os.getenv("SUPABASE_TABLA_CAMPANIAS", "campanias")
HEAD = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
assert URL and KEY, "Falta CARTERA_URL / CARTERA_KEY en .env"
def traer_todo():
filas, paso, desde = [], 1000, 0
while True:
hdr = dict(HEAD)
hdr["Range-Unit"] = "items"
hdr["Range"] = f"{desde}-{desde + paso - 1}"
r = requests.get(f"{URL}/rest/v1/{TABLA}",
params={"select": "*", "order": "codigo"},
headers=hdr, timeout=60)
r.raise_for_status()
data = r.json()
if not data:
break
filas.extend(data)
desde += len(data)
if len(data) < paso:
break
return filas
filas = traer_todo()
print(f"\nTotal campanias: {len(filas)}\n")
if not filas:
print("Sin datos. Revisa CARTERA_URL / CARTERA_KEY en .env")
raise SystemExit
# columnas
cols = ["frase_busqueda", "cargo", "codigo", "sede", "dia", "origen", "fecha_inicio", "fecha_fin"]
# por si hay columnas extra
for f in filas:
for k in f.keys():
if k not in cols and k != "id":
cols.append(k)
# imprime cada campania en pantalla
for i, f in enumerate(filas, 1):
partes = []
for c in cols:
v = f.get(c, "")
v = "" if v is None else str(v)
partes.append(f"{c}={v}")
print(f"{i:3}. " + " | ".join(partes))

24
backend/ver_leyenda.py Normal file
View File

@@ -0,0 +1,24 @@
# ver_leyenda.py - Muestra en pantalla el mini-apartado LEYENDA (tabla campanias),
# igual que se ve en el dashboard. Solo lectura.
# Ejecutar en backend/: python ver_leyenda.py
from data_manager_v2 import DataManager
dm = DataManager()
filas = dm.listar_campanias()
print(f"\n=== LEYENDA (tabla campanias) — {len(filas)} filas ===\n")
h = f"{'id':>4} {'FRASE DE BUSQUEDA':38} {'CARGO':16} {'COD':6} {'SEDE':10} {'DIA':14} {'ORIGEN':13} {'INICIO':11} {'FIN':11}"
print(h)
print("-"*len(h))
for f in filas:
print(f"{str(f.get('id','')):>4} "
f"{str(f.get('frase_busqueda',''))[:38]:38} "
f"{str(f.get('cargo',''))[:16]:16} "
f"{str(f.get('codigo','')):6} "
f"{str(f.get('sede',''))[:10]:10} "
f"{str(f.get('dia',''))[:14]:14} "
f"{str(f.get('origen',''))[:13]:13} "
f"{str(f.get('fecha_inicio','')):11} "
f"{str(f.get('fecha_fin','')):11}")
print("-"*len(h))
print(f"Total: {len(filas)} campanias")

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard Leads — Escuela Refrigeración</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
frontend/node_modules/.bin/baseline-browser-mapping generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
else
exec node "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
fi

View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\baseline-browser-mapping\dist\cli.cjs" %*

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
} else {
& "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
} else {
& "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/browserslist generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
else
exec node "$basedir/../browserslist/cli.js" "$@"
fi

17
frontend/node_modules/.bin/browserslist.cmd generated vendored Normal file
View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\browserslist\cli.js" %*

28
frontend/node_modules/.bin/browserslist.ps1 generated vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../browserslist/cli.js" $args
} else {
& "node$exe" "$basedir/../browserslist/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/esbuild generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
else
exec node "$basedir/../esbuild/bin/esbuild" "$@"
fi

17
frontend/node_modules/.bin/esbuild.cmd generated vendored Normal file
View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\esbuild\bin\esbuild" %*

28
frontend/node_modules/.bin/esbuild.ps1 generated vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "$basedir/node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../esbuild/bin/esbuild" $args
} else {
& "node$exe" "$basedir/../esbuild/bin/esbuild" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/jsesc generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
else
exec node "$basedir/../jsesc/bin/jsesc" "$@"
fi

17
frontend/node_modules/.bin/jsesc.cmd generated vendored Normal file
View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jsesc\bin\jsesc" %*

28
frontend/node_modules/.bin/jsesc.ps1 generated vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
} else {
& "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../jsesc/bin/jsesc" $args
} else {
& "node$exe" "$basedir/../jsesc/bin/jsesc" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/json5 generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
else
exec node "$basedir/../json5/lib/cli.js" "$@"
fi

17
frontend/node_modules/.bin/json5.cmd generated vendored Normal file
View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\json5\lib\cli.js" %*

28
frontend/node_modules/.bin/json5.ps1 generated vendored Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../json5/lib/cli.js" $args
} else {
& "node$exe" "$basedir/../json5/lib/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

16
frontend/node_modules/.bin/loose-envify generated vendored Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../loose-envify/cli.js" "$@"
else
exec node "$basedir/../loose-envify/cli.js" "$@"
fi

17
frontend/node_modules/.bin/loose-envify.cmd generated vendored Normal file
View File

@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\loose-envify\cli.js" %*

Some files were not shown because too many files have changed in this diff Show More