Actualizar backend/services.py

This commit is contained in:
2026-10-05 12:32:39 -05:00
parent 44dc9e16dc
commit 2e5e3f3b9b

View File

@@ -100,12 +100,31 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
# PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que
# la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla).
try: try:
# 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) det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes), _excl_ret)
pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] 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 = {} pend_por_vend = {}
for f in pend_filas: for f in pend_filas:
if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": if not f or str(f[0]).strip().upper() == "TOTAL GENERAL":
continue continue
# 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() vend = str(f[0]).strip()
# INV NETA está en la posición [7] como "S/ x" # INV NETA está en la posición [7] como "S/ x"
try: try:
@@ -115,10 +134,14 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False):
acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0}) acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0})
acc["monto"] += monto acc["monto"] += monto
acc["cant"] += 1 acc["cant"] += 1
# Sobrescribir PENDIENTES en cada registro de la tabla principal # 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: for d in datos:
v = str(d.get("VENDEDOR", "")).strip() v = str(d.get("VENDEDOR", "")).strip()
acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) acc = pend_por_vend.get(v) or pend_norm.get(_nrm(v)) or {"monto": 0.0, "cant": 0}
d["PENDIENTES"] = round(acc["monto"], 2) d["PENDIENTES"] = round(acc["monto"], 2)
d["INSCRITOS_PENDIENTES"] = acc["cant"] d["INSCRITOS_PENDIENTES"] = acc["cant"]
except Exception as e: except Exception as e:
@@ -182,7 +205,8 @@ def comisiones_detalle_todos(ano, mes):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = VentasLogic(dm) logic = VentasLogic(dm)
filas = logic.buscar_todos_matriculados() 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) filas = _aplicar_overrides_comisiones(filas)
return {"filas": _serializar_filas(filas)} return {"filas": _serializar_filas(filas)}
return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load)
@@ -279,7 +303,7 @@ def _aplicar_overrides_comisiones(filas):
# ── COBRANZA ─────────────────────────────────────────────────────────────── # ── COBRANZA ───────────────────────────────────────────────────────────────
def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"): def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS", frec_filtro="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
@@ -288,16 +312,16 @@ def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS
mats_retirar = set(alumnos_por_retirar(ano, mes).get("matriculas", [])) mats_retirar = set(alumnos_por_retirar(ano, mes).get("matriculas", []))
except Exception: except Exception:
mats_retirar = set() mats_retirar = set()
filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta, mats_retirar) 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)) sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes))
return { return {
"filas": _serializar_filas(filas), "filas": _serializar_filas(filas),
"sectoristas": sectoristas or ["TODOS"], "sectoristas": sectoristas or ["TODOS"],
} }
return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta), _load) 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"): def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS", frec_filtro="TODOS"):
def _load(): def _load():
dm = get_dm() dm = get_dm()
logic = CobranzaLogic(dm) logic = CobranzaLogic(dm)
@@ -311,7 +335,7 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi
# Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META) # Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META)
mats_prog = None mats_prog = None
if not _pf_all or not _sf_all: if not _pf_all or not _sf_all:
crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro)
mats_prog = set() mats_prog = set()
for d in (crudos_pf or []): for d in (crudos_pf or []):
prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "")
@@ -320,10 +344,10 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi
if ok_prog and ok_sede: if ok_prog and ok_sede:
mats_prog.add(str(d.get("MATRICULA", ""))) mats_prog.add(str(d.get("MATRICULA", "")))
if agrupacion == "ASESOR": if agrupacion == "ASESOR":
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh) datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh, frec_filtro)
elif agrupacion == "SEDE": elif agrupacion == "SEDE":
# Filtrar por sede usando el MISMO clasificador del processor (sede.json) # Filtrar por sede usando el MISMO clasificador del processor (sede.json)
crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro)
grupo_up = str(grupo).upper() grupo_up = str(grupo).upper()
mats_sede = set() mats_sede = set()
for d in (crudos or []): for d in (crudos or []):
@@ -331,16 +355,16 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi
sede_det = str(proc.clasificar_sede(prog)).upper() sede_det = str(proc.clasificar_sede(prog)).upper()
if sede_det == grupo_up: if sede_det == grupo_up:
mats_sede.add(str(d.get("MATRICULA", ""))) mats_sede.add(str(d.get("MATRICULA", "")))
todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh) 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) # Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total)
datos = [f for f in (todas or []) if str(f[0]) in mats_sede] datos = [f for f in (todas or []) if str(f[0]) in mats_sede]
else: # PROGRAMA else: # PROGRAMA
datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh) 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 # Filtro por Sede y/o tipo de programa (TEAC/TERC/...) si aplica
if mats_prog is not None: 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"] datos = [f for f in (datos or []) if str(f[0]) in mats_prog or str(f[0]).upper() == "TOTAL"]
return {"filas": _serializar_filas(datos)} return {"filas": _serializar_filas(datos)}
return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load) return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta, frec_filtro), _load)
# ── RENTABILIDAD ─────────────────────────────────────────────────────────── # ── RENTABILIDAD ───────────────────────────────────────────────────────────
@@ -795,6 +819,16 @@ def retiros(ano="2026", mes=8):
mats_ret = set() mats_ret = set()
if not mats_ret: if not mats_ret:
return {"filas": [], "guardado": False} 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() dm = get_dm()
proc = CobranzaLogic(dm).processor # para clasificar SEDE por programa (REPLACE_META) proc = CobranzaLogic(dm).processor # para clasificar SEDE por programa (REPLACE_META)
crono = dm.ejecutar_consulta_cronograma_cobranza(str(ano)) or [] crono = dm.ejecutar_consulta_cronograma_cobranza(str(ano)) or []
@@ -806,16 +840,15 @@ def retiros(ano="2026", mes=8):
idx[m] = f idx[m] = f
# dsc_vendedor + cod_detalle por matrícula (consulta ligera, solo las POR RETIRAR) # dsc_vendedor + cod_detalle por matrícula (consulta ligera, solo las POR RETIRAR)
try: try:
extra = dm.traer_vendedor_cod_detalle(list(mats_ret)) extra = dm.traer_vendedor_cod_detalle(list(mats_ret), ano, mes)
except Exception: except Exception:
extra = {} extra = {}
# Saldos MES ANTERIOR y MES EN CURSO por matrícula (mismos datos de Cobranza). # 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. # cta_ant-cob_ant = saldo mes anterior; cta_cur-cob_cur = saldo mes en curso.
from datetime import datetime as _dtnow
_hoy = _dtnow.now()
cob_idx = {} cob_idx = {}
try: try:
alum = cobranza_detalle_todos(str(ano), str(_hoy.month), "TODOS").get("alumnos", []) # 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: for a in alum:
cob_idx[str(a.get("matricula", "")).strip()] = a cob_idx[str(a.get("matricula", "")).strip()] = a
except Exception: except Exception:
@@ -840,6 +873,7 @@ def retiros(ano="2026", mes=8):
_prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip() _prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip()
filas.append({ filas.append({
"matricula": str(m), "matricula": str(m),
"origen_buscador": (str(m) in mats_buscador), # marcado desde Buscar Alumno
"sede": str(proc.clasificar_sede_meta(_prog)), "sede": str(proc.clasificar_sede_meta(_prog)),
"fch_matricula": _d(f.get("fch_matricula")), "fch_matricula": _d(f.get("fch_matricula")),
"asesor": str(f.get("dsc_sectorista", "") or "").strip(), "asesor": str(f.get("dsc_sectorista", "") or "").strip(),
@@ -1266,6 +1300,145 @@ def comisiones_tc_del_mes(ano, mes):
return None 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): def vendedores_manuales_listar(ano, mes):
sb = _sb() sb = _sb()
query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano))
@@ -1301,7 +1474,8 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto):
def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
"""Reemplaza TODAS las filas de un vendedor manual en una sola operación. """Reemplaza TODAS las filas de un vendedor manual en una sola operación.
filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor.""" filas = lista de dicts {descripcion, fch_emision, monto, num_comprobante?}.
Si filas vacía → elimina el vendedor."""
sb = _sb() sb = _sb()
# Borrar las existentes de ese vendedor/mes # Borrar las existentes de ese vendedor/mes
sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute()
@@ -1313,6 +1487,7 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas):
"descripcion": f.get("descripcion","") or "", "descripcion": f.get("descripcion","") or "",
"fch_emision": f.get("fch_emision","") or "", "fch_emision": f.get("fch_emision","") or "",
"monto": float(f.get("monto",0) or 0), "monto": float(f.get("monto",0) or 0),
"num_comprobante": f.get("num_comprobante","") or "",
"ano": int(ano), "mes": int(mes), "ano": int(ano), "mes": int(mes),
}) })
if payload: if payload:
@@ -1344,3 +1519,616 @@ def comisiones_override_restaurar(num_matricula):
cache_invalidate("ventas") cache_invalidate("ventas")
cache_invalidate("comisiones_det_todos") cache_invalidate("comisiones_det_todos")
return {"status": "ok"} 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))
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 ninguno de los dos bloques, NO crear su hoja.
c1_pc, c2_pc = _clasificar(filas_pc)
c1_pp, c2_pp = _clasificar(filas_pend)
if not (c1_pc or c2_pc or c1_pp or c2_pp):
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, _ = _escribir_bloque(ws, 1, "MES EN CURSO", filas_pc)
# BLOQUE 2: MESES PASADOS (Venta Pendientes) — 3 filas más abajo
_escribir_bloque(ws, prox + 3, "MESES PASADOS", filas_pend)
# 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)}