Files
dashboard-completo/backend/services.py

790 lines
32 KiB
Python

"""
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)
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)
# 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)
print(f"[DEBUG Ocupabilidad] Proceso completado. Registros encontrados: {len(datos) if datos else 0}")
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)
dm._fecha_canc_overrides = comisiones_overrides_fecha()
dm._inv_neta_overrides = comisiones_overrides_inv_neta()
else:
dm._tc_override_mes = None
dm._fecha_canc_overrides = {}
dm._inv_neta_overrides = {}
logic = VentasLogic(dm)
print(f"[DEBUG Ventas] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}")
mes_str = str(mes)
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
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try:
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str)
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()
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
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)
print(f"[DEBUG Ventas] Proceso completado. Registros encontrados: {len(datos)}")
return {
"filas": _serializar_filas(filas),
"datos": _serializar_dicts(datos),
}
return cache_get_or_set("ventas", (ano, mes, sede, programa), _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"):
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))
filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else []
filas = _aplicar_overrides_comisiones(filas)
_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
_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), _load)
def comisiones_detalle_todos(ano, 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():
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()
if sv == "__VACIO__":
out[mat] = "__VACIO__"
elif sv != "":
out[mat] = sv
return out
except Exception:
return {}
def comisiones_overrides_inv_neta():
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):
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
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])
if len(f) == 16:
f.append(f[8])
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] = ""
elif str(val) != "":
f[idx] = val
f[17] = "1"
return filas
except Exception as e:
print(f"[overrides comisiones] {e}")
return filas
# ── COBRANZA ───────────────────────────────────────────────────────────────
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"):
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
print(f"[DEBUG Cobranza] Buscando ano={ano}, mes={mes}, sectorista={sectorista}, agrupacion={agrupacion}")
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 {
"filas": _serializar_filas(filas),
"sectoristas": sectoristas or ["TODOS"],
}
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load)
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"):
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
if agrupacion == "ASESOR":
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS")
elif agrupacion == "SEDE":
crudos = logic.processor.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(logic.processor.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")
datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
else:
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo)
return {"filas": _serializar_filas(datos)}
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _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 ────────────────────────────────────────────────────────
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):
from cache_manager import cache_invalidate
try:
prefijo, _, resto = key.partition(":")
args = resto.split("|") if resto else []
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
def refrescar_todo():
global _refresh_count
from cache_manager import cache_refresh_existing, cache_keys
_refresh_count += 1
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}")
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 para la vista que el usuario ve AHORA.
2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO.
NO afecta al ciclo automático de 15 min."""
import threading
from cache_manager import cache_keys, cache_invalidate
claves_previas = cache_keys()
cache_invalidate()
try:
get_dm().limpiar_cache_cobranza()
except Exception:
pass
if clave_actual:
try:
_recalcular_clave(clave_actual)
except Exception as e:
print(f"[refresh manual] vista actual {clave_actual}: {e}")
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()
precargar_todos_modulos()
_marcar_actualizacion()
return ultima_actualizacion()
# ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ────────────────────
import threading as _threading
_precarga_gen = {}
_precarga_estado = {}
_precarga_lock = _threading.Lock()
_PRECARGA_PAUSA = 0.15
def precarga_estado(modulo):
"""Progreso de la precarga de un módulo."""
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):
"""(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior."""
periodos = _periodos_act_ant()
agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"]
combos = []
for (yy, mm) in periodos:
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 (mes actual + anterior)."""
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
try:
cobranza(str(yy), str(mm), sec, ag)
_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"):
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. Se llama al arrancar, cada 15 min y tras el refresco manual."""
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()
# ── OPERACIONES EXTRA ──────────────────────────────────────────────────────
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)
def clasificar_programas(nombres):
dm = get_dm()
logic = CobranzaLogic(dm)
return {n: str(logic.processor.clasificar_sede(n)) for n in nombres}
def cobranza_detalle_todos(ano, mes, sectorista="TODOS"):
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 ────────────────────────────────────────────────────
def _sb():
dm = get_dm()
if not dm.supabase_client:
raise RuntimeError("Supabase no está conectado")
return dm.supabase_client
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"}
# ── CONFIG MENSUAL COMISIONES ──────────────────────────────────────────────
def comisiones_config_listar(ano, mes):
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):
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):
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:
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):
sb = _sb()
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
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 ────────────────────────────────────────────────
def comisiones_override_guardar(registros):
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):
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"}