Actualizar backend/services.py

This commit is contained in:
2026-08-27 16:53:50 -05:00
parent a71ab603cb
commit d7d1fc752a

View File

@@ -1,3 +1,4 @@
# backend/services.py
""" """
Capa de servicios: envuelve la lógica existente (modules/) con caché. 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. NO modifica la lógica de negocio — solo la llama y serializa el resultado.
@@ -25,12 +26,14 @@ def ultima_actualizacion():
"""Devuelve la hora (ISO) del último refresco de datos, o None.""" """Devuelve la hora (ISO) del último refresco de datos, o None."""
return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None} return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None}
def get_dm() -> DataManager: def get_dm() -> DataManager:
global _DM global _DM
if _DM is None: if _DM is None:
_DM = DataManager() _DM = DataManager()
return _DM return _DM
def _serializar_dicts(datos): def _serializar_dicts(datos):
out = [] out = []
for d in (datos or []): for d in (datos or []):
@@ -40,6 +43,7 @@ def _serializar_dicts(datos):
out.append(fila) out.append(fila)
return out return out
def _serializar_filas(filas, limite=None): def _serializar_filas(filas, limite=None):
out = [] out = []
for f in (filas or []): for f in (filas or []):
@@ -47,24 +51,14 @@ def _serializar_filas(filas, limite=None):
out.append([str(v) if v is not None else "" for v in vals]) out.append([str(v) if v is not None else "" for v in vals])
return out return out
# ── OCUPABILIDAD ─────────────────────────────────────────────────────────── # ── OCUPABILIDAD ───────────────────────────────────────────────────────────
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
analizador = AnalizadorCursos(dm) analizador = AnalizadorCursos(dm)
print(f"[DEBUG Ocupabilidad] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
# Intento 1: Formato original (ej: "6")
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa) datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
# Intento 2: Si viene vacío, probar con formato de dos dígitos (ej: "06")
if not datos and len(str(mes)) == 1:
mes_padded = str(mes).zfill(2)
print(f"[DEBUG Ocupabilidad] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = analizador.obtener_datos_procesados(str(ano), mes_padded, sede, programa)
filas = analizador.formatear_datos_para_tabla(datos) filas = analizador.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ocupabilidad] Proceso completado. Registros encontrados: {len(datos) if datos else 0}")
return { return {
"datos": _serializar_dicts(datos), "datos": _serializar_dicts(datos),
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
@@ -89,39 +83,29 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
# Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True). # Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True).
# En Ventas se ignoran: la tabla muestra los datos reales del SQL. # En Ventas se ignoran: la tabla muestra los datos reales del SQL.
if con_overrides: if con_overrides:
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None
dm._fecha_canc_overrides = comisiones_overrides_fecha() dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override}
dm._inv_neta_overrides = comisiones_overrides_inv_neta() dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inv_neta override}
else: else:
dm._tc_override_mes = None dm._tc_override_mes = None
dm._fecha_canc_overrides = {} dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {} dm._inv_neta_overrides = {}
logic = VentasLogic(dm) logic = VentasLogic(dm)
print(f"[DEBUG Ventas] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}") 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))
mes_str = str(mes) datos = [d for d in datos if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_str, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_str)
# Intento 2: Mes de dos dígitos si el original no trajo nada
if not datos and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Ventas] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_padded, sede, programa) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_padded)
datos = [d for d in (datos or []) if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR']
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # 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). # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try: try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str) det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes))
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
pend_por_vend = {} pend_por_vend = {}
for f in pend_filas: for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue continue
vend = str(f[0]).strip() vend = str(f[0]).strip()
# INV NETA está en la posición [7] como "S/ x"
try: try:
monto = float(str(f[7]).replace("S/", "").replace(",", "").strip()) monto = float(str(f[7]).replace("S/", "").replace(",", "").strip())
except Exception: except Exception:
@@ -129,6 +113,7 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0}) acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0})
acc["monto"] += monto acc["monto"] += monto
acc["cant"] += 1 acc["cant"] += 1
# Sobrescribir PENDIENTES en cada registro de la tabla principal
for d in datos: for d in datos:
v = str(d.get("VENDEDOR", "")).strip() v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0})
@@ -138,12 +123,12 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}") print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}")
filas = logic.formatear_datos_para_tabla(datos) filas = logic.formatear_datos_para_tabla(datos)
print(f"[DEBUG Ventas] Proceso completado. Registros encontrados: {len(datos)}")
return { return {
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
"datos": _serializar_dicts(datos), "datos": _serializar_dicts(datos),
} }
return cache_get_or_set("ventas", (ano, mes, sede, programa), _load) return cache_get_or_set("ventas", (ano, mes, sede, programa, con_overrides), _load)
def _clasif_filtro_programa(dm, dscp): def _clasif_filtro_programa(dm, dscp):
"""Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS).""" """Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS)."""
@@ -155,6 +140,7 @@ def _clasif_filtro_programa(dm, dscp):
return cat return cat
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS" 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"): def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -164,7 +150,8 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes)) resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes))
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else [] filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
filas = _aplicar_overrides_comisiones(filas) 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() _sede = str(sede or "TODOS").upper()
_prog = str(programa or "TODOS").upper() _prog = str(programa or "TODOS").upper()
if _sede != "TODOS" or _prog != "TODOS": if _sede != "TODOS" or _prog != "TODOS":
@@ -176,6 +163,8 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
out.append(f); continue out.append(f); continue
if _sede != "TODOS" and str(f[14]).strip().upper() != _sede: if _sede != "TODOS" and str(f[14]).strip().upper() != _sede:
continue 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] _prog_nombre = f[16] if len(f) > 16 else f[8]
if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog: if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog:
continue continue
@@ -184,7 +173,10 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS
return {"filas": _serializar_filas(filas)} return {"filas": _serializar_filas(filas)}
return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load) return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load)
def comisiones_detalle_todos(ano, mes): 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(): def _load():
dm = get_dm() dm = get_dm()
logic = VentasLogic(dm) logic = VentasLogic(dm)
@@ -193,7 +185,9 @@ def comisiones_detalle_todos(ano, mes):
return {"filas": _serializar_filas(filas)} return {"filas": _serializar_filas(filas)}
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
def comisiones_overrides_fecha(): def comisiones_overrides_fecha():
"""Devuelve dict {num_matricula: fecha_cancelacion1_override} para clasificación."""
try: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute() res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
@@ -202,6 +196,8 @@ def comisiones_overrides_fecha():
mat = str(r["num_matricula"]) mat = str(r["num_matricula"])
v = r.get("fecha_cancelacion1") v = r.get("fecha_cancelacion1")
sv = "" if v is None else str(v).strip() 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__": if sv == "__VACIO__":
out[mat] = "__VACIO__" out[mat] = "__VACIO__"
elif sv != "": elif sv != "":
@@ -210,7 +206,10 @@ def comisiones_overrides_fecha():
except Exception: except Exception:
return {} return {}
def comisiones_overrides_inv_neta(): 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: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute() res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute()
@@ -222,14 +221,19 @@ def comisiones_overrides_inv_neta():
s = str(v).replace("S/", "").replace(",", "").strip() s = str(v).replace("S/", "").replace(",", "").strip()
if s == "": if s == "":
continue continue
try: out[str(r["num_matricula"])] = float(s) try:
out[str(r["num_matricula"])] = float(s)
except Exception: except Exception:
pass pass
return out return out
except Exception: except Exception:
return {} return {}
def _aplicar_overrides_comisiones(filas): 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: try:
dm = get_dm() dm = get_dm()
if not getattr(dm, "supabase_client", None): if not getattr(dm, "supabase_client", None):
@@ -238,6 +242,7 @@ def _aplicar_overrides_comisiones(filas):
ov = {str(r["num_matricula"]): r for r in (res.data or [])} ov = {str(r["num_matricula"]): r for r in (res.data or [])}
if not ov: if not ov:
return filas return filas
# columna override -> índice en la fila
mapa = { mapa = {
"fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7, "fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7,
"fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11, "fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11,
@@ -248,8 +253,10 @@ def _aplicar_overrides_comisiones(filas):
if len(f) < 16: if len(f) < 16:
continue continue
mat = str(f[15]) 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: if len(f) == 16:
f.append(f[8]) f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado
if len(f) == 17: if len(f) == 17:
f.append("0") f.append("0")
if mat in ov: if mat in ov:
@@ -259,59 +266,72 @@ def _aplicar_overrides_comisiones(filas):
if val is None: if val is None:
continue continue
if str(val) == "__VACIO__": if str(val) == "__VACIO__":
f[idx] = "" f[idx] = "" # forzar vacío explícito (override "borrar")
elif str(val) != "": elif str(val) != "":
f[idx] = val f[idx] = val
f[17] = "1" f[17] = "1" # marcar fila como editada
return filas return filas
except Exception as e: except Exception as e:
print(f"[overrides comisiones] {e}") print(f"[overrides comisiones] {e}")
return filas return filas
# ── COBRANZA ─────────────────────────────────────────────────────────────── # ── COBRANZA ───────────────────────────────────────────────────────────────
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"): def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
print(f"[DEBUG Cobranza] Buscando ano={ano}, mes={mes}, sectorista={sectorista}, agrupacion={agrupacion}") filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta)
sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
mes_str = str(mes)
filas = logic.obtener_datos_tabla(str(ano), mes_str, sectorista, agrupacion)
if not filas and len(mes_str) == 1:
mes_padded = mes_str.zfill(2)
print(f"[DEBUG Cobranza] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'")
filas = logic.obtener_datos_tabla(str(ano), mes_padded, sectorista, agrupacion)
sectoristas = logic.obtener_lista_sectoristas(str(ano), mes_str)
print(f"[DEBUG Cobranza] Proceso completado. Filas encontradas: {len(filas) if filas else 0}")
return { return {
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
"sectoristas": sectoristas or ["TODOS"], "sectoristas": sectoristas or ["TODOS"],
} }
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load) 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"):
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(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": if agrupacion == "ASESOR":
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS") datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh)
elif agrupacion == "SEDE": elif agrupacion == "SEDE":
crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") # 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() grupo_up = str(grupo).upper()
mats_sede = set() mats_sede = set()
for d in (crudos or []): for d in (crudos or []):
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
sede_det = str(logic.processor.clasificar_sede(prog)).upper() sede_det = str(proc.clasificar_sede(prog)).upper()
if sede_det == grupo_up: if sede_det == grupo_up:
mats_sede.add(str(d.get("MATRICULA", ""))) mats_sede.add(str(d.get("MATRICULA", "")))
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS") 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] datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
else: else: # PROGRAMA
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo) 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 {"filas": _serializar_filas(datos)}
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load) return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load)
# ── RENTABILIDAD ─────────────────────────────────────────────────────────── # ── RENTABILIDAD ───────────────────────────────────────────────────────────
def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
@@ -325,6 +345,7 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"):
return {"filas": filas_s, "programas": nombres} return {"filas": filas_s, "programas": nombres}
return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load) return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load)
def rentabilidad_detalle(programa, ano, mes): def rentabilidad_detalle(programa, ano, mes):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -333,6 +354,7 @@ def rentabilidad_detalle(programa, ano, mes):
return {"filas": _serializar_filas(resultado)} return {"filas": _serializar_filas(resultado)}
return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load) return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load)
def rentabilidad_costos(programa, ano, mes): def rentabilidad_costos(programa, ano, mes):
def _load(): def _load():
dm = get_dm() dm = get_dm()
@@ -340,6 +362,7 @@ def rentabilidad_costos(programa, ano, mes):
return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))} return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))}
return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load) return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load)
# ── SALDO PENDIENTE ──────────────────────────────────────────────────────── # ── SALDO PENDIENTE ────────────────────────────────────────────────────────
def saldo_pendiente(tipo_cuota): def saldo_pendiente(tipo_cuota):
def _load(): def _load():
@@ -349,7 +372,8 @@ def saldo_pendiente(tipo_cuota):
return {"datos": _serializar_dicts(datos)} return {"datos": _serializar_dicts(datos)}
return cache_get_or_set("saldo", (tipo_cuota,), _load) return cache_get_or_set("saldo", (tipo_cuota,), _load)
# ── PRECARGA GLOBAL ────────────────────────────────────────────────────────
# ── PRECARGA GLOBAL (calienta todo el caché) ───────────────────────────────
def precargar_todo(): def precargar_todo():
from datetime import datetime from datetime import datetime
ahora = datetime.now() ahora = datetime.now()
@@ -372,13 +396,16 @@ def precargar_todo():
# Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos) # Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos)
precargar_todos_modulos() precargar_todos_modulos()
_refresh_count = 0 _refresh_count = 0
def _recalcular_clave(key): def _recalcular_clave(key):
"""Dada una clave 'prefijo:arg1|arg2|...', recalcula su valor sin usar el caché viejo."""
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
try: try:
prefijo, _, resto = key.partition(":") prefijo, _, resto = key.partition(":")
args = resto.split("|") if resto else [] args = resto.split("|") if resto else []
# Invalidar SOLO esta entrada para forzar su recálculo
cache_invalidate(prefijo + ":" + resto if resto else prefijo) cache_invalidate(prefijo + ":" + resto if resto else prefijo)
if prefijo == "ocupabilidad" and len(args) == 4: if prefijo == "ocupabilidad" and len(args) == 4:
return ocupabilidad(args[0], args[1], args[2], args[3]) return ocupabilidad(args[0], args[1], args[2], args[3])
@@ -396,18 +423,24 @@ def _recalcular_clave(key):
return cobranza_detalle_todos(args[0], args[1], args[2]) return cobranza_detalle_todos(args[0], args[1], args[2])
except Exception as e: except Exception as e:
print(f"[recalcular] {key}: {e}") print(f"[recalcular] {key}: {e}")
return None return None # los detalles puntuales se recalculan al pedirse
def refrescar_todo(): 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 global _refresh_count
from cache_manager import cache_refresh_existing, cache_keys from cache_manager import cache_refresh_existing, cache_keys
_refresh_count += 1 _refresh_count += 1
# El hilo corre cada 900s (15 min); 4 ciclos ≈ 60 min → recargar config GitHub
if _refresh_count % 4 == 0: if _refresh_count % 4 == 0:
try: try:
get_dm().cargar_toda_configuracion() get_dm().cargar_toda_configuracion()
print("[config] Recarga de queries/JSON de GitHub completada") print("[config] Recarga de queries/JSON de GitHub completada")
except Exception as e: except Exception as e:
print(f"[config] error al recargar GitHub: {e}") print(f"[config] error al recargar GitHub: {e}")
# Si el caché está vacío (primer arranque), hacer precarga normal
if not cache_keys(): if not cache_keys():
precargar_todo() precargar_todo()
else: else:
@@ -424,25 +457,31 @@ def refrescar_todo():
def refrescar_manual(clave_actual=None): def refrescar_manual(clave_actual=None):
"""Refresco a demanda (botón 'Actualizar'): """Refresco a demanda (botón 'Actualizar'):
1) VACÍA el caché y RE-LEE las bases para la vista que el usuario ve AHORA. 1) VACÍA el caché y RE-LEE las bases (SQL/Postgre/Supabase) para la vista que el
2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO. 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.""" NO afecta al ciclo automático de 15 min."""
import threading import threading
from cache_manager import cache_keys, cache_invalidate from cache_manager import cache_keys, cache_invalidate
claves_previas = cache_keys() claves_previas = cache_keys()
# Vaciar TODO el caché para forzar re-lectura real de las bases
cache_invalidate() cache_invalidate()
# También el caché anual de cobranza (si no, el recálculo usaría datos viejos)
try: try:
get_dm().limpiar_cache_cobranza() get_dm().limpiar_cache_cobranza()
except Exception: except Exception:
pass pass
# 1) Vista actual primero (bloqueante, rápido)
if clave_actual: if clave_actual:
try: try:
_recalcular_clave(clave_actual) _recalcular_clave(clave_actual)
except Exception as e: except Exception as e:
print(f"[refresh manual] vista actual {clave_actual}: {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] resto = [k for k in claves_previas if k != clave_actual]
def _fondo(): def _fondo():
@@ -455,21 +494,27 @@ def refrescar_manual(clave_actual=None):
print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)") print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)")
threading.Thread(target=_fondo, daemon=True).start() threading.Thread(target=_fondo, daemon=True).start()
# Reprecargar mes actual + anterior de todos los módulos con datos frescos
precargar_todos_modulos() precargar_todos_modulos()
_marcar_actualizacion() _marcar_actualizacion()
return ultima_actualizacion() return ultima_actualizacion()
# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ──────────────────── # ── 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 import threading as _threading
_precarga_gen = {} _precarga_gen = {} # modulo -> id de generación (para cancelar la anterior)
_precarga_estado = {} _precarga_estado = {} # modulo -> {"total","hechas","completo","ano"}
_precarga_lock = _threading.Lock() _precarga_lock = _threading.Lock()
_PRECARGA_PAUSA = 0.15 _PRECARGA_PAUSA = 0.15 # seg entre vistas, para no golpear las bases
def precarga_estado(modulo): def precarga_estado(modulo):
"""Progreso de la precarga de un módulo.""" """Progreso de la precarga de un módulo (para el aviso en el frontend)."""
modulo = (modulo or "").lower() modulo = (modulo or "").lower()
with _precarga_lock: with _precarga_lock:
e = _precarga_estado.get(modulo) e = _precarga_estado.get(modulo)
@@ -485,11 +530,13 @@ def _periodos_act_ant():
def _combos_cobranza(ano): def _combos_cobranza(ano):
"""(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior.""" """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() periodos = _periodos_act_ant()
agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"] agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"]
combos = [] combos = []
for (yy, mm) in periodos: for (yy, mm) in periodos:
# sectoristas reales de ese mes (+ TODOS). Si falla, solo TODOS.
try: try:
logic = CobranzaLogic(get_dm()) logic = CobranzaLogic(get_dm())
secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"] secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"]
@@ -503,7 +550,9 @@ def _combos_cobranza(ano):
def precargar_modulo(modulo, ano, en_hilo=True): def precargar_modulo(modulo, ano, en_hilo=True):
"""Precarga la tabla principal del módulo (mes actual + anterior).""" """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() modulo = (modulo or "").lower()
with _precarga_lock: with _precarga_lock:
gen = _precarga_gen.get(modulo, 0) + 1 gen = _precarga_gen.get(modulo, 0) + 1
@@ -536,9 +585,9 @@ def precargar_modulo(modulo, ano, en_hilo=True):
_set_total(len(combos)) _set_total(len(combos))
for (yy, mm, sec, ag) in combos: for (yy, mm, sec, ag) in combos:
if not _vigente(): if not _vigente():
return return # una precarga más nueva la reemplazó
try: try:
cobranza(str(yy), str(mm), sec, ag) cobranza(str(yy), str(mm), sec, ag) # cache_get_or_set: si ya está, no reconsulta
_sumar("hechas") _sumar("hechas")
except Exception as e: except Exception as e:
print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}") print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}")
@@ -547,6 +596,7 @@ def precargar_modulo(modulo, ano, en_hilo=True):
_marcar_completo() _marcar_completo()
print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)") print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)")
elif modulo in ("ocupabilidad", "ventas", "rentabilidad"): elif modulo in ("ocupabilidad", "ventas", "rentabilidad"):
# Solo mes actual y anterior, con filtros por defecto (TODOS/TODOS)
periodos = _periodos_act_ant() periodos = _periodos_act_ant()
_set_total(len(periodos)) _set_total(len(periodos))
for (yy, mm) in periodos: for (yy, mm) in periodos:
@@ -578,7 +628,8 @@ def precargar_modulo(modulo, ano, en_hilo=True):
def precargar_todos_modulos(): def precargar_todos_modulos():
"""Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma """Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma
SECUENCIAL en un solo hilo. Se llama al arrancar, cada 15 min y tras el refresco manual.""" 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 from datetime import datetime
ano = datetime.now().year ano = datetime.now().year
@@ -593,7 +644,7 @@ def precargar_todos_modulos():
_threading.Thread(target=_todo, daemon=True).start() _threading.Thread(target=_todo, daemon=True).start()
# ── OPERACIONES EXTRA ────────────────────────────────────────────────────── # ── GUARDAR COSTOS (override Supabase) ─────────────────────────────────────
def guardar_costos(num_indice, costos_inicial, costos_actual): def guardar_costos(num_indice, costos_inicial, costos_actual):
dm = get_dm() dm = get_dm()
ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial) ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial)
@@ -603,12 +654,30 @@ def guardar_costos(num_indice, costos_inicial, costos_actual):
cache_invalidate("rentabilidad_costos") cache_invalidate("rentabilidad_costos")
return bool(ok1 and ok2) return bool(ok1 and ok2)
# ── CLASIFICACIÓN DE SEDE (sede.json) ──────────────────────────────────────
def clasificar_programas(nombres): def clasificar_programas(nombres):
"""Devuelve {programa: sede} usando el clasificador del processor (sede.json)."""
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres} 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"): def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
"""Trae TODOS los alumnos en UNA sola consulta (para export y buscador)."""
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
@@ -636,7 +705,8 @@ def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
return {"alumnos": alumnos} return {"alumnos": alumnos}
return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load)
# ── GESTIÓN DE USUARIOS ────────────────────────────────────────────────────
# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ───────────────────
def _sb(): def _sb():
dm = get_dm() dm = get_dm()
if not dm.supabase_client: if not dm.supabase_client:
@@ -716,13 +786,17 @@ def usuarios_eliminar(user_id):
sb.auth.admin.delete_user(user_id) sb.auth.admin.delete_user(user_id)
return {"status": "ok"} return {"status": "ok"}
# ── CONFIG MENSUAL COMISIONES ──────────────────────────────────────────────
# ── VENDEDORES MANUALES (Comisiones, en Supabase) ──────────────────────────
# ── CONFIG MENSUAL COMISIONES (IMP_TC, META, COMISIÓN por tipo de programa) ──
def comisiones_config_listar(ano, mes): def comisiones_config_listar(ano, mes):
"""Lista la config de un mes (todas las filas por tipo_programa)."""
sb = _sb() sb = _sb()
res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute() res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute()
return {"config": res.data or []} return {"config": res.data or []}
def comisiones_config_guardar(ano, mes, filas): def comisiones_config_guardar(ano, mes, filas):
"""Reemplaza la config del mes. filas = [{tipo_programa, imp_tc, meta, comision}]."""
sb = _sb() sb = _sb()
sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute() sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute()
payload = [] payload = []
@@ -742,6 +816,7 @@ def comisiones_config_guardar(ano, mes, filas):
return {"status": "ok"} return {"status": "ok"}
def comisiones_tc_del_mes(ano, mes): 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: try:
sb = _sb() sb = _sb()
res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute() res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute()
@@ -752,10 +827,11 @@ def comisiones_tc_del_mes(ano, mes):
pass pass
return None return None
def vendedores_manuales_listar(ano, mes): def vendedores_manuales_listar(ano, mes):
sb = _sb() sb = _sb()
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
if int(mes) != 0: if int(mes) != 0: # mes=0 → traer todo el año (para cache en frontend)
query = query.eq("mes", int(mes)) query = query.eq("mes", int(mes))
res = query.execute() res = query.execute()
return {"vendedores": res.data or []} return {"vendedores": res.data or []}
@@ -775,6 +851,7 @@ def vendedores_manuales_eliminar(id):
sb.table("vendedores_manuales").delete().eq("id", id).execute() sb.table("vendedores_manuales").delete().eq("id", id).execute()
return {"status": "ok"} return {"status": "ok"}
def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
sb = _sb() sb = _sb()
sb.table("vendedores_manuales").update({ sb.table("vendedores_manuales").update({
@@ -783,9 +860,14 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
}).eq("id", id).execute() }).eq("id", id).execute()
return {"status": "ok"} return {"status": "ok"}
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): 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() 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() 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 = [] payload = []
for f in filas: for f in filas:
payload.append({ payload.append({
@@ -799,8 +881,12 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
sb.table("vendedores_manuales").insert(payload).execute() sb.table("vendedores_manuales").insert(payload).execute()
return {"status": "ok"} return {"status": "ok"}
# ── OVERRIDES DE COMISIONES ────────────────────────────────────────────────
# ── OVERRIDES DE COMISIONES (editar valores por num_matricula) ─────────────
def comisiones_override_guardar(registros): def comisiones_override_guardar(registros):
"""Guarda/actualiza overrides en UNA sola operación (batch upsert)."""
sb = _sb() sb = _sb()
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
validos = [r for r in registros if r.get("num_matricula")] validos = [r for r in registros if r.get("num_matricula")]
@@ -812,6 +898,7 @@ def comisiones_override_guardar(registros):
return {"status": "ok"} return {"status": "ok"}
def comisiones_override_restaurar(num_matricula): def comisiones_override_restaurar(num_matricula):
"""Borra el override de un alumno (vuelve a los valores de SQL)."""
sb = _sb() sb = _sb()
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute() sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute()