2405 lines
108 KiB
Python
2405 lines
108 KiB
Python
# backend/services.py
|
||
"""
|
||
Capa de servicios: envuelve la lógica existente (modules/) con caché.
|
||
NO modifica la lógica de negocio — solo la llama y serializa el resultado.
|
||
"""
|
||
from core.data_manager import DataManager
|
||
from modules.ocupabilidad.logic import AnalizadorCursos
|
||
from modules.ventas.logic import VentasLogic
|
||
from modules.cobranza.logic import CobranzaLogic
|
||
from modules.rentabilidad.logic import RentabilidadLogic
|
||
from modules.saldo_pendiente.logic import SaldoLogic
|
||
from cache_manager import cache_get_or_set
|
||
|
||
# Instancia única del DataManager (como @st.cache_resource)
|
||
_DM = None
|
||
|
||
# Hora de la última actualización de DATOS (se setea al precargar/refrescar)
|
||
_ultima_actualizacion = None
|
||
|
||
def _marcar_actualizacion():
|
||
global _ultima_actualizacion
|
||
from datetime import datetime
|
||
_ultima_actualizacion = datetime.now()
|
||
|
||
def ultima_actualizacion():
|
||
"""Devuelve la hora (ISO) del último refresco de datos, o None."""
|
||
return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None}
|
||
|
||
|
||
def get_dm() -> DataManager:
|
||
global _DM
|
||
if _DM is None:
|
||
_DM = DataManager()
|
||
return _DM
|
||
|
||
|
||
def _serializar_dicts(datos):
|
||
out = []
|
||
for d in (datos or []):
|
||
fila = {}
|
||
for k, v in d.items():
|
||
fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v)
|
||
out.append(fila)
|
||
return out
|
||
|
||
|
||
def _serializar_filas(filas, limite=None):
|
||
out = []
|
||
for f in (filas or []):
|
||
vals = f[:limite] if limite else f
|
||
out.append([str(v) if v is not None else "" for v in vals])
|
||
return out
|
||
|
||
|
||
# ── OCUPABILIDAD ───────────────────────────────────────────────────────────
|
||
def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"):
|
||
def _load():
|
||
dm = get_dm()
|
||
analizador = AnalizadorCursos(dm)
|
||
datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa)
|
||
filas = analizador.formatear_datos_para_tabla(datos)
|
||
return {
|
||
"datos": _serializar_dicts(datos),
|
||
"filas": _serializar_filas(filas),
|
||
}
|
||
return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load)
|
||
|
||
|
||
def ocupabilidad_alumnos(num_indice):
|
||
"""Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU)."""
|
||
def _load():
|
||
dm = get_dm()
|
||
analizador = AnalizadorCursos(dm)
|
||
alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice)
|
||
return {"alumnos": alumnos}
|
||
return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load)
|
||
|
||
|
||
# ── VENTAS ─────────────────────────────────────────────────────────────────
|
||
def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
|
||
def _load():
|
||
dm = get_dm()
|
||
# Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True).
|
||
# En Ventas se ignoran: la tabla muestra los datos reales del SQL.
|
||
if con_overrides:
|
||
dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) # TC de Supabase o None
|
||
dm._fecha_canc_overrides = comisiones_overrides_fecha() # {mat: fecha_canc override}
|
||
dm._inv_neta_overrides = comisiones_overrides_inv_neta() # {mat: inv_neta override}
|
||
else:
|
||
dm._tc_override_mes = None
|
||
dm._fecha_canc_overrides = {}
|
||
dm._inv_neta_overrides = {}
|
||
logic = VentasLogic(dm)
|
||
# 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)}
|