Actualizar backend/services.py

This commit is contained in:
2026-09-10 11:28:16 -05:00
parent e01121a64d
commit 85ea808481

View File

@@ -1,33 +1,33 @@
# backend/services.py
"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads."""
from data_manager_v2 import DataManager
import leads_logic as L
from cache_manager import cache_get_or_set, cache_invalidate
# backend/services.py
"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads."""
from data_manager_v2 import DataManager
import leads_logic as L
from cache_manager import cache_get_or_set, cache_invalidate
_DM = None
_DM = None
def get_dm() -> DataManager:
def get_dm() -> DataManager:
global _DM
if _DM is None:
_DM = DataManager()
return _DM
# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar
# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan
# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar
# los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache.
_PREF_CAMPANIAS = (
# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar
# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan
# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar
# los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache.
_PREF_CAMPANIAS = (
"campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros",
"leads_alertas", "vendedores_dash", "roas_dash", "otros_general",
"matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas",
"tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo",
"resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash",
)
)
def _invalidar_por_campanias():
def _invalidar_por_campanias():
"""Invalida el cache de campañas (Supabase) y de todos los apartados que
dependen de las leyendas, para que el cambio se vea de inmediato."""
from data_manager_v2 import invalidar_campanias_cache
@@ -36,31 +36,31 @@ def _invalidar_por_campanias():
cache_invalidate(p)
# ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
def _leads_crudos():
# ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
def _leads_crudos():
return cache_get_or_set("leads_raw", ("GLOBAL",),
lambda: L.procesar_leads(get_dm().traer_leads_chatwoot()))
def _cursos_crudos():
def _cursos_crudos():
return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos())
def _matriculas_crudas():
def _matriculas_crudas():
return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas())
def _pauta_cruda():
def _pauta_cruda():
return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos())
def _programa_pautas():
def _programa_pautas():
"""{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos)."""
return cache_get_or_set("programa_pautas", ("GLOBAL",),
lambda: get_dm().traer_programa_pautas())
def _pautas_de_indice():
def _pautas_de_indice():
"""{num_indice: [pautas]} combinando:
- la pauta de basebi_programacion (1 por num_indice), y
- las pautas extra de programa_pautas (varias).
@@ -82,18 +82,18 @@ def _pautas_de_indice():
return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load)
def _campanias_map():
def _campanias_map():
return cache_get_or_set("campanias_map", ("GLOBAL",),
lambda: get_dm().traer_campanias_map())
def _clasif_meta():
def _clasif_meta():
"""JSON de clasificacion (sede/programa) de GitHub, MISMO que Ventas."""
return cache_get_or_set("clasif_meta", ("GLOBAL",),
lambda: get_dm().traer_clasificacion_meta())
def clasificar_sede_meta(dsc_programa):
def clasificar_sede_meta(dsc_programa):
"""Sede segun el JSON (clasificacion_sedes). Default LIMA. Igual que Ventas."""
up = str(dsc_programa or "").upper()
data = _clasif_meta().get("clasificacion_sedes", {})
@@ -106,7 +106,7 @@ def clasificar_sede_meta(dsc_programa):
return data.get("DEFAULT", "LIMA")
def clasificar_prog_meta(dsc_programa):
def clasificar_prog_meta(dsc_programa):
"""Programa (TEAC/TERC/SEMINARIOS/OTROS) segun clasificacion_filtro_programa
del JSON. Respeta el ORDEN del JSON (primero que coincide gana). Igual que Ventas."""
up = str(dsc_programa or "").upper()
@@ -120,7 +120,7 @@ def clasificar_prog_meta(dsc_programa):
return data.get("DEFAULT", "OTROS")
def meta_de_curso(dsc_programa):
def meta_de_curso(dsc_programa):
"""Meta de inscritos segun sede+programa (JSON), como acordado:
LIMA TEAC/TERC=22, provincia TEAC/TERC=18, SEMINARIOS/OTROS=15."""
sede = clasificar_sede_meta(dsc_programa)
@@ -130,22 +130,22 @@ def meta_de_curso(dsc_programa):
return 15
# Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula).
L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta)
# Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula).
L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta)
def _conjunto_pauta():
def _conjunto_pauta():
return cache_get_or_set("conjunto_pauta", ("GLOBAL",),
lambda: get_dm().traer_conjunto_pauta())
def _meta_filas():
def _meta_filas():
"""Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...]."""
return cache_get_or_set("meta_filas", ("GLOBAL",),
lambda: get_dm().traer_meta_importe())
def _importe_por_pauta():
def _importe_por_pauta():
"""{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los
conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha."""
def _load():
@@ -162,11 +162,11 @@ def _importe_por_pauta():
return cache_get_or_set("importe_pauta", ("GLOBAL",), _load)
# Canales que cuentan como Copito (ademas de origen_base=POSTGRE).
_CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"}
# Canales que cuentan como Copito (ademas de origen_base=POSTGRE).
_CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"}
def _tel_es_copito():
def _tel_es_copito():
"""{telefono: True/False} = si el telefono es COPITO segun su FILA MAS ANTIGUA
en la cartera. Copito = (origen_base=POSTGRE) OR (canal en _CANALES_COPITO).
Se calcula una vez sobre toda la cartera (cacheado)."""
@@ -188,7 +188,7 @@ def _tel_es_copito():
return cache_get_or_set("tel_es_copito", ("GLOBAL",), _load)
def _leads_por_codigo_periodo(ano, mes, dia):
def _leads_por_codigo_periodo(ano, mes, dia):
"""{codigo(pauta): {"rec": set(tels recibidos del periodo),
"proc": set(tels procesados del periodo)}}.
Recibido = lead unico con ese codigo cuya fecha cae en el periodo.
@@ -215,7 +215,7 @@ def _leads_por_codigo_periodo(ano, mes, dia):
return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load)
def _resultados_por_pauta():
def _resultados_por_pauta():
"""{pauta: resultados_totales} = suma de Resultados de TODAS las filas de los
conjuntos ligados a esa pauta. Sin filtro de fecha (analogo a _importe_por_pauta)."""
def _load():
@@ -232,7 +232,7 @@ def _resultados_por_pauta():
return cache_get_or_set("resultados_pauta", ("GLOBAL",), _load)
def _importe_por_pauta_periodo(ano, mes, dia):
def _importe_por_pauta_periodo(ano, mes, dia):
"""{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe'
cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba."""
def _load():
@@ -255,7 +255,7 @@ def _importe_por_pauta_periodo(ano, mes, dia):
return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load)
def _resultados_por_pauta_periodo(ano, mes, dia):
def _resultados_por_pauta_periodo(ano, mes, dia):
"""{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe'
cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados."""
def _load():
@@ -275,12 +275,12 @@ def _resultados_por_pauta_periodo(ano, mes, dia):
return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load)
def _campanias_sede_cargo():
def _campanias_sede_cargo():
return cache_get_or_set("campanias_sc", ("GLOBAL",),
lambda: get_dm().traer_campanias_sede_cargo())
def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
"""Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice).
Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados,
Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor).
@@ -426,7 +426,7 @@ def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa=
return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load)
def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
"""Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa.
Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y
Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead)."""
@@ -521,23 +521,23 @@ def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", p
return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load)
def _asignacion_respuesta_crudos():
def _asignacion_respuesta_crudos():
return cache_get_or_set("asig_resp", ("GLOBAL",),
lambda: get_dm().traer_asignacion_respuesta())
def _leads_asignados_crudos():
def _leads_asignados_crudos():
# UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas),
# asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan.
return _asignacion_respuesta_crudos()
def _actividad_asesores_crudos():
def _actividad_asesores_crudos():
return cache_get_or_set("act_asesores", ("GLOBAL",),
lambda: get_dm().traer_actividad_asesores())
def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del
mes; valor = cantidad de telefonos asignados a ese asesor ese dia.
Fecha = created_at del mensaje de asignacion (ya con -5h)."""
@@ -584,12 +584,12 @@ def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load)
def _plantillas_crudas():
def _plantillas_crudas():
return cache_get_or_set("plantillas_raw", ("GLOBAL",),
lambda: get_dm().traer_plantillas())
def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz de plantillas COBRADAS por plantilla. Columnas:
ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado
RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1)
@@ -683,13 +683,13 @@ def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load)
def _norm_ni(x):
def _norm_ni(x):
"""Normaliza num_indice a texto (quita el '.0' si viniera como float)."""
s = str(x or "").strip()
return s[:-2] if s.endswith(".0") else s
def _mat_por_indice():
def _mat_por_indice():
"""{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas.
Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2."""
def _load():
@@ -705,14 +705,14 @@ def _mat_por_indice():
return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load)
def _cartera_rows_cache():
def _cartera_rows_cache():
"""Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez).
Evita re-descargar las 86k filas en cada llamada/cambio de mes."""
return cache_get_or_set("cartera_rows", ("GLOBAL",),
lambda: get_dm().traer_cartera_rows())
def _cartera_idx():
def _cartera_idx():
"""Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}.
'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío).
Se baja la cartera UNA vez y queda cacheado."""
@@ -783,8 +783,8 @@ def _cartera_idx():
return cache_get_or_set("cartera_idx", ("GLOBAL",), _load)
# ── Respuesta del dashboard de Leads (con filtros) ──────────────
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
# ── Respuesta del dashboard de Leads (con filtros) ──────────────
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
def _load():
leads = _leads_crudos()
cursos = _cursos_crudos()
@@ -840,18 +840,18 @@ def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sed
return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load)
def _asesores():
def _asesores():
"""{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}."""
return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores())
def _norm_asesor(nombre, mapa):
def _norm_asesor(nombre, mapa):
"""Normaliza un nombre de asesor a su 'correcto' via el diccionario."""
k = " ".join(str(nombre or "").upper().split())
return mapa.get(k) # None si no es uno de los vendedores
def _norm_tel_pe(v):
def _norm_tel_pe(v):
"""Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial
de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta."""
s = str(v or "")
@@ -863,7 +863,7 @@ def _norm_tel_pe(v):
return s
def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion.
- Turno detectado por dia/asesor segun donde hay MAS actividad:
tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido).
@@ -1081,7 +1081,7 @@ def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load)
def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
ano="TODOS", mes="TODOS"):
"""Detalle de los leads que forman el promedio de UN día del gráfico
'Tiempo de Respuesta por Día'. Reproduce la MISMA lógica de turnos/minutos.
@@ -1222,7 +1222,7 @@ def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
return cache_get_or_set("tiempo_resp_det", (dia_click, modo, vendedor, ano, mes), _load)
def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día:
- Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta.
- Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI,
@@ -1454,17 +1454,17 @@ def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load)
def _conjunto_sede_prog():
def _conjunto_sede_prog():
"""{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase)."""
return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",),
lambda: get_dm().traer_conjunto_sede_programa())
# Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca).
_PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"}
# Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca).
_PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"}
def _conjuntos_branding():
def _conjuntos_branding():
"""Set de CONJUNTOS (UPPER) asignados manualmente como BRANDING/COPEAIRE.
Sus resultados NO deben contarse (su importe sí)."""
def _load():
@@ -1474,7 +1474,7 @@ def _conjuntos_branding():
return cache_get_or_set("conjuntos_branding", ("GLOBAL",), _load)
def _codigos_branding():
def _codigos_branding():
"""Set de codigos(pauta) cuyos conjuntos estan asignados manualmente como
BRANDING/COPEAIRE. Sirve para NO contar sus resultados en las tablas."""
def _load():
@@ -1493,7 +1493,7 @@ def _codigos_branding():
return cache_get_or_set("codigos_branding", ("GLOBAL",), _load)
def conjuntos_sin_sede():
def conjuntos_sin_sede():
"""Lista los conjuntos del Meta CSV que NO estan ligados a ninguna sede
(ni por pauta->campania, ni por asignacion manual). Para el apartado de
asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene).
@@ -1531,7 +1531,7 @@ def conjuntos_sin_sede():
return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load)
def guardar_conjunto_sede(cambios):
def guardar_conjunto_sede(cambios):
"""Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes."""
r = get_dm().guardar_conjunto_sede_programa(cambios)
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
@@ -1539,7 +1539,7 @@ def guardar_conjunto_sede(cambios):
return r
def borrar_conjunto_sede(conjunto):
def borrar_conjunto_sede(conjunto):
"""Borra la asignacion de un conjunto e invalida cachés dependientes."""
r = get_dm().borrar_conjunto_sede_programa(conjunto)
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
@@ -1547,7 +1547,7 @@ def borrar_conjunto_sede(conjunto):
return r
def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y
Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe).
Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin
@@ -1693,10 +1693,10 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("roas_dash", (ano, mes, dia), _load)
_CURSOS_EXCLUIDOS = {"1121", "1154"}
_CURSOS_EXCLUIDOS = {"1121", "1154"}
def _curso_excluido(c, pmap):
def _curso_excluido(c, pmap):
"""Mismo criterio que 'Detalle por Curso': excluye cursos en la lista fija,
o con contar=NO / estado=SUSPENDIDO en basebi."""
ni = str(c.get("num_indice", "")).strip()
@@ -1708,7 +1708,7 @@ def _curso_excluido(c, pmap):
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
def programa_opciones(ano="TODOS", mes="TODOS"):
def programa_opciones(ano="TODOS", mes="TODOS"):
"""Lista de programas (dsc_det_programa) cuyos cursos tienen fch_inicio en el
periodo. Para el selector del apartado PROGRAMA. Ordenada por fch_inicio ASC."""
def _load():
@@ -1731,7 +1731,7 @@ def programa_opciones(ano="TODOS", mes="TODOS"):
return cache_get_or_set("programa_opciones", (ano, mes), _load)
def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
"""Datos agregados de UN programa (dsc_det_programa) en el periodo:
Cartera Total/Copito/Otros, Leads Recibidos/Procesados/Contactados y %.
- Filtro por fch_inicio del curso para elegir los cursos del programa.
@@ -1778,6 +1778,7 @@ def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
# de las pautas del programa. Telefonos unicos por etiqueta.
from collections import defaultdict as _dd
etiq_tels = _dd(set)
etiq_ase_tels = _dd(lambda: _dd(set)) # estado -> asesor -> {telefonos}
cods_set0 = set(cods)
for l in _leads_crudos():
if not l.get("es_unico") or not l.get("asesor"):
@@ -1788,9 +1789,16 @@ def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
if not tel:
continue
ult = l.get("ultima") or "NO TRABAJADO"
ase = l.get("asesor") or "SIN ASESOR"
etiq_tels[ult].add(tel)
etiq_ase_tels[ult][ase].add(tel)
matriz_etiq = sorted(
[{"estado": k, "cantidad": len(v)} for k, v in etiq_tels.items()],
[{"estado": k, "cantidad": len(v),
"asesores": sorted(
[{"asesor": a, "cantidad": len(t), "telefonos": sorted(t)}
for a, t in etiq_ase_tels[k].items()],
key=lambda x: -x["cantidad"])}
for k, v in etiq_tels.items()],
key=lambda x: -x["cantidad"])
# 3) CARTERA TOTAL: MISMA logica que 'Detalle por Curso'. Por cada curso,
@@ -1994,7 +2002,7 @@ def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
return cache_get_or_set("programa_dash", (ano, mes, programa), _load)
def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta).
Mas liviano que leads_dashboard -> filtrar por mes es mas rapido."""
def _load():
@@ -2007,12 +2015,12 @@ def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
return cache_get_or_set("otros_general", (ano, mes, dia), _load)
def conjuntos_sin_pauta():
def conjuntos_sin_pauta():
"""Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up)."""
return get_dm().traer_conjuntos_sin_pauta()
def _pautas_de_curso_ni(ni):
def _pautas_de_curso_ni(ni):
"""Pautas (codigos) de un num_indice = pauta de basebi + programa_pautas."""
cds = set()
for p in _pautas_de_indice().get(ni, []):
@@ -2023,7 +2031,7 @@ def _pautas_de_curso_ni(ni):
return cds
def alertas_dashboard():
def alertas_dashboard():
"""ALERTAS: cursos que inician en los proximos 30 dias (con pauta, importe>0).
Estado (CRITICO/ADVERTENCIA/BUEN RUMBO) segun MATRICULAS vs meta ESPERADA por
dias que faltan. CPL alto / pocos leads son SENALES (no cambian el estado).
@@ -2315,7 +2323,7 @@ def alertas_dashboard():
return res
def programas_ocultos():
def programas_ocultos():
"""Programas con contar=NO en basebi_programacion (ocultos de la matriz).
Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}."""
pm = _pauta_cruda()
@@ -2337,7 +2345,7 @@ def programas_ocultos():
return {"filas": filas}
def encender_programas(num_indices):
def encender_programas(num_indices):
"""Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva)."""
from cache_manager import cache_invalidate
dm = get_dm()
@@ -2348,7 +2356,7 @@ def encender_programas(num_indices):
return {"encendidos": len(num_indices)}
def programas_disponibles():
def programas_disponibles():
"""Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el
desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}."""
from datetime import date
@@ -2370,30 +2378,30 @@ def programas_disponibles():
return {"programas": filas}
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
def campanias_listar():
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
def campanias_listar():
return get_dm().listar_campanias()
def campania_agregar(datos):
def campania_agregar(datos):
res = get_dm().agregar_campania(datos)
_invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard
return res
def campania_editar(cid, datos):
def campania_editar(cid, datos):
res = get_dm().editar_campania(cid, datos)
_invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard
return res
def campania_borrar(cid):
def campania_borrar(cid):
res = get_dm().borrar_campania(cid)
_invalidar_por_campanias()
return res
def leyenda_anuncios():
def leyenda_anuncios():
"""Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa
(dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya
en memoria) para que abrir la Leyenda sea rapido."""
@@ -2431,7 +2439,7 @@ def leyenda_anuncios():
return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load)
def guardar_leyenda(cambios):
def guardar_leyenda(cambios):
"""cambios = [{conjunto, pauta, num_indice?}].
- conjunto -> pauta: upsert en conjunto_pauta.
- si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion.
@@ -2460,7 +2468,7 @@ def guardar_leyenda(cambios):
return {"guardados": len(cambios)}
def uso_de_pauta(pauta, excluir_num_indice=None):
def uso_de_pauta(pauta, excluir_num_indice=None):
"""Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa)
y conjuntos que la usan (para la advertencia antes de guardar)."""
dm = get_dm()
@@ -2480,7 +2488,7 @@ def uso_de_pauta(pauta, excluir_num_indice=None):
"cursos": detalle, "num_indices": nis, "conjuntos": conjuntos}
def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
"""Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió,
vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que
dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida)."""
@@ -2498,7 +2506,7 @@ def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
return res
def _tipo_de_curso(c):
def _tipo_de_curso(c):
"""Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS)."""
dscp = c.get("dsc_programa", "")
otros = L.clasificar_tipo_programa(dscp)
@@ -2511,8 +2519,8 @@ def _tipo_de_curso(c):
return L.tipo_programa_cohorte(sede, cat)
# ── Listas para los filtros (años, tipos de programa) ───────────
def opciones_filtros():
# ── Listas para los filtros (años, tipos de programa) ───────────
def opciones_filtros():
def _load():
cursos = _cursos_crudos()
anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos
@@ -2523,14 +2531,14 @@ def opciones_filtros():
return cache_get_or_set("leads_filtros", ("GLOBAL",), _load)
# ── Alertas: valores de programa/sede no identificados en cartera_junta ──
def alertas():
# ── Alertas: valores de programa/sede no identificados en cartera_junta ──
def alertas():
return cache_get_or_set("leads_alertas", ("GLOBAL",),
lambda: get_dm().traer_no_identificados())
# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
def debug_cursos(ano, mes):
# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
def debug_cursos(ano, mes):
cursos = _cursos_crudos()
periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")]
detalle = []
@@ -2560,8 +2568,8 @@ def debug_cursos(ano, mes):
}
# ── Precarga / refresco ─────────────────────────────────────────
def _periodos_actual_anterior():
# ── Precarga / refresco ─────────────────────────────────────────
def _periodos_actual_anterior():
"""[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy)."""
from datetime import date
hoy = date.today()
@@ -2573,11 +2581,11 @@ def _periodos_actual_anterior():
return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))]
import os as _os
_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log")
import os as _os
_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log")
def _log_carga(linea, reset=False):
def _log_carga(linea, reset=False):
"""Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos)."""
try:
with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f:
@@ -2586,13 +2594,13 @@ def _log_carga(linea, reset=False):
pass
import threading as _thr
_TIEMPOS = {} # etiqueta -> segundos (para el resumen final)
_TIEMPOS_LOCK = _thr.Lock()
_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen
import threading as _thr
_TIEMPOS = {} # etiqueta -> segundos (para el resumen final)
_TIEMPOS_LOCK = _thr.Lock()
_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen
def _cron(etiqueta, base, fn, apartado=None):
def _cron(etiqueta, base, fn, apartado=None):
"""Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log:
paso, base de datos y segundos. Se ve en la ventana del BACKEND y en la
ventana de TIEMPOS al iniciar, para saber que carga cada cosa.
@@ -2615,7 +2623,7 @@ def _cron(etiqueta, base, fn, apartado=None):
return r
def _hilo_termino(nombre):
def _hilo_termino(nombre):
"""Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan,
imprime el resumen final por apartado en el log."""
with _TIEMPOS_LOCK:
@@ -2639,7 +2647,7 @@ def _hilo_termino(nombre):
_log_carga("=" * 60)
def precargar_todo():
def precargar_todo():
"""Precarga en segundo plano: insumos crudos + el dashboard del mes actual y
anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar."""
try:
@@ -2677,7 +2685,7 @@ def precargar_todo():
_hilo_termino("leads")
def precargar_otros_general():
def precargar_otros_general():
"""Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual
y anterior. Se llama en su propio hilo para no bloquear."""
try:
@@ -2697,26 +2705,29 @@ def precargar_otros_general():
_hilo_termino("otros")
# Hora de la última actualización (hora Perú)
_ULTIMA_ACTUALIZACION = None
# Hora de la última actualización (hora Perú)
_ULTIMA_ACTUALIZACION = None
def _marcar_actualizacion():
def _marcar_actualizacion():
global _ULTIMA_ACTUALIZACION
from datetime import datetime, timezone, timedelta
peru = timezone(timedelta(hours=-5))
_ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M")
def ultima_actualizacion():
def ultima_actualizacion():
return {"hora": _ULTIMA_ACTUALIZACION}
def refrescar_todo():
from cache_manager import cache_invalidate
def refrescar_todo():
from cache_manager import set_forzar
from data_manager_v2 import invalidar_campanias_cache
cache_invalidate() # vaciar y recargar TODO
invalidar_campanias_cache() # tambien las campañas (cache aparte)
set_forzar(True) # recalcula y sobrescribe (no lee cache viejo)
try:
precargar_todo() # leads (mes actual/anterior)
precargar_otros_general() # cartera + Otros General (para que no quede vacia)
precargar_otros_general() # cartera + Otros General
finally:
set_forzar(False)
_marcar_actualizacion()