Files
dashboard-completo/backend/services.py

2405 lines
108 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
# En VENTAS (con_overrides=False) excluimos los RET aunque hayan pagado. En Comisiones NO.
_excl_ret = not con_overrides
datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa, _excl_ret) \
if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes), _excl_ret)
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:
# El dm es compartido; un refresco en 2º plano (con_overrides=False) puede
# haber borrado los overrides entre medio. Re-forzarlos aquí para Comisiones.
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()
det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes), _excl_ret)
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
# Aplicar el MISMO filtro de SEDE/PROGRAMA que la tabla (por eso la columna
# "MES PASADO" = Venta Pendientes debe cambiar con el filtro de Programa/Sede).
_sede_set = {s.strip().upper() for s in str(sede or "TODOS").split(",") if s.strip()}
_prog_set = {p.strip().upper() for p in str(programa or "TODOS").split(",") if p.strip()}
_sede_all = (not _sede_set) or ("TODOS" in _sede_set)
_prog_all = (not _prog_set) or ("TODOS" in _prog_set)
pend_por_vend = {}
for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
# Filtro sede [14] y programa (clasificado desde el nombre [16]/[8])
if (not _sede_all or not _prog_all) and len(f) >= 15:
if not _sede_all and str(f[14]).strip().upper() not in _sede_set:
continue
_prog_nombre = f[16] if len(f) > 16 else f[8]
if not _prog_all and _clasif_filtro_programa(dm, _prog_nombre) not in _prog_set:
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.
# Match tolerante por nombre (normalizado) para no fallar por puntos/comas.
def _nrm(x):
return " ".join(str(x).upper().replace(".", "").replace(",", "").split())
pend_norm = { _nrm(k): val for k, val in pend_por_vend.items() }
for d in datos:
v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v) or pend_norm.get(_nrm(v)) or {"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 _clasif_sede_fact(dm, dscp):
"""Clasifica la sede del programa con sede.json (igual que Cobranza/Ocupabilidad)."""
up = str(dscp or "").upper()
if not up:
return ""
for s, data in dm.sede_data.get("clasificacion_sedes", {}).items():
if s == "DEFAULT":
continue
for pat in data.get("patrones", []):
if pat.upper() in up:
return s
_def = dm.sede_data.get("clasificacion_default", {})
return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA"
def _fact_ym(fch):
"""Devuelve (año, mes) de una fecha (datetime o str 'YYYY-MM-DD')."""
if fch is None:
return (None, None)
try:
return (fch.year, fch.month)
except Exception:
s = str(fch)
try:
return (int(s[0:4]), int(s[5:7]))
except Exception:
return (None, None)
def facturacion(ano="TODOS", mes="TODOS", cuota="TODOS"):
"""Matriz de Facturación: Ingresos (facturas) − Notas de Crédito = Neto,
agrupado Sede -> Tipo. Clasifica con los JSON (sede.json / REPLACE_META.json).
Filtros por fch_emision (año/mes) y num_cuota. La NC hereda programa y cuota
de su factura (enlace num_comprobante_BF = num_comprobante). Replica el Power BI."""
dm = get_dm()
_ano = None if str(ano).upper() == "TODOS" else int(ano)
# mes y cuota pueden venir como lista separada por comas (multi-selección).
# None = TODOS (sin filtro).
_mes = None
if str(mes).upper() != "TODOS":
_mes = {int(x) for x in str(mes).split(",") if str(x).strip().isdigit()}
if not _mes: _mes = None
def _ncuo(v):
# Normaliza num_cuota a string entero: 0 / 0.0 / "0.0" -> "0".
s = str(v if v is not None else "").strip()
if s == "": return ""
try: return str(int(float(s)))
except Exception: return s
_cuo = None
if str(cuota).upper() != "TODOS":
_cuo = {_ncuo(x) for x in str(cuota).split(",") if str(x).strip() != ""}
if not _cuo: _cuo = None
facturas = dm.ejecutar_consulta_facturas_cobranza("ALL") or []
notas = dm.ejecutar_consulta_notascredito() or []
def _num(v):
try: return float(v) if v is not None else 0.0
except Exception: return 0.0
def _fstr(fch):
"""Fecha a 'dd/mm/yyyy'."""
if fch is None: return ""
try: return fch.strftime("%d/%m/%Y")
except Exception:
s = str(fch)
return f"{s[8:10]}/{s[5:7]}/{s[0:4]}" if len(s) >= 10 else s
def _etiq_comp(comp, fch, deudor):
"""Etiqueta del comprobante: '12/08/2026 - B00100... - JUAN CARLOS'."""
partes = [p for p in (_fstr(fch), str(comp or "").strip(), str(deudor or "").strip()) if p]
return " - ".join(partes) or "(SIN COMPROBANTE)"
# ── Índice de facturas por num_comprobante: programa, matrícula, min cuota ──
# (para enlazar NC -> factura). Guardamos el primer programa/matrícula visto
# y el MIN(num_cuota) del comprobante (num_cuota_NC del Power BI).
fact_idx = {}
# Cuántas veces aparece cada num_comprobante (regla IMP_SUBTOTAL_FINAL).
comp_rep = {}
for f in facturas:
if str(f.get("cod_estado") or "").strip().upper() == "ANU": continue
nc = str(f.get("num_comprobante") or "").strip()
if nc:
comp_rep[nc] = comp_rep.get(nc, 0) + 1
g = fact_idx.setdefault(nc, {"prog": f.get("dsc_det_programa") or "",
"glosa": f.get("dsc_glosa") or "",
"deudor": f.get("dsc_deudor") or "",
"fch": f.get("fch_emision"),
"matricula": str(f.get("num_matricula") or "").strip(),
"min_cuota": None})
cu = f.get("num_cuota")
if cu is not None:
try:
cu = int(cu)
if g["min_cuota"] is None or cu < g["min_cuota"]:
g["min_cuota"] = cu
except Exception:
pass
# ── acumulador: {(sede,tipo,programa,comprobante): {"ing":x, "nc":y}} ──
from collections import defaultdict
acc = defaultdict(lambda: {"ing": 0.0, "nc": 0.0})
# ── INGRESOS ──
# 1) Filtrar por año/mes (SIN cuota). El conteo de repeticiones del comprobante
# (regla IMP_SUBTOTAL_FINAL) se hace sobre ESTE conjunto, igual que el Power BI:
# el slicer de cuota NO afecta cuántas cuotas tiene realmente la factura.
fact_am = []
for f in facturas:
if str(f.get("cod_estado") or "").strip().upper() == "ANU": continue # excluir anuladas
a, m = _fact_ym(f.get("fch_emision"))
if _ano is not None and a != _ano: continue
if _mes is not None and m not in _mes: continue
fact_am.append(f)
# 2) Contar repeticiones de cada comprobante sobre año/mes (SIN filtro de cuota).
rep_filt = {}
for f in fact_am:
comp = str(f.get("num_comprobante") or "").strip()
if comp:
rep_filt[comp] = rep_filt.get(comp, 0) + 1
# 3) Aplicar el filtro de cuota solo para elegir QUÉ filas se suman.
fact_filt = fact_am if _cuo is None else [
f for f in fact_am if _ncuo(f.get("num_cuota")) in _cuo]
# 4) Calcular IMP_SUBTOTAL_FINAL por fila (de las cuotas que pasan el filtro).
for f in fact_filt:
prog = str(f.get("dsc_det_programa") or "").strip()
if not prog:
glosa = str(f.get("dsc_glosa") or "").strip() or "(SIN GLOSA)"
sede, tipo, pnom = "OTROS INGRESOS", "SIN CLASIFICAR", glosa
else:
sede = _clasif_sede_fact(dm, prog)
tipo = _clasif_filtro_programa(dm, prog)
pnom = prog
# único -> imp_subtotal; repetido -> imp_emitido
comp = str(f.get("num_comprobante") or "").strip()
base = _num(f.get("imp_subtotal")) if rep_filt.get(comp, 0) == 1 else _num(f.get("imp_emitido"))
if not base:
base = _num(f.get("imp_subtotal"))
if str(f.get("cod_moneda") or "").upper() != "SOL":
base *= _num(f.get("imp_tc"))
etiq = _etiq_comp(comp, f.get("fch_emision"), f.get("dsc_deudor"))
acc[(sede, tipo, pnom, etiq)]["ing"] += base
# ── NOTAS DE CRÉDITO ──
for n in notas:
a, m = _fact_ym(n.get("fch_emision"))
if _ano is not None and a != _ano: continue
if _mes is not None and m not in _mes: continue
comp_bf = str(n.get("num_comprobante_BF") or "").strip()
fi = fact_idx.get(comp_bf)
if not fi:
continue # NC sin factura enlazada -> no clasifica (como BLANK del PBI)
# El enlace NC->factura por num_comprobante_BF ya identifica la factura;
# de ahí se hereda programa y cuota (como el LOOKUPVALUE del Power BI).
# Filtro cuota: usa la cuota de la factura enlazada (num_cuota_NC = MIN)
if _cuo is not None and _ncuo(fi["min_cuota"]) not in _cuo:
continue
prog = str(fi["prog"] or "").strip()
if not prog:
glosa = str(fi.get("glosa") or "").strip() or "(SIN GLOSA)"
sede, tipo, pnom = "OTROS INGRESOS", "SIN CLASIFICAR", glosa
else:
sede = _clasif_sede_fact(dm, prog)
tipo = _clasif_filtro_programa(dm, prog)
pnom = prog
base = _num(n.get("imp_subtotal"))
if str(n.get("cod_moneda") or "").upper() != "SOL":
base *= _num(n.get("imp_tc"))
# Etiqueta con fecha/deudor de la factura enlazada (misma línea que el ingreso)
etiq = _etiq_comp(comp_bf, fi.get("fch"), fi.get("deudor"))
acc[(sede, tipo, pnom, etiq)]["nc"] += base
# ── armar jerarquía Sede -> Tipo -> Programa -> Comprobante ──
def _n0(): return {"ing": 0.0, "nc": 0.0}
def _nprog(): return {"ing": 0.0, "nc": 0.0, "comps": defaultdict(_n0)}
por_sede = defaultdict(lambda: {"ing": 0.0, "nc": 0.0,
"tipos": defaultdict(lambda: {"ing": 0.0, "nc": 0.0, "progs": defaultdict(_nprog)})})
for (sede, tipo, pnom, comp), v in acc.items():
s = por_sede[sede]
s["ing"] += v["ing"]; s["nc"] += v["nc"]
t = s["tipos"][tipo]
t["ing"] += v["ing"]; t["nc"] += v["nc"]
p = t["progs"][pnom]
p["ing"] += v["ing"]; p["nc"] += v["nc"]
c = p["comps"][comp]
c["ing"] += v["ing"]; c["nc"] += v["nc"]
filas = []
for sede in sorted(por_sede.keys()):
s = por_sede[sede]
hijos = []
for tipo in sorted(s["tipos"].keys()):
t = s["tipos"][tipo]
progs = []
for pnom in sorted(t["progs"].keys()):
p = t["progs"][pnom]
comps = []
for comp in sorted(p["comps"].keys()):
cc = p["comps"][comp]
comps.append({"comprobante": comp, "ingresos": round(cc["ing"], 2),
"notas_credito": round(cc["nc"], 2),
"neto": round(cc["ing"] - cc["nc"], 2)})
progs.append({"programa": pnom, "ingresos": round(p["ing"], 2),
"notas_credito": round(p["nc"], 2),
"neto": round(p["ing"] - p["nc"], 2), "comprobantes": comps})
hijos.append({"tipo": tipo, "ingresos": round(t["ing"], 2),
"notas_credito": round(t["nc"], 2),
"neto": round(t["ing"] - t["nc"], 2), "programas": progs})
filas.append({"sede": sede, "ingresos": round(s["ing"], 2),
"notas_credito": round(s["nc"], 2),
"neto": round(s["ing"] - s["nc"], 2), "tipos": hijos})
tot = {"ingresos": round(sum(f["ingresos"] for f in filas), 2),
"notas_credito": round(sum(f["notas_credito"] for f in filas), 2),
"neto": round(sum(f["neto"] for f in filas), 2)}
return {"filas": filas, "total": tot}
def facturacion_cuotas():
"""Lista de num_cuota disponibles (para el filtro CUOTA)."""
dm = get_dm()
facturas = dm.ejecutar_consulta_facturas_cobranza("ALL") or []
def _ncuo(v):
s = str(v if v is not None else "").strip()
if s == "": return ""
try: return str(int(float(s)))
except Exception: return s
cuotas = set()
for f in facturas:
c = _ncuo(f.get("num_cuota"))
if c != "":
cuotas.add(c)
try:
orden = sorted(cuotas, key=lambda x: int(x))
except Exception:
orden = sorted(cuotas)
return {"cuotas": orden}
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]).
# Acepta varios valores separados por coma (multi). Vacío/TODOS = todo.
_sede_set = {s.strip().upper() for s in str(sede or "TODOS").split(",") if s.strip()}
_prog_set = {p.strip().upper() for p in str(programa or "TODOS").split(",") if p.strip()}
_sede_all = (not _sede_set) or ("TODOS" in _sede_set)
_prog_all = (not _prog_set) or ("TODOS" in _prog_set)
if not _sede_all or not _prog_all:
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 not _sede_all and str(f[14]).strip().upper() not in _sede_set:
continue
_prog_nombre = f[16] if len(f) > 16 else f[8]
if not _prog_all and _clasif_filtro_programa(dm, _prog_nombre) not in _prog_set:
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)
tc_por_mes = comisiones_tc_todos_los_meses() # {(ano,mes): imp_tc}
filas = logic.buscar_todos_matriculados(tc_por_mes)
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", frec_filtro="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, frec_filtro)
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, frec_filtro), _load)
def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS", frec_filtro="TODOS"):
def _load():
dm = get_dm()
logic = CobranzaLogic(dm)
proc = logic.processor
# Sede/Programa multi (coma). Vacío/TODOS = todos.
_pf_set = {p.strip().upper() for p in str(prog_filtro or "TODOS").split(",") if p.strip()}
_pf_all = (not _pf_set) or ("TODOS" in _pf_set)
_sf_set = {s.strip().upper() for s in str(sede_filtro or "TODOS").split(",") if s.strip()}
_sf_all = (not _sf_set) or ("TODOS" in _sf_set)
vh = venc_hasta
# Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META)
mats_prog = None
if not _pf_all or not _sf_all:
crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro)
mats_prog = set()
for d in (crudos_pf or []):
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
ok_prog = _pf_all or str(proc.clasificar_filtro_programa(prog)).upper() in _pf_set
ok_sede = _sf_all or str(proc.clasificar_sede_meta(prog)).upper() in _sf_set
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, frec_filtro)
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", vh, frec_filtro)
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, frec_filtro)
# 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, frec_filtro)
# 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, frec_filtro), _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)
# Desglose de costos por programa (para exportar). Va aparte para NO alterar
# la tabla visible (que solo usa las 13 primeras columnas).
extras = {}
for f in (filas or []):
if len(f) >= 29 and str(f[0]).upper().find("TOTAL") < 0:
extras[str(f[0])] = {
"costo_epp_cert": f[14], "costo_doc": f[15],
"costo_cons": f[16], "costo_mkt": f[17], "mb_ini": f[18],
"sede": f[19], "venta_ini": f[20], "costo_ini": f[21],
"venta_act": f[22], "costo_act": f[23], "mb_act": f[24],
"costo_cert": f[25], "total_ins": f[26], "retirados": f[27],
"en_curso": f[28],
}
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, "extras": extras}
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) ──
# Ahora es POR (matricula, ano, mes): el estado/observaciones aplican a ese mes.
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 [])]}
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():
# 1) ¿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}
# Matrículas marcadas POR RETIRAR desde el BUSCADOR de Retiros (para pintarlas).
mats_buscador = set()
try:
_rb = (_sb().table("cobranza_estado_alumno")
.select("matricula,actualizado_por")
.eq("estado", "POR RETIRAR").eq("ano", int(ano)).eq("mes", int(mes))
.eq("actualizado_por", "BUSCADOR_RETIROS").execute())
mats_buscador = {str(r["matricula"]) for r in (_rb.data or [])}
except Exception:
mats_buscador = set()
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), ano, mes)
except Exception:
extra = {}
# Saldos MES ANTERIOR y MES EN CURSO por matrícula (mismos datos de Cobranza).
# cta_ant-cob_ant = saldo mes anterior; cta_cur-cob_cur = saldo mes en curso.
cob_idx = {}
try:
# Usar el MES DEL FILTRO (no la fecha de hoy) para los saldos vencidos.
alum = cobranza_detalle_todos(str(ano), str(mes), "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 vencido Anterior/Curso: desde las MISMAS cuotas vencidas (ARM)
# que cuenta N° Cuotas Vencidas, repartidas por fecha de vencimiento.
monto_ant = round(float(ex.get("monto_venc_ant", 0) or 0), 2)
monto_cur = round(float(ex.get("monto_venc_cur", 0) or 0), 2)
_prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip()
filas.append({
"matricula": str(m),
"origen_buscador": (str(m) in mats_buscador), # marcado desde Buscar Alumno
"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),
# columnas manuales guardadas de este mes
"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 "",
})
# ordenar por fecha de matrícula (más reciente primero)
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)
# ── Info manual de retiros (tabla retiros_info en Supabase) ──
_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 []):
# Fila de snapshot = tiene alumno o programa (las de solo-INFO no los tienen).
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,
}
# upsert por la llave (matricula, ano, mes)
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,
})
# 1) borrar todo el mes 2) insertar lo nuevo (2 operaciones, egress mínimo)
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"}
# ── HISTORIAL DE ESTADO DE ASESORES (Chatwoot -> tabla 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()
# Estado actual de cada asesor (solo correos de la escuela, ignora tests)
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() # [(name, email, availability, updated_at), ...]
# Ultimo estado guardado por asesor (para comparar)
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()} # {email: 'ON'/'OFF'}
nuevos = 0
for (name, email, avail, updated_at) in actuales:
accion = "ON" if int(avail) == 0 else "OFF" # 0=on, 1=off
if ultimo.get(email) == accion:
continue # no cambió -> no se guarda
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 desactivar_auto_offline_todos():
"""Revisa TODOS los agentes de Chatwoot y, si alguno tiene auto_offline activado,
lo desactiva. Pensado para correr 1 vez al día (5-6am)."""
import requests
CHATWOOT_URL = "https://gestor.escueladerefrigeracion.edu.pe"
ACCESS_TOKEN = "4anazHvZnvKLtup8biu5Zuoh"
ACCOUNT_ID = "1"
headers = {"api_access_token": ACCESS_TOKEN, "Content-Type": "application/json"}
try:
r = requests.get(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents",
headers=headers, timeout=15)
r.raise_for_status()
agentes = r.json()
except Exception as e:
print(f"[auto_offline] no se pudo leer agentes: {e}")
return {"status": "error", "msg": str(e)}
desactivados = 0
for ag in agentes:
ao = ag.get("auto_offline")
if ao is True or str(ao).lower() == "true":
try:
requests.patch(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/agents/{ag.get('id')}",
headers=headers, json={"auto_offline": False}, timeout=15)
desactivados += 1
except Exception as e:
print(f"[auto_offline] fallo al desactivar {ag.get('id')}: {e}")
print(f"[auto_offline] desactivados: {desactivados}")
return {"status": "ok", "desactivados": desactivados}
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()
# ── 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),
para armar las casillas al crear un rol. Ordenados alfabéticamente."""
sb = _sb()
res = sb.table("permisos").select("modulo").execute()
mods = {str(r.get("modulo", "")).strip() for r in (res.data or []) if r.get("modulo")}
mods.add("facturacion") # módulo nuevo: siempre asignable aunque no esté en la tabla
return {"modulos": sorted(mods)}
def roles_listar():
"""Devuelve todos los roles con la lista de módulos que puede ver cada uno.
[{rol, modulos: [..]}]"""
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.
modulos = lista de módulos que el rol PUEDE ver."""
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 comisiones_metas_listar(ano, mes):
"""Lista las metas por asesor de un mes (tabla comisiones_metas_asesor)."""
sb = _sb()
res = (sb.table("comisiones_metas_asesor").select("*")
.eq("ano", int(ano)).eq("mes", int(mes)).execute())
return {"metas": res.data or []}
def comisiones_metas_guardar(ano, mes, filas):
"""Upsert de metas por asesor en comisiones_metas_asesor.
filas = [{asesor, grupo, meta, pct_bajo_meta, pct_sobre_meta}].
Ignora filas totalmente vacías (sin ningún valor)."""
from datetime import datetime, timezone
sb = _sb()
ahora = datetime.now(timezone.utc).isoformat()
payload = []
for f in filas:
asesor = str(f.get("asesor", "") or "").strip()
grupo = str(f.get("grupo", "") or "").strip()
if not asesor or not grupo:
continue
meta = f.get("meta")
bajo = f.get("pct_bajo_meta")
sobre = f.get("pct_sobre_meta")
# Ignorar filas totalmente vacías (sin ningún valor)
if (meta in (None, "")) and (bajo in (None, "")) and (sobre in (None, "")):
continue
payload.append({
"ano": int(ano),
"mes": int(mes),
"asesor": asesor,
"grupo": grupo,
"meta": float(meta or 0),
"pct_bajo_meta": float(bajo or 0),
"pct_sobre_meta": float(sobre or 0),
"actualizado_en": ahora,
})
if payload:
sb.table("comisiones_metas_asesor").upsert(
payload, on_conflict="ano,mes,asesor,grupo").execute()
return {"status": "ok"}
def _factura_fecha_norm(valor):
"""Normaliza fch_emision (datetime/date/str) a (str 'yyyy-mm-dd', mes:int).
Devuelve ('', 0) si no se puede interpretar."""
from datetime import datetime, date
if valor is None:
return "", 0
# datetime/date nativos (pyodbc suele devolver datetime)
if isinstance(valor, (datetime, date)):
return valor.strftime("%Y-%m-%d"), int(valor.month)
s = str(valor).strip()
if not s:
return "", 0
# Tomar solo la parte de fecha (por si viene con hora)
s = s.replace("T", " ").split(" ")[0].strip()
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%m/%d/%Y", "%Y/%m/%d"):
try:
d = datetime.strptime(s, fmt)
return d.strftime("%Y-%m-%d"), int(d.month)
except Exception:
continue
return s, 0
def facturas_buscar(ano, mes=0):
"""Facturas del año para el buscador del modal de vendedores manuales.
Deduplica por num_comprobante (una fila por comprobante, la primera) y
devuelve por cada una el monto ya convertido a SOLES:
- SOL → monto_soles = imp_subtotal
- DOL + TC mes → monto_soles = imp_subtotal * TC_manual_del_mes
- DOL sin TC → monto_soles = imp_subtotal * imp_tc (de la factura)
El TC del mes se toma del mes/año del VENDEDOR que se está editando
(parámetro mes), no del mes de cada factura."""
def _load():
dm = get_dm()
filas = dm.ejecutar_consulta_facturas_cobranza(str(ano))
tc_mes = comisiones_tc_del_mes(ano, mes) if int(mes or 0) != 0 else None
vistos = set()
out = []
for f in (filas or []):
comp = str(f.get("num_comprobante", "") or "").strip()
if not comp or comp in vistos:
continue
vistos.add(comp)
fch_str, mes_f = _factura_fecha_norm(f.get("fch_emision"))
cod_moneda = str(f.get("cod_moneda", "") or "").strip().upper()
try: imp_subtotal = float(f.get("imp_subtotal", 0) or 0)
except Exception: imp_subtotal = 0.0
try: imp_igv = float(f.get("imp_igv", 0) or 0)
except Exception: imp_igv = 0.0
try: imp_total = float(f.get("imp_total", 0) or 0)
except Exception: imp_total = 0.0
try: imp_tc_fac = float(f.get("imp_tc", 0) or 0)
except Exception: imp_tc_fac = 0.0
# Monto en soles
if cod_moneda == "DOL":
tc = tc_mes if (tc_mes and tc_mes > 0) else (imp_tc_fac if imp_tc_fac > 0 else 1.0)
monto_soles = imp_subtotal * tc
else:
monto_soles = imp_subtotal
out.append({
"num_comprobante": comp,
"fch_emision": fch_str,
"mes": mes_f,
"dsc_deudor": str(f.get("dsc_deudor", "") or ""),
"cod_estado": str(f.get("cod_estado", "") or ""),
"cod_moneda": cod_moneda,
"imp_subtotal": imp_subtotal,
"imp_igv": imp_igv,
"imp_total": imp_total,
"imp_tc": imp_tc_fac,
"monto_soles": round(monto_soles, 2),
})
# tc_mes: TC manual del mes (o None) — lo usa el "Agregar manual" en dólares.
return {"facturas": out, "tc_mes": (tc_mes if tc_mes and tc_mes > 0 else None)}
return cache_get_or_set("facturas_buscar", (ano, mes), _load)
def comisiones_tc_todos_los_meses():
"""Devuelve dict {(ano, mes): imp_tc} de TODOS los meses configurados (TC > 0).
Se usa para convertir dólares en el buscador según el mes de cada cancelación."""
out = {}
try:
sb = _sb()
res = sb.table("comisiones_config_mensual").select("ano,mes,imp_tc").execute()
for r in (res.data or []):
try:
tc = float(r.get("imp_tc", 0) or 0)
if tc > 0:
out[(int(r["ano"]), int(r["mes"]))] = tc
except Exception:
pass
except Exception:
pass
return out
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, num_comprobante?}.
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),
"num_comprobante": f.get("num_comprobante","") or "",
"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"}
# ── EXPORT A EXCEL (Comisiones: una hoja por asesor, 2 cuadros) ─────────────
def comisiones_export_excel(ano, mes):
"""Genera un Excel (bytes) con UNA HOJA POR ASESOR.
Cada hoja lleva 2 cuadros con los alumnos de 'Venta P.C' (inscritos pagados
del mes) de ese asesor, para el año/mes indicados:
Cuadro 1 (arriba): programas TEAC/TERC — 14 columnas.
Cuadro 2 (abajo, tras 2 filas en blanco): SEMINARIOS/OTROS/otros — 11
columnas (sin Promedio Cuota, Días de Anticipación, Valor Cuota Adicional).
NO altera ninguna lógica de cálculo; sólo lee obtener_detalle_vendedor().
"""
from io import BytesIO
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
dm = get_dm()
# Overrides de Comisiones (igual que ventas_detalle / ventas con_overrides=True)
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()
logic = VentasLogic(dm)
# Lista de asesores del mes (desde la tabla de ventas con overrides).
res_ventas = ventas(ano, mes, "TODOS", "TODOS", True)
vendedores = []
_vistos = set()
for d in (res_ventas.get("datos") or []):
v = str(d.get("VENDEDOR", "")).strip()
up = v.upper()
if not v or up in ("SIN VENDEDOR", "TOTAL GENERAL"):
continue
if up in _vistos:
continue
_vistos.add(up)
vendedores.append(v)
# Encabezados
HEAD_FULL = [
"Nombre", "Fecha Matricula", "Vendedor", "Moneda", "Inversion",
"Saldo Matricula", "Saldo Cuota 1", "Cuota", "Programa", "Fecha Inicio",
"Fecha de Pago", "Promedio Cuota", "Dias de Anticipacion",
"Valor Cuota Adicional",
]
HEAD_SHORT = HEAD_FULL[:11] # termina en "Fecha de Pago"
def _num(x):
"""Convierte '"S/ 1,234"' / '1,234' / '' a número (float) o None si vacío."""
s = str(x if x is not None else "").replace("S/", "").replace(",", "").strip()
if s in ("", "-", "None"):
return None
try:
return float(s)
except Exception:
return None
# Estilos
head_font = Font(bold=True, color="FFFFFF")
head_fill = PatternFill("solid", fgColor="305496")
head_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin = Side(style="thin", color="BFBFBF")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
red_font = Font(color="FF0000", bold=True)
def _fila_valores(f, corto):
"""Devuelve la lista de valores en el orden de columnas, marcando cuáles
van en rojo (índice de columna -> True)."""
# Índices de la fila cruda de obtener_detalle_vendedor:
moneda = str(f[17]) if len(f) > 17 else ""
vals = [
f[1], # Nombre
f[2], # Fecha Matricula
f[0], # Vendedor
moneda, # Moneda
_num(f[7]), # Inversion (soles)
_num(f[10]), # Saldo_Matricula
_num(f[11]), # Saldo Cuota 1
"", # Cuota (vacío)
f[8], # Programa
f[9], # Fecha Inicio
f[3], # Fecha de Pago
]
rojos = {}
if not corto:
prom = _num(f[5]) # Promedio Cuota
dias = _num(f[12]) if len(f) > 12 else None # Dias de Anticipacion
# Días de Anticipación: si es negativo -> 0.
dias_val = 0 if (dias is not None and dias < 0) else (dias if dias is not None else "")
vals.append(prom if prom is not None else "")
vals.append(dias_val)
# Valor Cuota Adicional (recalculado con la nueva regla):
# Promedio Cuota - 650 si Días de Anticipación es 0,1,2,3
# Promedio Cuota - 640 si Días de Anticipación es 4 o más
# Si el resultado es <= 0 -> 0 (en rojo).
_dias_int = int(dias_val) if isinstance(dias_val, (int, float)) else 0
if prom is None:
adic = 0
else:
resta = 650 if _dias_int in (0, 1, 2, 3) else 640
adic = prom - resta
if adic <= 0:
vals.append(0)
rojos[13] = True
else:
vals.append(adic)
return vals, rojos
total_font = Font(bold=True)
total_fill = PatternFill("solid", fgColor="DDEBF7")
def _escribir_cuadro(ws, start_row, headers, filas, corto):
# Fila de encabezados
for c, h in enumerate(headers, start=1):
cell = ws.cell(row=start_row, column=c, value=h)
cell.font = head_font
cell.fill = head_fill
cell.alignment = head_align
cell.border = border
r = start_row + 1
sum_inv = 0.0 # columna 5 = Inversion
sum_adic = 0.0 # columna 14 = Valor Cuota Adicional (solo cuadro completo)
for f in filas:
vals, rojos = _fila_valores(f, corto)
for c, v in enumerate(vals, start=1):
cell = ws.cell(row=r, column=c, value=v)
cell.border = border
if rojos.get(c - 1):
cell.font = red_font
# acumular totales
try: sum_inv += float(vals[4] or 0) # Inversion (col 5, índice 4)
except: pass
if not corto and len(vals) >= 14:
try: sum_adic += float(vals[13] or 0) # Valor Cuota Adicional
except: pass
r += 1
# Fila TOTAL GENERAL (suma Inversion y Valor Cuota Adicional)
cell = ws.cell(row=r, column=1, value="TOTAL GENERAL")
cell.font = total_font; cell.fill = total_fill; cell.border = border
for c in range(2, len(headers) + 1):
cc = ws.cell(row=r, column=c)
cc.fill = total_fill; cc.border = border
ci = ws.cell(row=r, column=5, value=round(sum_inv, 2)) # Inversion
ci.font = total_font; ci.fill = total_fill; ci.border = border
if not corto:
ca = ws.cell(row=r, column=14, value=round(sum_adic, 2)) # Valor Cuota Adic.
ca.font = total_font; ca.fill = total_fill; ca.border = border
r += 1
return r, round(sum_inv, 2) # (próxima fila libre, total inversión del cuadro)
def _solo_nombre(vend):
# De "PERALTA C. ALMENDRA LUCIA" -> "ALMENDRA" (primera palabra tras la
# inicial + punto). Si no hay ese patrón, toma la última palabra.
partes = str(vend or "").split()
for i, p in enumerate(partes):
if p.endswith(".") and len(p) <= 2: # inicial tipo "C."
if i + 1 < len(partes):
return partes[i + 1]
return partes[-1] if partes else str(vend or "Asesor")
def _sanear_hoja(nombre, usados):
# Excel: máx 31 chars y sin : \ / ? * [ ]. Usa solo el NOMBRE.
base = _solo_nombre(nombre)
for ch in ':\\/?*[]':
base = base.replace(ch, " ")
base = " ".join(base.split()).strip() or "Asesor"
base = base[:31]
nombre_final = base
i = 2
while nombre_final.upper() in usados:
suf = f" ({i})"
nombre_final = base[:31 - len(suf)] + suf
i += 1
usados.add(nombre_final.upper())
return nombre_final
wb = openpyxl.Workbook()
wb.remove(wb.active) # quitar hoja por defecto
hojas_usadas = set()
def _clasificar(filas):
"""Separa las filas en cuadro1 (LIMA TEAC/TERC) y cuadro2 (resto)."""
c1, c2 = [], []
for f in filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
nombre_prog = f[16] if len(f) > 16 else (f[8] if len(f) > 8 else "")
try:
clase = str(logic.clasificar_programa(nombre_prog)).upper()
except Exception:
clase = "OTROS"
sede = str(f[14]).strip().upper() if len(f) > 14 else "LIMA"
if clase in ("TEAC", "TERC") and sede == "LIMA":
c1.append(f)
else:
c2.append(f)
return c1, c2
def _sumar_por_grupo(filas):
"""Suma la Inversión Neta (soles) por grupo: PT (TEAC/TERC, cualquier sede),
ESP (SEMINARIOS), OTROS (el resto). Devuelve dict {PT, ESP, OTROS}."""
tot = {"PT": 0.0, "ESP": 0.0, "OTROS": 0.0}
for f in filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue
nombre_prog = f[16] if len(f) > 16 else (f[8] if len(f) > 8 else "")
try:
clase = str(logic.clasificar_programa(nombre_prog)).upper()
except Exception:
clase = "OTROS"
inv = _num(f[7]) or 0.0
if clase in ("TEAC", "TERC"):
tot["PT"] += inv
elif clase == "SEMINARIOS":
tot["ESP"] += inv
else:
tot["OTROS"] += inv
return tot
# Acumulador del RESUMEN: por asesor, montos por grupo y período.
resumen = [] # [{nombre, pt_ant, pt_cur, esp_ant, esp_cur, otr_ant, otr_cur}]
def _escribir_bloque(ws, start_row, titulo, filas):
"""Escribe un bloque completo: título + 2 cuadros + TOTAL. Devuelve la
próxima fila libre. Omite el cuadro que no tenga datos."""
c1, c2 = _clasificar(filas)
# Título del bloque (MES EN CURSO / MESES PASADOS)
tcell = ws.cell(row=start_row, column=1, value=titulo)
tcell.font = Font(bold=True, size=12, color="1F4E79")
r = start_row + 1
inv1 = inv2 = 0.0
if c1:
r, inv1 = _escribir_cuadro(ws, r, HEAD_FULL, c1, corto=False)
r += 2
if c2:
r, inv2 = _escribir_cuadro(ws, r, HEAD_SHORT, c2, corto=True)
r += 1
# 2 filas abajo: TOTAL (col D) + suma de inversiones de ambos cuadros (col E)
rtot = r + 2
cd = ws.cell(row=rtot, column=4, value="TOTAL")
cd.font = total_font; cd.fill = total_fill; cd.border = border
ce = ws.cell(row=rtot, column=5, value=round(inv1 + inv2, 2))
ce.font = total_font; ce.fill = total_fill; ce.border = border
return rtot + 1, (bool(c1) or bool(c2)), round(inv1 + inv2, 2)
for vend in vendedores:
try:
det = logic.obtener_detalle_vendedor(vend, str(ano), str(mes), False)
except Exception as e:
print(f"[comisiones_export] {vend}: {e}")
det = {}
filas_pc = det.get("Venta P.C", []) if isinstance(det, dict) else []
filas_pend = det.get("Venta Pendientes", []) if isinstance(det, dict) else []
# Aplicar overrides de Comisiones a las filas (montos/fechas editados)
filas_pc = _aplicar_overrides_comisiones(list(filas_pc))
filas_pend = _aplicar_overrides_comisiones(list(filas_pend))
# Si el asesor NO tiene datos en ningún bloque (ventas ni deudores), NO crear hoja.
c1_pc, c2_pc = _clasificar(filas_pc)
c1_pp, c2_pp = _clasificar(filas_pend)
_dm_raw = det.get("Deudores Mes", []) if isinstance(det, dict) else []
_da_raw = det.get("Deudores Anterior", []) if isinstance(det, dict) else []
_tiene_deudores = any(f and str(f[0]).strip().upper() != "TOTAL GENERAL" for f in (_dm_raw + _da_raw))
if not (c1_pc or c2_pc or c1_pp or c2_pp or _tiene_deudores):
continue
# Acumular para el RESUMEN (MES EN CURSO = Venta P.C; MES ANTERIOR = Pendientes)
_cur = _sumar_por_grupo(filas_pc)
_ant = _sumar_por_grupo(filas_pend)
if (_cur["PT"] + _cur["ESP"] + _cur["OTROS"] + _ant["PT"] + _ant["ESP"] + _ant["OTROS"]) > 0:
resumen.append({
"nombre": _solo_nombre(vend),
"pt_ant": _ant["PT"], "pt_cur": _cur["PT"],
"esp_ant": _ant["ESP"], "esp_cur": _cur["ESP"],
"otr_ant": _ant["OTROS"], "otr_cur": _cur["OTROS"],
})
ws = wb.create_sheet(title=_sanear_hoja(vend, hojas_usadas))
anchos = [26, 14, 20, 8, 12, 14, 13, 8, 24, 12, 13, 14, 16, 18]
for i, w in enumerate(anchos, start=1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
# BLOQUE 1: MES EN CURSO (Venta P.C)
prox, _, tot_cur = _escribir_bloque(ws, 1, "MES EN CURSO", filas_pc)
# BLOQUE 2: MESES PASADOS (Venta Pendientes) — 3 filas más abajo
prox2, _, tot_ant = _escribir_bloque(ws, prox + 3, "MESES PASADOS", filas_pend)
# TOTAL COMISIÓN = suma de los totales de los 2 bloques de arriba
rtc = prox2 + 1
c_tc = ws.cell(row=rtc, column=4, value="TOTAL COMISIÓN")
c_tc.font = Font(bold=True, size=12, color="1F4E79"); c_tc.fill = total_fill; c_tc.border = border
c_tcv = ws.cell(row=rtc, column=5, value=round(tot_cur + tot_ant, 2))
c_tcv.font = Font(bold=True, size=12); c_tcv.fill = total_fill; c_tcv.border = border
# Separador visual + título SALDO PENDIENTE antes de los deudores
r_sep = rtc + 3
c_sp = ws.cell(row=r_sep, column=1, value="SALDO PENDIENTE")
c_sp.font = Font(bold=True, size=13, color="FFFFFF")
c_sp.fill = PatternFill("solid", fgColor="992B2B")
for _c in range(1, 15):
cc = ws.cell(row=r_sep, column=_c)
if _c != 1:
cc.fill = PatternFill("solid", fgColor="992B2B")
# BLOQUES DE DEUDORES (debían al cierre del mes del filtro; solo ALU/PRE):
# - "NO PAGADOS — MES" : matriculados en el mes del filtro
# - "NO PAGADOS — ANTERIOR" : matriculados antes del filtro
filas_dm = _aplicar_overrides_comisiones(list(det.get("Deudores Mes", []) if isinstance(det, dict) else []))
filas_da = _aplicar_overrides_comisiones(list(det.get("Deudores Anterior", []) if isinstance(det, dict) else []))
prox3, _, _ = _escribir_bloque(ws, r_sep + 2, "NO PAGADOS — MES", filas_dm)
_escribir_bloque(ws, prox3 + 3, "NO PAGADOS — MESES ANTERIORES", filas_da)
# Agregar VENDEDORES MANUALES al final del RESUMEN: todo su monto va a
# OTROS → MES EN CURSO. Agrupados por nombre de vendedor manual.
try:
_sb_m = _sb()
_res_m = (_sb_m.table("vendedores_manuales").select("nombre,monto")
.eq("ano", int(ano)).eq("mes", int(mes)).execute())
_man_por_nombre = {}
for _row in (_res_m.data or []):
try: _mt = float(_row.get("monto", 0) or 0)
except: _mt = 0.0
if _mt <= 0:
continue
_nm = str(_row.get("nombre", "") or "").strip() or "OTROS"
_man_por_nombre[_nm] = _man_por_nombre.get(_nm, 0.0) + _mt
for _nm, _mt in _man_por_nombre.items():
resumen.append({
"nombre": _nm,
"pt_ant": 0.0, "pt_cur": 0.0,
"esp_ant": 0.0, "esp_cur": 0.0,
"otr_ant": 0.0, "otr_cur": _mt, # manual → OTROS, mes en curso
})
except Exception as e:
print(f"[comisiones_export RESUMEN manuales] {e}")
# ── HOJA "RESUMEN" (al inicio): por asesor, PT / ESPECIALIDAD / OTROS × (MES
# ANTERIOR, MES EN CURSO) + TOTAL. PT = TEAC+TERC, ESP = SEMINARIOS, OTROS = resto.
ws_r = wb.create_sheet(title="RESUMEN", index=0)
for col, w in zip("ABCDEFGH", [20, 13, 13, 13, 13, 13, 13, 14]):
ws_r.column_dimensions[col].width = w
cen = Alignment(horizontal="center", vertical="center", wrap_text=True)
mfmt = '#,##0'
grp_fill = PatternFill("solid", fgColor="B4C6E7")
sub_fill = PatternFill("solid", fgColor="DDEBF7")
hdr_font = Font(bold=True)
# Fila 1: cabeceras de grupo (fusionadas de a 2) + ASESOR y TOTAL (2 filas alto)
ws_r.merge_cells("A1:A2")
c = ws_r.cell(row=1, column=1, value="ASESOR"); c.font = hdr_font; c.alignment = cen; c.fill = grp_fill; c.border = border
ws_r.merge_cells("B1:C1"); ws_r.merge_cells("D1:E1"); ws_r.merge_cells("F1:G1")
for col, txt in ((2, "PT"), (4, "ESPECIALIDAD"), (6, "OTROS")):
cc = ws_r.cell(row=1, column=col, value=txt); cc.font = hdr_font; cc.alignment = cen; cc.fill = grp_fill; cc.border = border
ws_r.cell(row=1, column=col+1).fill = grp_fill; ws_r.cell(row=1, column=col+1).border = border
ws_r.merge_cells("H1:H2")
ct = ws_r.cell(row=1, column=8, value="TOTAL"); ct.font = hdr_font; ct.alignment = cen; ct.fill = grp_fill; ct.border = border
ws_r.cell(row=2, column=8).fill = grp_fill; ws_r.cell(row=2, column=8).border = border
# Fila 2: subcabeceras MES ANTERIOR / MES EN CURSO por grupo
subs = ["", "MES ANTERIOR", "MES EN CURSO", "MES ANTERIOR", "MES EN CURSO", "MES ANTERIOR", "MES EN CURSO", ""]
for i, s in enumerate(subs, start=1):
if s:
cs = ws_r.cell(row=2, column=i, value=s); cs.font = hdr_font; cs.alignment = cen; cs.fill = sub_fill; cs.border = border
# Filas de asesores
r = 3
T = {"pt_ant":0.0,"pt_cur":0.0,"esp_ant":0.0,"esp_cur":0.0,"otr_ant":0.0,"otr_cur":0.0}
def _cell(row, col, val, money=True, bold=False, center=True):
x = ws_r.cell(row=row, column=col, value=(round(val,2) if (money and isinstance(val,(int,float))) else val))
x.border = border
if money and isinstance(val,(int,float)): x.number_format = mfmt
if bold: x.font = hdr_font
if center: x.alignment = Alignment(horizontal="center", vertical="center")
return x
for a in resumen:
total = a["pt_ant"]+a["pt_cur"]+a["esp_ant"]+a["esp_cur"]+a["otr_ant"]+a["otr_cur"]
nm = ws_r.cell(row=r, column=1, value=a["nombre"]); nm.border = border; nm.font = hdr_font
nm.alignment = Alignment(horizontal="center", vertical="center")
_cell(r,2,a["pt_ant"] or ""); _cell(r,3,a["pt_cur"] or "")
_cell(r,4,a["esp_ant"] or ""); _cell(r,5,a["esp_cur"] or "")
_cell(r,6,a["otr_ant"] or ""); _cell(r,7,a["otr_cur"] or "")
_cell(r,8,total, bold=True)
for k in T: T[k] += a[k]
r += 1
# Fila de subtotales por columna
tot_fill = PatternFill("solid", fgColor="DDEBF7")
_cell(r,1,"", money=False); ws_r.cell(row=r,column=1).fill = tot_fill; ws_r.cell(row=r,column=1).border = border
for col, val in ((2,T["pt_ant"]),(3,T["pt_cur"]),(4,T["esp_ant"]),(5,T["esp_cur"]),(6,T["otr_ant"]),(7,T["otr_cur"])):
x = _cell(r,col,val,bold=True); x.fill = tot_fill
gtot = sum(T.values())
xt = _cell(r,8,gtot,bold=True); xt.fill = PatternFill("solid", fgColor="FFF2CC")
r += 1
# Fila: total por grupo (ant+cur) fusionado
ws_r.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3)
_cell(r,2,T["pt_ant"]+T["pt_cur"],bold=True); ws_r.cell(row=r,column=3).border = border
ws_r.merge_cells(start_row=r, start_column=4, end_row=r, end_column=5)
_cell(r,4,T["esp_ant"]+T["esp_cur"],bold=True); ws_r.cell(row=r,column=5).border = border
ws_r.merge_cells(start_row=r, start_column=6, end_row=r, end_column=7)
_cell(r,6,T["otr_ant"]+T["otr_cur"],bold=True); ws_r.cell(row=r,column=7).border = border
# ── Tablita JEFE COMERCIAL (2 filas más abajo): PT+SEDES / ESPECIALIDAD / OTROS ──
r2 = r + 3
# Encabezados (fila r2): UNIDAD, MES ANTERIOR, MES EN CURSO, TOTAL
heads2 = [("A", ""), ("B", "UNIDAD"), ("C", "MES ANTERIOR"), ("D", "MES EN CURSO"), ("E", "TOTAL")]
ws_r.cell(row=r2, column=2, value="UNIDAD").font = hdr_font
ws_r.cell(row=r2, column=2).fill = sub_fill
for col, txt in ((3, "MES ANTERIOR"), (4, "MES EN CURSO"), (5, "TOTAL")):
hc = ws_r.cell(row=r2, column=col, value=txt); hc.font = hdr_font; hc.alignment = cen; hc.fill = sub_fill; hc.border = border
ws_r.cell(row=r2, column=2).alignment = cen; ws_r.cell(row=r2, column=2).border = border
# Columna A fusionada (r2..r2+3) = "JEFE COMERCIAL"
ws_r.merge_cells(start_row=r2, start_column=1, end_row=r2+3, end_column=1)
jc = ws_r.cell(row=r2, column=1, value="JEFE COMERCIAL"); jc.font = hdr_font
jc.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True); jc.border = border
# Filas de datos
filas_jc = [
("PT+ SEDES", T["pt_ant"], T["pt_cur"]),
("ESPECIALIDAD", T["esp_ant"], T["esp_cur"]),
("OTROS", T["otr_ant"], T["otr_cur"]),
]
gran_jc = sum(T.values())
rr = r2 + 1
for nombre_u, ant, cur in filas_jc:
u = ws_r.cell(row=rr, column=2, value=nombre_u); u.border = border; u.font = hdr_font
u.alignment = Alignment(horizontal="center", vertical="center")
_cell(rr, 3, ant); _cell(rr, 4, cur)
rr += 1
# TOTAL (columna E) fusionado para las 3 filas
ws_r.merge_cells(start_row=r2+1, start_column=5, end_row=r2+3, end_column=5)
tg = ws_r.cell(row=r2+1, column=5, value=round(gran_jc, 2))
tg.font = hdr_font; tg.number_format = mfmt; tg.border = border
tg.alignment = Alignment(horizontal="center", vertical="center")
for _rb in range(r2+1, r2+4):
ws_r.cell(row=_rb, column=5).border = border
# ── HOJA "OTROS": vendedores manuales de ENERO hasta el mes del filtro ──
_MESES_NOM = ["", "ENERO", "FEBRERO", "MARZO", "ABRIL", "MAYO", "JUNIO",
"JULIO", "AGOSTO", "SEPTIEMBRE", "OCTUBRE", "NOVIEMBRE", "DICIEMBRE"]
man_filas = []
try:
sb = _sb()
res = (sb.table("vendedores_manuales").select("*")
.eq("ano", int(ano)).lte("mes", int(mes)).execute())
man_filas = res.data or []
except Exception as e:
print(f"[comisiones_export OTROS] {e}")
# Ordenar por mes y luego por vendedor
man_filas.sort(key=lambda r: (int(r.get("mes", 0) or 0), str(r.get("nombre", ""))))
ws_o = wb.create_sheet(title="OTROS")
anchos_o = [16, 22, 15, 17, 34, 12, 17, 16]
for i, w in enumerate(anchos_o, start=1):
ws_o.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
HEAD_O = ["MES", "VENDEDOR", "FECHA EMISION", "FACTURA", "EMPRESA",
"ESTADO", "FECHA CANCELACION", "IMPORTE SUBTOTAL"]
center = Alignment(horizontal="center", vertical="center")
left_al = Alignment(horizontal="left", vertical="center")
money_fmt = '"S/" #,##0.00'
mes_title_font = Font(bold=True, size=12, color="FFFFFF")
mes_title_fill = PatternFill("solid", fgColor="1F4E79")
# Omitir los de monto 0; agrupar por mes.
def _m(r_):
try: return float(r_.get("monto", 0) or 0)
except: return 0.0
por_mes = {}
for f in man_filas:
if _m(f) <= 0:
continue
mn = int(f.get("mes", 0) or 0)
por_mes.setdefault(mn, []).append(f)
r = 1
gran_total = 0.0
for mn in sorted(por_mes.keys()):
nombre_mes = _MESES_NOM[mn] if 1 <= mn <= 12 else str(mn)
# Título del mes (fila fusionada)
ws_o.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
tc = ws_o.cell(row=r, column=1, value=nombre_mes)
tc.font = mes_title_font; tc.fill = mes_title_fill; tc.alignment = center
r += 1
# Encabezados del cuadro
for c, h in enumerate(HEAD_O, start=1):
cell = ws_o.cell(row=r, column=c, value=h)
cell.font = head_font; cell.fill = head_fill; cell.alignment = head_align; cell.border = border
r += 1
suma_mes = 0.0
for f in por_mes[mn]:
monto = _m(f)
suma_mes += monto
vals = [
nombre_mes,
f.get("nombre", "") or "",
f.get("fch_emision", "") or "",
f.get("num_comprobante", "") or "", # FACTURA
f.get("descripcion", "") or "", # EMPRESA
"", "", # ESTADO / FECHA CANCELACION (vacío)
monto, # IMPORTE SUBTOTAL
]
for c, v in enumerate(vals, start=1):
cell = ws_o.cell(row=r, column=c, value=v)
cell.border = border
cell.alignment = left_al if c == 5 else center
if c == 8:
cell.number_format = money_fmt
r += 1
# Total del mes
tcm = ws_o.cell(row=r, column=1, value=f"TOTAL {nombre_mes}")
tcm.font = total_font; tcm.fill = total_fill; tcm.border = border; tcm.alignment = center
for c in range(2, 9):
cc = ws_o.cell(row=r, column=c); cc.fill = total_fill; cc.border = border
tv = ws_o.cell(row=r, column=8, value=round(suma_mes, 2))
tv.font = total_font; tv.fill = total_fill; tv.border = border
tv.number_format = money_fmt; tv.alignment = center
gran_total += suma_mes
r += 2 # 1 fila en blanco entre meses
# Gran total general al final
if por_mes:
gc = ws_o.cell(row=r, column=1, value="TOTAL GENERAL")
gc.font = Font(bold=True, color="FFFFFF"); gc.fill = PatternFill("solid", fgColor="305496")
gc.border = border; gc.alignment = center
for c in range(2, 9):
cc = ws_o.cell(row=r, column=c); cc.fill = PatternFill("solid", fgColor="305496"); cc.border = border
gv = ws_o.cell(row=r, column=8, value=round(gran_total, 2))
gv.font = Font(bold=True, color="FFFFFF"); gv.fill = PatternFill("solid", fgColor="305496")
gv.border = border; gv.number_format = money_fmt; gv.alignment = center
# Si no hubo ningún asesor, dejar una hoja informativa (Excel exige >=1 hoja)
if not wb.sheetnames:
ws = wb.create_sheet(title="Sin datos")
ws.cell(row=1, column=1, value=f"Sin asesores para {ano}-{mes}")
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
# ── RESPALDO DIARIO de fch_cancelacion_cuota1 en comisiones_overrides ──
# Objetivo: guardar la fecha de cancelación de la cuota 1 ANTES de que el SQL
# la pierda (por ejemplo cuando la matrícula pasa a ANU). Regla:
# - Solo matrículas con fch_matricula de máx 7 meses de antigüedad.
# - Si en Supabase (comisiones_overrides.fecha_cancelacion1) YA hay algo
# (fecha o "__VACIO__") -> NO se toca (respeta ediciones manuales/respaldos).
# - Solo se escribe cuando el campo está vacío/None y el SQL sí tiene fecha.
# - Si la matrícula no existe en la tabla, se crea la fila con solo esa fecha.
# Corre 1 vez al día (ver main.py / arranque). Formato de fecha: dd/mm/yyyy.
def respaldar_fechas_cancelacion(meses_atras=7):
from datetime import datetime, timedelta
dm = get_dm()
if not getattr(dm, "supabase_client", None):
print("[respaldo_fechas] Supabase no conectado; se omite.")
return {"status": "skip", "motivo": "sin supabase"}
# Fecha de corte: hoy menos N meses (aprox 30 días por mes)
corte = datetime.now() - timedelta(days=meses_atras * 31)
corte_sql = corte.strftime("%Y-%m-%d")
# 1) SQL: matrículas de los últimos ~7 meses con su fch_cancelacion de la cuota 1.
sql = """
SELECT m.num_matricula,
(SELECT TOP 1 c.fch_cancelacion
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = 1
AND c.num_cuota = 1) AS fch_cancelacion_cuota1
FROM sgeca_matricula m
WHERE m.fch_matricula >= ?
"""
sql_por_mat = {}
try:
conn = dm.get_connection()
cur = conn.cursor()
cur.execute(sql, corte_sql)
for row in cur.fetchall():
mat = str(row[0]).strip()
if mat.endswith(".0"):
mat = mat[:-2]
fch = row[1]
if not fch:
continue # sin fecha de cancelación -> no interesa
try:
if hasattr(fch, "strftime"):
fch_str = fch.strftime("%d/%m/%Y")
else:
fch_str = datetime.strptime(str(fch)[:10], "%Y-%m-%d").strftime("%d/%m/%Y")
except Exception:
continue
sql_por_mat[mat] = fch_str
conn.close()
except Exception as e:
print(f"[respaldo_fechas] error SQL: {e}")
return {"status": "error", "detalle": str(e)}
if not sql_por_mat:
return {"status": "ok", "insertados": 0, "actualizados": 0, "revisados": 0}
# 2) Supabase: fecha_cancelacion1 actual por matrícula (para no pisar lo que ya hay).
sb = dm.supabase_client
existentes = {}
try:
res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute()
for r in (res.data or []):
existentes[str(r["num_matricula"])] = r.get("fecha_cancelacion1")
except Exception as e:
print(f"[respaldo_fechas] error leyendo overrides: {e}")
return {"status": "error", "detalle": str(e)}
# 3) Decidir: solo escribir donde el campo está vacío/None (nunca pisar).
insertados, actualizados = 0, []
for mat, fch_str in sql_por_mat.items():
actual = existentes.get(mat, "__NO_EXISTE__")
if actual == "__NO_EXISTE__":
# Fila nueva: crear con solo la fecha
try:
sb.table("comisiones_overrides").insert(
{"num_matricula": mat, "fecha_cancelacion1": fch_str}).execute()
insertados += 1
except Exception as e:
print(f"[respaldo_fechas] insert {mat} falló: {e}")
else:
sv = "" if actual is None else str(actual).strip()
if sv == "": # existe la fila pero la fecha está vacía -> completar
try:
sb.table("comisiones_overrides").update(
{"fecha_cancelacion1": fch_str}).eq("num_matricula", mat).execute()
actualizados.append(mat)
except Exception as e:
print(f"[respaldo_fechas] update {mat} falló: {e}")
# si sv tiene fecha o "__VACIO__" -> NO se toca
if insertados or actualizados:
try:
from cache_manager import cache_invalidate
cache_invalidate("ventas")
cache_invalidate("ventas_det")
cache_invalidate("comisiones_det_todos")
except Exception:
pass
print(f"[respaldo_fechas] revisados={len(sql_por_mat)} nuevos={insertados} completados={len(actualizados)}")
return {"status": "ok", "insertados": insertados,
"actualizados": len(actualizados), "revisados": len(sql_por_mat)}