Files
dashboard-completo/backend/services.py

1106 lines
47 KiB
Python

# backend/services.py
"""
Capa de servicios: envuelve la lógica existente (modules/) con caché.
NO modifica la lógica de negocio — solo la llama y serializa el resultado.
"""
from core.data_manager import DataManager
from modules.ocupabilidad.logic import AnalizadorCursos
from modules.ventas.logic import VentasLogic
from modules.cobranza.logic import CobranzaLogic
from modules.rentabilidad.logic import RentabilidadLogic
from modules.saldo_pendiente.logic import SaldoLogic
from cache_manager import cache_get_or_set
# Instancia única del DataManager (como @st.cache_resource)
_DM = None
# Hora de la última actualización de DATOS (se setea al precargar/refrescar)
_ultima_actualizacion = None
def _marcar_actualizacion():
global _ultima_actualizacion
from datetime import datetime
_ultima_actualizacion = datetime.now()
def ultima_actualizacion():
"""Devuelve la hora (ISO) del último refresco de datos, o None."""
return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None}
def get_dm() -> DataManager:
global _DM
if _DM is None:
_DM = DataManager()
return _DM
def _serializar_dicts(datos):
out = []
for d in (datos or []):
fila = {}
for k, v in d.items():
fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v)
out.append(fila)
return out
def _serializar_filas(filas, limite=None):
out = []
for f in (filas or []):
vals = f[:limite] if limite else f
out.append([str(v) if v is not None else "" for v in vals])
return out
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
def _load():
dm = get_dm()
analizador = AnalizadorCursos(dm)
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
filas = analizador.formatear_datos_para_tabla(datos)
return {
"datos": _serializar_dicts(datos),
"filas": _serializar_filas(filas),
}
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
def ocupabilidad_alumnos(num_indice):
"""Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU)."""
def _load():
dm = get_dm()
analizador = AnalizadorCursos(dm)
alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice)
return {"alumnos": alumnos}
return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load)
# ── VENTAS ─────────────────────────────────────────────────────────────────
def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
def _load():
dm = get_dm()
# Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True).
# En Ventas se ignoran: la tabla muestra los datos reales del SQL.
if con_overrides:
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None
dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override}
dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inv_neta override}
else:
dm._tc_override_mes = None
dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {}
logic = VentasLogic(dm)
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes))
datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes))
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
pend_por_vend = {}
for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
vend = str(f[0]).strip()
# INV NETA está en la posición [7] como "S/ x"
try:
monto = float(str(f[7]).replace("S/", "").replace(",", "").strip())
except Exception:
monto = 0.0
acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0})
acc["monto"] += monto
acc["cant"] += 1
# Sobrescribir PENDIENTES en cada registro de la tabla principal
for d in datos:
v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0})
d["PENDIENTES"] = round(acc["monto"], 2)
d["INSCRITOS_PENDIENTES"] = acc["cant"]
except Exception as e:
print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}")
filas = logic.formatear_datos_para_tabla(datos)
return {
"filas": _serializar_filas(filas),
"datos": _serializar_dicts(datos),
}
return cache_get_or_set("ventas", (ano, mes, sede, programa, con_overrides), _load)
def _clasif_filtro_programa(dm, dscp):
"""Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS)."""
up = str(dscp or "").upper()
fdata = dm.meta_data.get("clasificacion_filtro_programa", {})
for cat in ["OTROS", "SEMINARIOS", "TEAC", "TERC"]:
for pat in fdata.get(cat, {}).get("patrones", []):
if pat.upper() in up:
return cat
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS", excluir_ret=False):
def _load():
dm = get_dm()
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes)
dm._fecha_canc_overrides = comisiones_overrides_fecha()
logic = VentasLogic(dm)
resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes), excluir_ret)
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
filas = _aplicar_overrides_comisiones(filas)
# Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]),
# mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL.
_sede = str(sede or "TODOS").upper()
_prog = str(programa or "TODOS").upper()
if _sede != "TODOS" or _prog != "TODOS":
out = []
for f in filas:
if str(f[0]).strip().upper() == "TOTAL GENERAL":
continue # el TOTAL se recalcula en el frontend
if len(f) < 15:
out.append(f); continue
if _sede != "TODOS" and str(f[14]).strip().upper() != _sede:
continue
# Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa);
# si no existe, usar el mostrado [8].
_prog_nombre = f[16] if len(f) > 16 else f[8]
if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog:
continue
out.append(f)
filas = out
return {"filas": _serializar_filas(filas)}
return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa, excluir_ret), _load)
def comisiones_detalle_todos(ano, mes):
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
Aplica overrides de Supabase. Cacheado globalmente (no depende del mes)."""
def _load():
dm = get_dm()
logic = VentasLogic(dm)
filas = logic.buscar_todos_matriculados()
filas = _aplicar_overrides_comisiones(filas)
return {"filas": _serializar_filas(filas)}
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
def comisiones_overrides_fecha():
"""Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación."""
try:
sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
out = {}
for r in (res.data or []):
mat = str(r["num_matricula"])
v = r.get("fecha_cancelacion1")
sv = "" if v is None else str(v).strip()
# Solo "__VACIO__" = fecha borrada a propósito → anula la del SQL.
# None/"" = la fila tiene override de OTROS campos pero no toco la fecha -> usar SQL.
if sv == "__VACIO__":
out[mat] = "__VACIO__"
elif sv != "":
out[mat] = sv
return out
except Exception:
return {}
def comisiones_overrides_inv_neta():
"""Devuelve dict {num_matricula: inversion_neta(float)} para la tabla principal.
El valor es el monto final en soles (no se re-convierte por TC)."""
try:
sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute()
out = {}
for r in (res.data or []):
v = r.get("inversion_neta")
if v is None:
continue
s = str(v).replace("S/", "").replace(",", "").strip()
if s == "":
continue
try:
out[str(r["num_matricula"])] = float(s)
except Exception:
pass
return out
except Exception:
return {}
def _aplicar_overrides_comisiones(filas):
"""Sobreescribe valores de cada fila con los guardados en Supabase (por num_matricula).
Índices de fila: [2]F.MAT [7]INV.NETA [9]F.INI [10]SALDO MAT [11]SALDO C1
[12]DÍAS [13]VALOR ADIC [14]TIPO PROG [15]num_matricula. (5=PROMEDIO CUOTA)"""
try:
dm = get_dm()
if not getattr(dm, "supabase_client", None):
return filas
res = dm.supabase_client.table("comisiones_overrides").select("*").execute()
ov = {str(r["num_matricula"]): r for r in (res.data or [])}
if not ov:
return filas
# columna override -> índice en la fila
mapa = {
"fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7,
"fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11,
"dias_anticipacion": 12, "valor_cuota_adicional": 13, "tipo_programa": 14,
"fecha_cancelacion1": 3,
}
for f in filas:
if len(f) < 16:
continue
mat = str(f[15])
# [16] = nombre para clasificar (si no vino, usar el mostrado [8])
# [17] = flag "tiene override" (0 por defecto)
if len(f) == 16:
f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado
if len(f) == 17:
f.append("0")
if mat in ov:
r = ov[mat]
for col, idx in mapa.items():
val = r.get(col)
if val is None:
continue
if str(val) == "__VACIO__":
f[idx] = "" # forzar vacío explícito (override "borrar")
elif str(val) != "":
f[idx] = val
f[17] = "1" # marcar fila como editada
return filas
except Exception as e:
print(f"[overrides comisiones] {e}")
return filas
# ── COBRANZA ───────────────────────────────────────────────────────────────
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"):
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
# Matrículas POR RETIRAR (Supabase) de ESTE mes/año, para la columna SALDO POR RETIRAR
try:
mats_retirar = set(alumnos_por_retirar(ano, mes).get("matriculas", []))
except Exception:
mats_retirar = set()
filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta, mats_retirar)
sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
return {
"filas": _serializar_filas(filas),
"sectoristas": sectoristas or ["TODOS"],
}
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta), _load)
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS"):
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
proc = logic.processor
pf = str(prog_filtro).upper()
sf = str(sede_filtro).upper()
vh = venc_hasta
# Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META)
mats_prog = None
if pf not in ("TODOS", "", "NONE") or sf not in ("TODOS", "", "NONE"):
crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
mats_prog = set()
for d in (crudos_pf or []):
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
ok_prog = pf in ("TODOS", "", "NONE") or str(proc.clasificar_filtro_programa(prog)).upper() == pf
ok_sede = sf in ("TODOS", "", "NONE") or str(proc.clasificar_sede_meta(prog)).upper() == sf
if ok_prog and ok_sede:
mats_prog.add(str(d.get("MATRICULA", "")))
if agrupacion == "ASESOR":
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh)
elif agrupacion == "SEDE":
# Filtrar por sede usando el MISMO clasificador del processor (sede.json)
crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
grupo_up = str(grupo).upper()
mats_sede = set()
for d in (crudos or []):
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
sede_det = str(proc.clasificar_sede(prog)).upper()
if sede_det == grupo_up:
mats_sede.add(str(d.get("MATRICULA", "")))
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh)
# Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total)
datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
else: # PROGRAMA
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh)
# Filtro por Sede y/o tipo de programa (TEAC/TERC/...) si aplica
if mats_prog is not None:
datos = [f for f in (datos or []) if str(f[0]) in mats_prog or str(f[0]).upper() == "TOTAL"]
return {"filas": _serializar_filas(datos)}
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load)
# ── RENTABILIDAD ───────────────────────────────────────────────────────────
def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
def _load():
dm = get_dm()
logic = RentabilidadLogic(dm)
datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog)
filas = logic.formatear_datos_para_tabla(datos)
filas_s = _serializar_filas(filas, 13)
nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()]
return {"filas": filas_s, "programas": nombres}
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
def rentabilidad_detalle(programa, ano, mes):
def _load():
dm = get_dm()
logic = RentabilidadLogic(dm)
resultado = logic.obtener_detalle_programa(programa, str(ano), str(mes))
return {"filas": _serializar_filas(resultado)}
return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load)
def rentabilidad_costos(programa, ano, mes):
def _load():
dm = get_dm()
logic = RentabilidadLogic(dm)
return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))}
return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load)
# ── SALDO PENDIENTE ────────────────────────────────────────────────────────
def saldo_pendiente(tipo_cuota):
def _load():
dm = get_dm()
logic = SaldoLogic(dm)
datos = logic.obtener_saldos_consolidados(tipo_cuota)
return {"datos": _serializar_dicts(datos)}
return cache_get_or_set("saldo", (tipo_cuota,), _load)
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
def precargar_todo():
from datetime import datetime
ahora = datetime.now()
ano, mes = ahora.year, ahora.month
tareas = [
lambda: ocupabilidad(ano, mes),
lambda: ventas(ano, mes),
lambda: cobranza(ano, mes, "TODOS", "SEDE"),
lambda: cobranza(ano, mes, "TODOS", "PROGRAMA"),
lambda: cobranza(ano, mes, "TODOS", "ASESOR"),
lambda: rentabilidad(ano, mes),
lambda: saldo_pendiente("1° Cuota"),
]
for t in tareas:
try:
t()
except Exception as e:
print(f"[precarga] error: {e}")
_marcar_actualizacion()
# Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos)
precargar_todos_modulos()
_refresh_count = 0
def _recalcular_clave(key):
"""Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo."""
from cache_manager import cache_invalidate
try:
prefijo, _, resto = key.partition(":")
args = resto.split("|") if resto else []
# Invalidar SOLO esta entrada para forzar su recálculo
cache_invalidate(prefijo + ":" + resto if resto else prefijo)
if prefijo == "ocupabilidad" and len(args) == 4:
return ocupabilidad(args[0], args[1], args[2], args[3])
if prefijo == "ventas" and len(args) == 2:
return ventas(args[0], args[1])
if prefijo == "ventas" and len(args) == 5:
return ventas(args[0], args[1], args[2], args[3], args[4] == "True")
if prefijo == "cobranza" and len(args) == 4:
return cobranza(args[0], args[1], args[2], args[3])
if prefijo == "rentabilidad" and len(args) == 4:
return rentabilidad(args[0], args[1], args[2], args[3])
if prefijo == "saldo" and len(args) == 1:
return saldo_pendiente(args[0])
if prefijo == "cobranza_det_todos" and len(args) == 3:
return cobranza_detalle_todos(args[0], args[1], args[2])
except Exception as e:
print(f"[recalcular] {key}: {e}")
return None # los detalles puntuales se recalculan al pedirse
def refrescar_todo():
"""Refresca SOLO las entradas ya cacheadas (no vacía el caché), para que los
meses ya visitados se mantengan rápidos. Cada ~1h recarga config de GitHub."""
global _refresh_count
from cache_manager import cache_refresh_existing, cache_keys
_refresh_count += 1
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
if _refresh_count % 4 == 0:
try:
get_dm().cargar_toda_configuracion()
print("[config] Recarga de queries/JSON de GitHub completada")
except Exception as e:
print(f"[config] error al recargar GitHub: {e}")
# Si el caché está vacío (primer arranque), hacer precarga normal
if not cache_keys():
precargar_todo()
else:
# Limpiar el caché anual de cobranza para que el recálculo lea SQL fresco
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
cache_refresh_existing(_recalcular_clave)
_marcar_actualizacion()
# Reprecargar mes actual + anterior de todos los módulos (incluye combos nuevos)
precargar_todos_modulos()
def refrescar_manual(clave_actual=None):
"""Refresco a demanda (botón 'Actualizar'):
1) VACÍA el caché y RE-LEE las bases (SQL/Postgre/Supabase) para la vista que el
usuario está viendo AHORA (clave_actual) → se devuelve al instante.
2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO (hilo aparte),
sin bloquear la respuesta.
NO afecta al ciclo automático de 15 min."""
import threading
from cache_manager import cache_keys, cache_invalidate
claves_previas = cache_keys()
# Vaciar TODO el caché para forzar re-lectura real de las bases
cache_invalidate()
# También el caché anual de cobranza (si no, el recálculo usaría datos viejos)
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
# 1) Vista actual primero (bloqueante, rápido)
if clave_actual:
try:
_recalcular_clave(clave_actual)
except Exception as e:
print(f"[refresh manual] vista actual {clave_actual}: {e}")
# 2) El resto en segundo plano
resto = [k for k in claves_previas if k != clave_actual]
def _fondo():
for k in resto:
try:
_recalcular_clave(k)
except Exception as e:
print(f"[refresh manual bg] {k}: {e}")
_marcar_actualizacion()
print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)")
threading.Thread(target=_fondo, daemon=True).start()
# Reprecargar mes actual + anterior de todos los módulos con datos frescos
precargar_todos_modulos()
_marcar_actualizacion()
return ultima_actualizacion()
# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ────────────────────
# Calienta en 2º plano TODAS las combinaciones de la TABLA PRINCIPAL del año
# indicado, de a una y con micro-pausas para NO saturar las bases. Solo lo que
# consulta al backend; los filtros que se resuelven en el cliente (sede/frecuencia
# en Cobranza) quedan instantáneos sin precargar nada. Los detalles (Ver/buscar)
# NO se precargan. Cada llamada nueva reemplaza (supersede) la precarga anterior.
import threading as _threading
_precarga_gen = {} # modulo -> id de generación (para cancelar la anterior)
_precarga_estado = {} # modulo -> {"total","hechas","completo","ano"}
_precarga_lock = _threading.Lock()
_PRECARGA_PAUSA = 0.15 # seg entre vistas, para no golpear las bases
def precarga_estado(modulo):
"""Progreso de la precarga de un módulo (para el aviso en el frontend)."""
modulo = (modulo or "").lower()
with _precarga_lock:
e = _precarga_estado.get(modulo)
return dict(e) if e else {"total": 0, "hechas": 0, "fallidas": 0, "completo": False}
def _periodos_act_ant():
"""(año, mes) del mes actual y el anterior (maneja el cambio de año en enero)."""
from datetime import datetime
hoy = datetime.now()
prev = (hoy.year - 1, 12) if hoy.month == 1 else (hoy.year, hoy.month - 1)
return [(hoy.year, hoy.month), prev]
def _combos_cobranza(ano):
"""Lista de (ano, mes, sectorista, agrupacion) a calentar para Cobranza.
SOLO el mes actual y el mes anterior (no todo el año)."""
periodos = _periodos_act_ant()
agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"]
combos = []
for (yy, mm) in periodos:
# sectoristas reales de ese mes (+ TODOS). Si falla, solo TODOS.
try:
logic = CobranzaLogic(get_dm())
secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"]
except Exception:
secs = ["TODOS"]
secs = list(dict.fromkeys(["TODOS"] + [s for s in secs if s and s != "TODOS"]))
for ag in agrupaciones:
for sec in secs:
combos.append((yy, mm, sec, ag))
return combos
def precargar_modulo(modulo, ano, en_hilo=True):
"""Precarga la tabla principal del módulo para el año (mes actual + anterior).
Si en_hilo=True lanza un hilo y devuelve al instante; si False corre sincrónico
(para encadenar varios módulos en un solo hilo, sin ráfagas de conexiones)."""
modulo = (modulo or "").lower()
with _precarga_lock:
gen = _precarga_gen.get(modulo, 0) + 1
_precarga_gen[modulo] = gen
_precarga_estado[modulo] = {"total": 0, "hechas": 0, "fallidas": 0, "completo": False, "ano": ano}
def _vigente():
with _precarga_lock:
return _precarga_gen.get(modulo) == gen
def _sumar(campo):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo][campo] += 1
def _set_total(n):
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["total"] = n
def _marcar_completo():
with _precarga_lock:
if _precarga_gen.get(modulo) == gen:
_precarga_estado[modulo]["completo"] = True
def _fondo():
import time
if modulo == "cobranza":
combos = _combos_cobranza(ano)
_set_total(len(combos))
for (yy, mm, sec, ag) in combos:
if not _vigente():
return # una precarga más nueva la reemplazó
try:
cobranza(str(yy), str(mm), sec, ag) # cache_get_or_set: si ya está, no reconsulta
_sumar("hechas")
except Exception as e:
print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)")
elif modulo in ("ocupabilidad", "ventas", "rentabilidad"):
# Solo mes actual y anterior, con filtros por defecto (TODOS/TODOS)
periodos = _periodos_act_ant()
_set_total(len(periodos))
for (yy, mm) in periodos:
if not _vigente():
return
try:
if modulo == "ocupabilidad":
ocupabilidad(str(yy), str(mm), "TODOS", "TODOS")
elif modulo == "ventas":
ventas(str(yy), str(mm), "TODOS", "TODOS")
else:
rentabilidad(str(yy), str(mm), "TODOS", "TODOS")
_sumar("hechas")
except Exception as e:
print(f"[precarga {modulo}] {yy}-{mm}: {e}")
_sumar("fallidas")
time.sleep(_PRECARGA_PAUSA)
_marcar_completo()
print(f"[precarga {modulo}] {ano} completado ({len(periodos)} vistas)")
else:
print(f"[precarga] módulo no soportado aún: {modulo}")
if en_hilo:
_threading.Thread(target=_fondo, daemon=True).start()
else:
_fondo()
return {"status": "precarga iniciada", "modulo": modulo, "ano": ano}
def precargar_todos_modulos():
"""Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma
SECUENCIAL en un solo hilo para no saturar el SQL. Se llama al arrancar, en cada
ciclo de 15 min y tras el refresco manual. No requiere entrar a cada módulo."""
from datetime import datetime
ano = datetime.now().year
def _todo():
for m in ("cobranza", "ocupabilidad", "ventas", "rentabilidad"):
try:
precargar_modulo(m, ano, en_hilo=False)
except Exception as e:
print(f"[precarga todos] {m}: {e}")
print("[precarga todos] completado (todos los módulos)")
_threading.Thread(target=_todo, daemon=True).start()
# ── GUARDAR COSTOS (override Supabase) ─────────────────────────────────────
def guardar_costos(num_indice, costos_inicial, costos_actual):
dm = get_dm()
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual)
from cache_manager import cache_invalidate
cache_invalidate("rentabilidad")
cache_invalidate("rentabilidad_costos")
return bool(ok1 and ok2)
# ── CLASIFICACIÓN DE SEDE (sede.json) ──────────────────────────────────────
def clasificar_programas(nombres):
"""Devuelve {programa: sede} usando el clasificador del processor (sede.json)."""
dm = get_dm()
logic = CobranzaLogic(dm)
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres}
def clasificar_programas_full(nombres):
"""Devuelve {programa: {sede, filtro}} usando el JSON REPLACE_META (meta_data):
- sede: clasificacion_sedes (LIMA/PIURA/AREQUIPA/TRUJILLO)
- filtro: clasificacion_filtro_programa (TEAC/TERC/SEMINARIOS/OTROS)."""
dm = get_dm()
logic = RentabilidadLogic(dm) # su identificar_sede/identificar_filtro_programa usan meta_data
out = {}
for n in nombres:
out[n] = {"sede": str(logic.identificar_sede(n)),
"filtro": str(logic.identificar_filtro_programa(n))}
return out
def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
"""Trae TODOS los alumnos en UNA sola consulta (para export y buscador)."""
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS")
alumnos = []
for d in (brutos or []):
prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-"
cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0))
cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0))
cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0))
saldo = float(d.get("SALDO", 0.0))
alumnos.append({
"matricula": str(d.get("MATRICULA", "")),
"alumno": d.get("ALUMNO", d.get("Alumno", "")),
"programa": prog,
"sede": str(logic.processor.clasificar_sede(prog)),
"frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")),
"num_cuota": d.get("NUM_CUOTA", "-"),
"fch_venc": d.get("FCH_VENC", "-"),
"cta_ant": cta_ant, "cob_ant": cob_ant,
"cta_cur": cta_cur, "cob_cur": cob_cur,
"cta_tot": cta_tot, "cob_tot": cob_tot,
"saldo": saldo,
})
return {"alumnos": alumnos}
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
def _sb():
dm = get_dm()
if not dm.supabase_client:
raise RuntimeError("Supabase no está conectado")
return dm.supabase_client
# ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ──
def alumno_estado_obtener(matricula, ano, mes):
"""Devuelve {estado, observaciones} de un alumno en (ano, mes). Default si no existe."""
sb = _sb()
res = (sb.table("cobranza_estado_alumno").select("*")
.eq("matricula", str(matricula)).eq("ano", int(ano)).eq("mes", int(mes))
.execute())
fila = (res.data or [None])[0]
if not fila:
return {"estado": "EN CURSO", "observaciones": []}
return {"estado": fila.get("estado") or "EN CURSO",
"observaciones": fila.get("observaciones") or []}
def alumno_estado_guardar(matricula, ano, mes, estado, observaciones, usuario=None):
"""Guarda (upsert) el estado y observaciones del alumno en (ano, mes)."""
sb = _sb()
from datetime import datetime, timezone
payload = {
"matricula": str(matricula),
"ano": int(ano),
"mes": int(mes),
"estado": estado or "EN CURSO",
"observaciones": observaciones or [],
"actualizado_en": datetime.now(timezone.utc).isoformat(),
"actualizado_por": usuario or None,
}
sb.table("cobranza_estado_alumno").upsert(payload, on_conflict="matricula,ano,mes").execute()
# Invalidar cobranza (columna SALDO POR RETIRAR) y el apartado Retiros
from cache_manager import cache_invalidate
cache_invalidate("cobranza")
cache_invalidate("retiros")
return {"status": "ok"}
def alumnos_por_retirar(ano, mes):
"""Matrículas marcadas POR RETIRAR en (ano, mes)."""
sb = _sb()
res = (sb.table("cobranza_estado_alumno").select("matricula")
.eq("estado", "POR RETIRAR").eq("ano", int(ano)).eq("mes", int(mes))
.execute())
return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]}
# ── RETIROS (apartado) ─────────────────────────────────────────────────────
def retiros(ano="2026", mes=8):
"""Apartado RETIROS: alumnos marcados POR RETIRAR en (ano, mes), con sus datos
del cronograma de cobranza. El estado POR RETIRAR y la info manual son de ese mes."""
def _load():
try:
mats_ret = set(alumnos_por_retirar(ano, mes).get("matriculas", []))
except Exception:
mats_ret = set()
if not mats_ret:
return {"filas": []}
dm = get_dm()
proc = CobranzaLogic(dm).processor # para clasificar SEDE por programa (REPLACE_META)
crono = dm.ejecutar_consulta_cronograma_cobranza(str(ano)) or []
# primera fila por matrícula (todas comparten los datos de cabecera)
idx = {}
for f in crono:
m = str(f.get("num_matricula", "")).strip()
if m and m not in idx:
idx[m] = f
# dsc_vendedor + cod_detalle por matrícula (consulta ligera, solo las POR RETIRAR)
try:
extra = dm.traer_vendedor_cod_detalle(list(mats_ret))
except Exception:
extra = {}
from datetime import datetime as _dtnow
_hoy = _dtnow.now()
cob_idx = {}
try:
alum = cobranza_detalle_todos(str(ano), str(_hoy.month), "TODOS").get("alumnos", [])
for a in alum:
cob_idx[str(a.get("matricula", "")).strip()] = a
except Exception:
cob_idx = {}
# Info manual guardada en Supabase de ESTE mes (una sola lectura). Indexado por matrícula.
try:
info_idx = retiros_info_obtener(ano, mes)
except Exception:
info_idx = {}
def _d(v):
return str(v or "")[:10]
filas = []
for m in mats_ret:
f = idx.get(str(m))
if not f:
continue
ex = extra.get(str(m), {})
cb = cob_idx.get(str(m), {})
inf = info_idx.get(str(m), {})
monto_ant = round((float(cb.get("cta_ant", 0) or 0) - float(cb.get("cob_ant", 0) or 0)), 2)
monto_cur = round((float(cb.get("cta_cur", 0) or 0) - float(cb.get("cob_cur", 0) or 0)), 2)
_prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip()
filas.append({
"matricula": str(m),
"sede": str(proc.clasificar_sede_meta(_prog)),
"fch_matricula": _d(f.get("fch_matricula")),
"asesor": str(f.get("dsc_sectorista", "") or "").strip(),
"vendedor": ex.get("dsc_vendedor", ""),
"programa": str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip(),
"cod_detalle": ex.get("cod_detalle", ""),
"fch_inicio": _d(f.get("fch_inicio")),
"fch_retiro": _d(f.get("fch_retiro")),
"alumno": ex.get("dsc_alumno", ""),
"num_cuotas_pagadas": ex.get("num_cuotas_pagadas", 0),
"num_cuotas_vencidas": ex.get("num_cuotas_vencidas", 0),
"fch_vencimiento": ex.get("fch_venc_mas_antigua", ""),
"monto_venc_ant": monto_ant,
"monto_venc_cur": monto_cur,
"imp_total_pagado": ex.get("imp_total_pagado", 0),
"monto_dejo_pagar": round(float(ex.get("inv_neta", 0) or 0) - float(ex.get("imp_total_pagado", 0) or 0), 2),
"n_asistencia": inf.get("n_asistencia"),
"n_faltas": inf.get("n_faltas"),
"ultimo_dia_asist": inf.get("ultimo_dia_asist") or "",
"hasta_sesion": inf.get("hasta_sesion") or "",
"fch_ult_resp": inf.get("fch_ult_resp") or "",
"fch_envio_correo": inf.get("fch_envio_correo") or "",
"retiro_wsp_class": inf.get("retiro_wsp_class") or "",
"motivo_retiro": inf.get("motivo_retiro") or "",
"detalle_retiro": inf.get("detalle_retiro") or "",
})
filas.sort(key=lambda x: x["fch_matricula"], reverse=True)
return {"filas": filas}
return cache_get_or_set("retiros", (str(ano), str(mes)), _load)
_RETIROS_INFO_CAMPOS = [
"n_asistencia", "n_faltas", "ultimo_dia_asist", "hasta_sesion",
"fch_ult_resp", "fch_envio_correo", "retiro_wsp_class",
"motivo_retiro", "detalle_retiro",
]
def _ri_num(v):
if v is None or v == "":
return None
try:
return int(v)
except Exception:
return None
def _ri_manual_payload(campos):
"""Solo las 9 columnas manuales, normalizadas para Supabase."""
c = campos or {}
return {
"n_asistencia": _ri_num(c.get("n_asistencia")),
"n_faltas": _ri_num(c.get("n_faltas")),
"ultimo_dia_asist": c.get("ultimo_dia_asist") or None,
"hasta_sesion": c.get("hasta_sesion") or None,
"fch_ult_resp": c.get("fch_ult_resp") or None,
"fch_envio_correo": c.get("fch_envio_correo") or None,
"retiro_wsp_class": c.get("retiro_wsp_class") or None,
"motivo_retiro": c.get("motivo_retiro") or None,
"detalle_retiro": c.get("detalle_retiro") or None,
}
def retiros_info_obtener(ano, mes):
"""Lee la info manual guardada de (ano, mes) en una sola consulta.
Devuelve un dict indexado por matrícula (egress mínimo: 1 lectura)."""
sb = _sb()
cols = "matricula," + ",".join(_RETIROS_INFO_CAMPOS)
res = (sb.table("retiros_info").select(cols)
.eq("ano", int(ano)).eq("mes", int(mes)).execute())
out = {}
for r in (res.data or []):
out[str(r.get("matricula", "")).strip()] = r
return out
def retiros_info_guardar(matricula, ano, mes, campos, usuario=None):
"""INFO: guarda SOLO las 9 columnas manuales de un alumno en (matricula, ano, mes)."""
sb = _sb()
from datetime import datetime, timezone
payload = {
"matricula": str(matricula),
"ano": int(ano),
"mes": int(mes),
**_ri_manual_payload(campos),
"actualizado_en": datetime.now(timezone.utc).isoformat(),
"actualizado_por": usuario or None,
}
sb.table("retiros_info").upsert(payload, on_conflict="matricula,ano,mes").execute()
from cache_manager import cache_invalidate
cache_invalidate("retiros")
return {"status": "ok"}
def retiros_snapshot_guardar(ano, mes, filas, usuario=None):
"""GUARDAR: reescribe TODO el mes. Borra los registros de (ano, mes) y reinserta
las filas recibidas con TODAS las columnas (snapshot calculado + 9 manuales)."""
sb = _sb()
from datetime import datetime, timezone
ano = int(ano); mes = int(mes)
now = datetime.now(timezone.utc).isoformat()
registros = []
for f in (filas or []):
registros.append({
"matricula": str(f.get("matricula", "")).strip(),
"ano": ano, "mes": mes,
"sede": f.get("sede") or None,
"fch_matricula": f.get("fch_matricula") or None,
"sectorista": f.get("asesor") or None,
"asesor_comercial": f.get("vendedor") or None,
"programa": f.get("programa") or None,
"cod_detalle": (str(f.get("cod_detalle")) if f.get("cod_detalle") not in (None, "") else None),
"fch_inicio": f.get("fch_inicio") or None,
"fch_retiro": f.get("fch_retiro") or None,
"alumno": f.get("alumno") or None,
"num_cuotas_pagadas": _ri_num(f.get("num_cuotas_pagadas")),
"num_cuotas_vencidas": _ri_num(f.get("num_cuotas_vencidas")),
"fch_vencimiento": f.get("fch_vencimiento") or None,
"monto_venc_ant": f.get("monto_venc_ant"),
"monto_venc_cur": f.get("monto_venc_cur"),
"monto_dejo_pagar": f.get("monto_dejo_pagar"),
**_ri_manual_payload(f),
"actualizado_en": now,
"actualizado_por": usuario or None,
})
sb.table("retiros_info").delete().eq("ano", ano).eq("mes", mes).execute()
if registros:
sb.table("retiros_info").insert(registros).execute()
from cache_manager import cache_invalidate
cache_invalidate("retiros")
return {"status": "ok", "guardados": len(registros)}
def usuarios_listar():
sb = _sb()
perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []
try:
users = sb.auth.admin.list_users()
lista = users if isinstance(users, list) else getattr(users, "users", [])
correo_por_id = {str(u.id): u.email for u in lista}
except Exception:
correo_por_id = {}
out = []
for p in perfiles:
out.append({
"id": p["id"],
"email": correo_por_id.get(str(p["id"]), "-"),
"nombre": p.get("nombre"),
"rol": p.get("rol"),
"activo": p.get("activo", True),
})
return {"usuarios": out}
def usuarios_crear(email, password, nombre, rol):
sb = _sb()
res = sb.auth.admin.create_user({
"email": email,
"password": password,
"email_confirm": True,
})
user = getattr(res, "user", None) or res
uid = str(user.id)
sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute()
return {"status": "ok", "id": uid}
def usuarios_actualizar_rol(user_id, rol):
sb = _sb()
sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute()
return {"status": "ok"}
def usuarios_eliminar(user_id):
sb = _sb()
sb.auth.admin.delete_user(user_id)
return {"status": "ok"}
# ── VENDEDORES MANUALES (Comisiones, en Supabase) ──────────────────────────
# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ──
def comisiones_config_listar(ano, mes):
"""Lista la config de un mes (todas las filas por tipo_programa)."""
sb = _sb()
res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute()
return {"config": res.data or []}
def comisiones_config_guardar(ano, mes, filas):
"""Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}]."""
sb = _sb()
sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute()
payload = []
for f in filas:
payload.append({
"ano": int(ano), "mes": int(mes),
"tipo_programa": f.get("tipo_programa", ""),
"imp_tc": float(f.get("imp_tc", 0) or 0),
"meta": float(f.get("meta", 0) or 0),
"comision": float(f.get("comision", 0) or 0),
})
if payload:
sb.table("comisiones_config_mensual").insert(payload).execute()
from cache_manager import cache_invalidate
cache_invalidate("ventas")
cache_invalidate("ventas_det")
return {"status": "ok"}
def comisiones_tc_del_mes(ano, mes):
"""Devuelve el IMP_TC configurado para el mes (toma el primero que tenga TC > 0), o None."""
try:
sb = _sb()
res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute()
for r in (res.data or []):
tc = float(r.get("imp_tc", 0) or 0)
if tc > 0: return tc
except Exception:
pass
return None
def vendedores_manuales_listar(ano, mes):
sb = _sb()
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend)
query = query.eq("mes", int(mes))
res = query.execute()
return {"vendedores": res.data or []}
def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes):
sb = _sb()
payload = {
"nombre": nombre, "descripcion": descripcion or "",
"fch_emision": fch_emision or "", "monto": float(monto or 0),
"ano": int(ano), "mes": int(mes),
}
res = sb.table("vendedores_manuales").insert(payload).execute()
return {"status": "ok", "data": res.data}
def vendedores_manuales_eliminar(id):
sb = _sb()
sb.table("vendedores_manuales").delete().eq("id", id).execute()
return {"status": "ok"}
def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
sb = _sb()
sb.table("vendedores_manuales").update({
"descripcion": descripcion or "", "fch_emision": fch_emision or "",
"monto": float(monto or 0),
}).eq("id", id).execute()
return {"status": "ok"}
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
"""Reemplaza TODAS las filas de un vendedor manual en una sola operación.
filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor."""
sb = _sb()
# Borrar las existentes de ese vendedor/mes
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
# Insertar las nuevas en un solo batch
payload = []
for f in filas:
payload.append({
"nombre": nombre,
"descripcion": f.get("descripcion","") or "",
"fch_emision": f.get("fch_emision","") or "",
"monto": float(f.get("monto",0) or 0),
"ano": int(ano), "mes": int(mes),
})
if payload:
sb.table("vendedores_manuales").insert(payload).execute()
return {"status": "ok"}
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
def comisiones_override_guardar(registros):
"""Guarda/actualiza overrides en UNA sola operación (batch upsert)."""
sb = _sb()
from cache_manager import cache_invalidate
validos = [r for r in registros if r.get("num_matricula")]
if validos:
sb.table("comisiones_overrides").upsert(validos).execute()
cache_invalidate("ventas_det")
cache_invalidate("ventas")
cache_invalidate("comisiones_det_todos")
return {"status": "ok"}
def comisiones_override_restaurar(num_matricula):
"""Borra el override de un alumno (vuelve a los valores de SQL)."""
sb = _sb()
from cache_manager import cache_invalidate
sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute()
cache_invalidate("ventas_det")
cache_invalidate("ventas")
cache_invalidate("comisiones_det_todos")
return {"status": "ok"}