1285 lines
55 KiB
Python
1285 lines
55 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():
|
|
# ¿Hay SNAPSHOT guardado (GUARDAR TOTAL) de este mes? Si hay al menos una
|
|
# fila guardada, se muestra ese snapshot congelado (todas las columnas).
|
|
snap = retiros_snapshot_obtener(ano, mes)
|
|
if snap.get("filas"):
|
|
return {"filas": snap["filas"], "guardado": True,
|
|
"guardado_en": snap.get("guardado_en")}
|
|
try:
|
|
mats_ret = set(alumnos_por_retirar(ano, mes).get("matriculas", []))
|
|
except Exception:
|
|
mats_ret = set()
|
|
if not mats_ret:
|
|
return {"filas": [], "guardado": False}
|
|
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, "guardado": False}
|
|
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_snapshot_obtener(ano, mes):
|
|
"""Lee el SNAPSHOT completo guardado (GUARDAR TOTAL) de (ano, mes) y lo devuelve
|
|
con los MISMOS nombres de campo que usa la tabla principal de Retiros.
|
|
Solo cuenta como snapshot si la fila tiene datos completos (alumno/programa)."""
|
|
sb = _sb()
|
|
try:
|
|
res = (sb.table("retiros_info").select("*")
|
|
.eq("ano", int(ano)).eq("mes", int(mes)).execute())
|
|
except Exception:
|
|
return {"filas": []}
|
|
filas = []
|
|
guardado_en = None
|
|
for r in (res.data or []):
|
|
if not (r.get("alumno") or r.get("programa")):
|
|
continue
|
|
if not guardado_en and r.get("actualizado_en"):
|
|
guardado_en = str(r.get("actualizado_en"))[:16].replace("T", " ")
|
|
filas.append({
|
|
"matricula": str(r.get("matricula", "")).strip(),
|
|
"sede": r.get("sede") or "",
|
|
"fch_matricula": r.get("fch_matricula") or "",
|
|
"asesor": r.get("sectorista") or "",
|
|
"vendedor": r.get("asesor_comercial") or "",
|
|
"programa": r.get("programa") or "",
|
|
"cod_detalle": r.get("cod_detalle") or "",
|
|
"fch_inicio": r.get("fch_inicio") or "",
|
|
"fch_retiro": r.get("fch_retiro") or "",
|
|
"alumno": r.get("alumno") or "",
|
|
"num_cuotas_pagadas": r.get("num_cuotas_pagadas") or 0,
|
|
"num_cuotas_vencidas": r.get("num_cuotas_vencidas") or 0,
|
|
"fch_vencimiento": r.get("fch_vencimiento") or "",
|
|
"monto_venc_ant": r.get("monto_venc_ant") or 0,
|
|
"monto_venc_cur": r.get("monto_venc_cur") or 0,
|
|
"imp_total_pagado": r.get("imp_total_pagado") or 0,
|
|
"monto_dejo_pagar": r.get("monto_dejo_pagar") or 0,
|
|
"n_asistencia": r.get("n_asistencia"),
|
|
"n_faltas": r.get("n_faltas"),
|
|
"ultimo_dia_asist": r.get("ultimo_dia_asist") or "",
|
|
"hasta_sesion": r.get("hasta_sesion") or "",
|
|
"fch_ult_resp": r.get("fch_ult_resp") or "",
|
|
"fch_envio_correo": r.get("fch_envio_correo") or "",
|
|
"retiro_wsp_class": r.get("retiro_wsp_class") or "",
|
|
"motivo_retiro": r.get("motivo_retiro") or "",
|
|
"detalle_retiro": r.get("detalle_retiro") or "",
|
|
})
|
|
filas.sort(key=lambda x: x["fch_matricula"], reverse=True)
|
|
return {"filas": filas, "guardado_en": guardado_en}
|
|
|
|
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"),
|
|
"imp_total_pagado": f.get("imp_total_pagado"),
|
|
"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_actualizar_email(user_id, email):
|
|
"""Cambia el correo de un usuario existente (Supabase Auth)."""
|
|
sb = _sb()
|
|
sb.auth.admin.update_user_by_id(user_id, {"email": email, "email_confirm": True})
|
|
return {"status": "ok"}
|
|
|
|
def usuarios_actualizar_password(user_id, password):
|
|
"""Cambia la contraseña de un usuario existente (Supabase Auth)."""
|
|
sb = _sb()
|
|
sb.auth.admin.update_user_by_id(user_id, {"password": password})
|
|
return {"status": "ok"}
|
|
|
|
def usuarios_eliminar(user_id):
|
|
sb = _sb()
|
|
sb.auth.admin.delete_user(user_id)
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── ROLES Y PERMISOS (tabla 'permisos': rol, modulo, puede_ver) ────────────
|
|
def roles_modulos_disponibles():
|
|
"""Lista de módulos existentes (todos los que aparecen en la tabla permisos)."""
|
|
sb = _sb()
|
|
res = sb.table("permisos").select("modulo").execute()
|
|
mods = sorted({str(r.get("modulo", "")).strip() for r in (res.data or []) if r.get("modulo")})
|
|
return {"modulos": mods}
|
|
|
|
def roles_listar():
|
|
"""Devuelve todos los roles con la lista de módulos que puede ver cada uno."""
|
|
sb = _sb()
|
|
res = sb.table("permisos").select("rol,modulo,puede_ver").execute()
|
|
por_rol = {}
|
|
for r in (res.data or []):
|
|
rol = str(r.get("rol", "")).strip()
|
|
if not rol:
|
|
continue
|
|
por_rol.setdefault(rol, [])
|
|
if r.get("puede_ver"):
|
|
por_rol[rol].append(str(r.get("modulo", "")).strip())
|
|
return {"roles": [{"rol": k, "modulos": sorted(v)} for k, v in sorted(por_rol.items())]}
|
|
|
|
def rol_guardar(rol, modulos):
|
|
"""Crea o actualiza un rol: borra sus filas y reinserta una por módulo marcado."""
|
|
sb = _sb()
|
|
rol = str(rol or "").strip().upper()
|
|
if not rol:
|
|
return {"status": "error", "msg": "Nombre de rol vacío"}
|
|
sb.table("permisos").delete().eq("rol", rol).execute()
|
|
registros = [{"rol": rol, "modulo": str(m).strip(), "puede_ver": True}
|
|
for m in (modulos or []) if str(m).strip()]
|
|
if registros:
|
|
sb.table("permisos").insert(registros).execute()
|
|
return {"status": "ok", "rol": rol, "modulos": len(registros)}
|
|
|
|
def rol_eliminar(rol):
|
|
"""Borra un rol y todos sus permisos."""
|
|
sb = _sb()
|
|
sb.table("permisos").delete().eq("rol", str(rol or "").strip().upper()).execute()
|
|
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"}
|
|
|
|
|
|
# ── HISTORIAL DE ESTADO DE ASESORES (Chatwoot -> historial_estado_asesor) ──
|
|
def registrar_estado_asesores():
|
|
"""Lee el estado actual de cada agente en Chatwoot (account_users) y, si cambió
|
|
respecto a lo último guardado, inserta una fila en historial_estado_asesor.
|
|
availability: 0 = ON (conectado), 1 = OFF (desconectado)."""
|
|
dm = get_dm()
|
|
conn = dm.get_pg_connection()
|
|
if conn is None:
|
|
return {"status": "error", "msg": "sin conexion postgre"}
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT u.name, u.uid, au.availability, au.updated_at
|
|
FROM account_users au
|
|
JOIN users u ON au.user_id = u.id
|
|
WHERE u.uid LIKE '%@escueladerefrigeracion.edu.pe'
|
|
""")
|
|
actuales = cur.fetchall()
|
|
|
|
cur.execute("""
|
|
SELECT DISTINCT ON (email) email, accion
|
|
FROM historial_estado_asesor
|
|
ORDER BY email, fecha_hora DESC
|
|
""")
|
|
ultimo = {r[0]: r[1] for r in cur.fetchall()}
|
|
|
|
nuevos = 0
|
|
for (name, email, avail, updated_at) in actuales:
|
|
accion = "ON" if int(avail) == 0 else "OFF"
|
|
if ultimo.get(email) == accion:
|
|
continue
|
|
cur.execute("""
|
|
INSERT INTO historial_estado_asesor (asesor, email, accion, origen, quien, fecha_hora)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
""", (name, email, accion, "CHATWOOT", name, updated_at))
|
|
nuevos += 1
|
|
conn.commit()
|
|
return {"status": "ok", "nuevos": nuevos}
|
|
except Exception as e:
|
|
print(f"[historial asesores] {e}")
|
|
return {"status": "error", "msg": str(e)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def historial_asesor(email, limite=100):
|
|
"""Devuelve el historial de estados de un asesor (por email), más reciente primero."""
|
|
dm = get_dm()
|
|
conn = dm.get_pg_connection()
|
|
if conn is None:
|
|
return {"historial": []}
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT accion, origen, quien, fecha_hora
|
|
FROM historial_estado_asesor
|
|
WHERE email = %s
|
|
ORDER BY fecha_hora DESC
|
|
LIMIT %s
|
|
""", (email, int(limite)))
|
|
filas = [{"accion": r[0], "origen": r[1], "quien": r[2],
|
|
"fecha_hora": r[3].isoformat() if r[3] else None} for r in cur.fetchall()]
|
|
return {"historial": filas}
|
|
except Exception as e:
|
|
print(f"[historial_asesor] {e}")
|
|
return {"historial": []}
|
|
finally:
|
|
conn.close()
|