""" Capa de servicios: envuelve la lógica existente (modules/) con caché. NO modifica la lógica de negocio — solo la llama y serializa el resultado. """ from core.data_manager import DataManager from modules.ocupabilidad.logic import AnalizadorCursos from modules.ventas.logic import VentasLogic from modules.cobranza.logic import CobranzaLogic from modules.rentabilidad.logic import RentabilidadLogic from modules.saldo_pendiente.logic import SaldoLogic from cache_manager import cache_get_or_set # Instancia única del DataManager (como @st.cache_resource) _DM = None # Hora de la última actualización de DATOS (se setea al precargar/refrescar) _ultima_actualizacion = None def _marcar_actualizacion(): global _ultima_actualizacion from datetime import datetime _ultima_actualizacion = datetime.now() def ultima_actualizacion(): """Devuelve la hora (ISO) del último refresco de datos, o None.""" return {"hora": _ultima_actualizacion.isoformat() if _ultima_actualizacion else None} def get_dm() -> DataManager: global _DM if _DM is None: _DM = DataManager() return _DM def _serializar_dicts(datos): out = [] for d in (datos or []): fila = {} for k, v in d.items(): fila[k] = v if isinstance(v, (str, int, float, bool)) or v is None else str(v) out.append(fila) return out def _serializar_filas(filas, limite=None): out = [] for f in (filas or []): vals = f[:limite] if limite else f out.append([str(v) if v is not None else "" for v in vals]) return out # ── OCUPABILIDAD ─────────────────────────────────────────────────────────── def ocupabilidad(ano, mes, sede="TODOS", programa="TODOS"): def _load(): dm = get_dm() analizador = AnalizadorCursos(dm) print(f"[DEBUG Ocupabilidad] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}") # Intento 1: Formato original (ej: "6") datos = analizador.obtener_datos_procesados(str(ano), str(mes), sede, programa) # Intento 2: Si viene vacío, probar con formato de dos dígitos (ej: "06") if not datos and len(str(mes)) == 1: mes_padded = str(mes).zfill(2) print(f"[DEBUG Ocupabilidad] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'") datos = analizador.obtener_datos_procesados(str(ano), mes_padded, sede, programa) filas = analizador.formatear_datos_para_tabla(datos) print(f"[DEBUG Ocupabilidad] Proceso completado. Registros encontrados: {len(datos) if datos else 0}") return { "datos": _serializar_dicts(datos), "filas": _serializar_filas(filas), } return cache_get_or_set("ocupabilidad", (ano, mes, sede, programa), _load) def ocupabilidad_alumnos(num_indice): """Alumnos de un curso (num_indice) para el popup Ver: nombre + estado (sin ANU).""" def _load(): dm = get_dm() analizador = AnalizadorCursos(dm) alumnos = analizador.curso_processor.obtener_alumnos_curso(num_indice) return {"alumnos": alumnos} return cache_get_or_set("ocup_alumnos", (str(num_indice),), _load) # ── VENTAS ───────────────────────────────────────────────────────────────── def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): def _load(): dm = get_dm() # Los overrides de Supabase SOLO aplican en Comisiones (con_overrides=True). # En Ventas se ignoran: la tabla muestra los datos reales del SQL. if con_overrides: dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) dm._fecha_canc_overrides = comisiones_overrides_fecha() dm._inv_neta_overrides = comisiones_overrides_inv_neta() else: dm._tc_override_mes = None dm._fecha_canc_overrides = {} dm._inv_neta_overrides = {} logic = VentasLogic(dm) print(f"[DEBUG Ventas] Buscando ano={ano}, mes={mes}, sede={sede}, programa={programa}") mes_str = str(mes) datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_str, sede, programa) \ if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_str) # Intento 2: Mes de dos dígitos si el original no trajo nada if not datos and len(mes_str) == 1: mes_padded = mes_str.zfill(2) print(f"[DEBUG Ventas] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'") datos = logic.obtener_datos_brutos_filtrado(str(ano), mes_padded, sede, programa) \ if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), mes_padded) datos = [d for d in (datos or []) if d.get('VENDEDOR', 'SIN VENDEDOR') != 'SIN VENDEDOR'] # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). try: det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str) pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] pend_por_vend = {} for f in pend_filas: if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": continue vend = str(f[0]).strip() try: monto = float(str(f[7]).replace("S/", "").replace(",", "").strip()) except Exception: monto = 0.0 acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0}) acc["monto"] += monto acc["cant"] += 1 for d in datos: v = str(d.get("VENDEDOR", "")).strip() acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) d["PENDIENTES"] = round(acc["monto"], 2) d["INSCRITOS_PENDIENTES"] = acc["cant"] except Exception as e: print(f"[ventas] no se pudo alinear PENDIENTES con el Ver: {e}") filas = logic.formatear_datos_para_tabla(datos) print(f"[DEBUG Ventas] Proceso completado. Registros encontrados: {len(datos)}") return { "filas": _serializar_filas(filas), "datos": _serializar_dicts(datos), } return cache_get_or_set("ventas", (ano, mes, sede, programa), _load) def _clasif_filtro_programa(dm, dscp): """Misma clasificación de programa que ejecutar_consulta_ventas (SEMINARIOS/TEAC/TERC/OTROS).""" up = str(dscp or "").upper() fdata = dm.meta_data.get("clasificacion_filtro_programa", {}) for cat in ["OTROS", "SEMINARIOS", "TEAC", "TERC"]: for pat in fdata.get(cat, {}).get("patrones", []): if pat.upper() in up: return cat return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS" def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS"): def _load(): dm = get_dm() dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) dm._fecha_canc_overrides = comisiones_overrides_fecha() logic = VentasLogic(dm) resultado = logic.obtener_detalle_vendedor(vendedor, str(ano), str(mes)) filas = resultado.get(tipo_lista, []) if isinstance(resultado, dict) else [] filas = _aplicar_overrides_comisiones(filas) _sede = str(sede or "TODOS").upper() _prog = str(programa or "TODOS").upper() if _sede != "TODOS" or _prog != "TODOS": out = [] for f in filas: if str(f[0]).strip().upper() == "TOTAL GENERAL": continue # el TOTAL se recalcula en el frontend if len(f) < 15: out.append(f); continue if _sede != "TODOS" and str(f[14]).strip().upper() != _sede: continue _prog_nombre = f[16] if len(f) > 16 else f[8] if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog: continue out.append(f) filas = out return {"filas": _serializar_filas(filas)} return cache_get_or_set("ventas_det", (vendedor, ano, mes, tipo_lista, sede, programa), _load) def comisiones_detalle_todos(ano, mes): def _load(): dm = get_dm() logic = VentasLogic(dm) filas = logic.buscar_todos_matriculados() filas = _aplicar_overrides_comisiones(filas) return {"filas": _serializar_filas(filas)} return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) def comisiones_overrides_fecha(): try: sb = _sb() res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute() out = {} for r in (res.data or []): mat = str(r["num_matricula"]) v = r.get("fecha_cancelacion1") sv = "" if v is None else str(v).strip() if sv == "__VACIO__": out[mat] = "__VACIO__" elif sv != "": out[mat] = sv return out except Exception: return {} def comisiones_overrides_inv_neta(): try: sb = _sb() res = sb.table("comisiones_overrides").select("num_matricula,inversion_neta").execute() out = {} for r in (res.data or []): v = r.get("inversion_neta") if v is None: continue s = str(v).replace("S/", "").replace(",", "").strip() if s == "": continue try: out[str(r["num_matricula"])] = float(s) except Exception: pass return out except Exception: return {} def _aplicar_overrides_comisiones(filas): try: dm = get_dm() if not getattr(dm, "supabase_client", None): return filas res = dm.supabase_client.table("comisiones_overrides").select("*").execute() ov = {str(r["num_matricula"]): r for r in (res.data or [])} if not ov: return filas mapa = { "fch_matricula": 2, "promedio_cuota": 5, "inversion_neta": 7, "fecha_inicio": 9, "saldo_matricula": 10, "saldo_cuota1": 11, "dias_anticipacion": 12, "valor_cuota_adicional": 13, "tipo_programa": 14, "fecha_cancelacion1": 3, } for f in filas: if len(f) < 16: continue mat = str(f[15]) if len(f) == 16: f.append(f[8]) if len(f) == 17: f.append("0") if mat in ov: r = ov[mat] for col, idx in mapa.items(): val = r.get(col) if val is None: continue if str(val) == "__VACIO__": f[idx] = "" elif str(val) != "": f[idx] = val f[17] = "1" return filas except Exception as e: print(f"[overrides comisiones] {e}") return filas # ── COBRANZA ─────────────────────────────────────────────────────────────── def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) print(f"[DEBUG Cobranza] Buscando ano={ano}, mes={mes}, sectorista={sectorista}, agrupacion={agrupacion}") mes_str = str(mes) filas = logic.obtener_datos_tabla(str(ano), mes_str, sectorista, agrupacion) if not filas and len(mes_str) == 1: mes_padded = mes_str.zfill(2) print(f"[DEBUG Cobranza] Sin resultados. Reintentando con mes acolchado: '{mes_padded}'") filas = logic.obtener_datos_tabla(str(ano), mes_padded, sectorista, agrupacion) sectoristas = logic.obtener_lista_sectoristas(str(ano), mes_str) print(f"[DEBUG Cobranza] Proceso completado. Filas encontradas: {len(filas) if filas else 0}") return { "filas": _serializar_filas(filas), "sectoristas": sectoristas or ["TODOS"], } return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion), _load) def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) if agrupacion == "ASESOR": datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS") elif agrupacion == "SEDE": crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") grupo_up = str(grupo).upper() mats_sede = set() for d in (crudos or []): prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") sede_det = str(logic.processor.clasificar_sede(prog)).upper() if sede_det == grupo_up: mats_sede.add(str(d.get("MATRICULA", ""))) todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS") datos = [f for f in (todas or []) if str(f[0]) in mats_sede] else: datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo) return {"filas": _serializar_filas(datos)} return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion), _load) # ── RENTABILIDAD ─────────────────────────────────────────────────────────── def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): def _load(): dm = get_dm() logic = RentabilidadLogic(dm) datos = logic.obtener_datos_procesados(str(ano), str(mes), sede, filtro_prog) filas = logic.formatear_datos_para_tabla(datos) filas_s = _serializar_filas(filas, 13) nombres = [str(f[0]) for f in filas_s if "TOTAL" not in str(f[0]).upper()] return {"filas": filas_s, "programas": nombres} return cache_get_or_set("rentabilidad", (ano, mes, sede, filtro_prog), _load) def rentabilidad_detalle(programa, ano, mes): def _load(): dm = get_dm() logic = RentabilidadLogic(dm) resultado = logic.obtener_detalle_programa(programa, str(ano), str(mes)) return {"filas": _serializar_filas(resultado)} return cache_get_or_set("rentabilidad_det", (programa, ano, mes), _load) def rentabilidad_costos(programa, ano, mes): def _load(): dm = get_dm() logic = RentabilidadLogic(dm) return {"costos": logic.obtener_datos_costos_programa(programa, str(ano), str(mes))} return cache_get_or_set("rentabilidad_costos", (programa, ano, mes), _load) # ── SALDO PENDIENTE ──────────────────────────────────────────────────────── def saldo_pendiente(tipo_cuota): def _load(): dm = get_dm() logic = SaldoLogic(dm) datos = logic.obtener_saldos_consolidados(tipo_cuota) return {"datos": _serializar_dicts(datos)} return cache_get_or_set("saldo", (tipo_cuota,), _load) # ── PRECARGA GLOBAL ──────────────────────────────────────────────────────── def precargar_todo(): from datetime import datetime ahora = datetime.now() ano, mes = ahora.year, ahora.month tareas = [ lambda: ocupabilidad(ano, mes), lambda: ventas(ano, mes), lambda: cobranza(ano, mes, "TODOS", "SEDE"), lambda: cobranza(ano, mes, "TODOS", "PROGRAMA"), lambda: cobranza(ano, mes, "TODOS", "ASESOR"), lambda: rentabilidad(ano, mes), lambda: saldo_pendiente("1° Cuota"), ] for t in tareas: try: t() except Exception as e: print(f"[precarga] error: {e}") _marcar_actualizacion() # Precargar mes actual + anterior de TODOS los módulos (sin entrar a ellos) precargar_todos_modulos() _refresh_count = 0 def _recalcular_clave(key): from cache_manager import cache_invalidate try: prefijo, _, resto = key.partition(":") args = resto.split("|") if resto else [] cache_invalidate(prefijo + ":" + resto if resto else prefijo) if prefijo == "ocupabilidad" and len(args) == 4: return ocupabilidad(args[0], args[1], args[2], args[3]) if prefijo == "ventas" and len(args) == 2: return ventas(args[0], args[1]) if prefijo == "ventas" and len(args) == 5: return ventas(args[0], args[1], args[2], args[3], args[4] == "True") if prefijo == "cobranza" and len(args) == 4: return cobranza(args[0], args[1], args[2], args[3]) if prefijo == "rentabilidad" and len(args) == 4: return rentabilidad(args[0], args[1], args[2], args[3]) if prefijo == "saldo" and len(args) == 1: return saldo_pendiente(args[0]) if prefijo == "cobranza_det_todos" and len(args) == 3: return cobranza_detalle_todos(args[0], args[1], args[2]) except Exception as e: print(f"[recalcular] {key}: {e}") return None def refrescar_todo(): global _refresh_count from cache_manager import cache_refresh_existing, cache_keys _refresh_count += 1 if _refresh_count % 4 == 0: try: get_dm().cargar_toda_configuracion() print("[config] Recarga de queries/JSON de GitHub completada") except Exception as e: print(f"[config] error al recargar GitHub: {e}") if not cache_keys(): precargar_todo() else: # Limpiar el caché anual de cobranza para que el recálculo lea SQL fresco try: get_dm().limpiar_cache_cobranza() except Exception: pass cache_refresh_existing(_recalcular_clave) _marcar_actualizacion() # Reprecargar mes actual + anterior de todos los módulos (incluye combos nuevos) precargar_todos_modulos() def refrescar_manual(clave_actual=None): """Refresco a demanda (botón 'Actualizar'): 1) VACÍA el caché y RE-LEE las bases para la vista que el usuario ve AHORA. 2) El resto de vistas cacheadas se recalculan en SEGUNDO PLANO. NO afecta al ciclo automático de 15 min.""" import threading from cache_manager import cache_keys, cache_invalidate claves_previas = cache_keys() cache_invalidate() try: get_dm().limpiar_cache_cobranza() except Exception: pass if clave_actual: try: _recalcular_clave(clave_actual) except Exception as e: print(f"[refresh manual] vista actual {clave_actual}: {e}") resto = [k for k in claves_previas if k != clave_actual] def _fondo(): for k in resto: try: _recalcular_clave(k) except Exception as e: print(f"[refresh manual bg] {k}: {e}") _marcar_actualizacion() print(f"[refresh manual] segundo plano completado ({len(resto)} vistas)") threading.Thread(target=_fondo, daemon=True).start() precargar_todos_modulos() _marcar_actualizacion() return ultima_actualizacion() # ── PRECARGA EN SEGUNDO PLANO (cache warming por módulo) ──────────────────── import threading as _threading _precarga_gen = {} _precarga_estado = {} _precarga_lock = _threading.Lock() _PRECARGA_PAUSA = 0.15 def precarga_estado(modulo): """Progreso de la precarga de un módulo.""" modulo = (modulo or "").lower() with _precarga_lock: e = _precarga_estado.get(modulo) return dict(e) if e else {"total": 0, "hechas": 0, "fallidas": 0, "completo": False} def _periodos_act_ant(): """(año, mes) del mes actual y el anterior (maneja el cambio de año en enero).""" from datetime import datetime hoy = datetime.now() prev = (hoy.year - 1, 12) if hoy.month == 1 else (hoy.year, hoy.month - 1) return [(hoy.year, hoy.month), prev] def _combos_cobranza(ano): """(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior.""" periodos = _periodos_act_ant() agrupaciones = ["SEDE", "PROGRAMA", "ASESOR"] combos = [] for (yy, mm) in periodos: try: logic = CobranzaLogic(get_dm()) secs = logic.obtener_lista_sectoristas(str(yy), str(mm)) or ["TODOS"] except Exception: secs = ["TODOS"] secs = list(dict.fromkeys(["TODOS"] + [s for s in secs if s and s != "TODOS"])) for ag in agrupaciones: for sec in secs: combos.append((yy, mm, sec, ag)) return combos def precargar_modulo(modulo, ano, en_hilo=True): """Precarga la tabla principal del módulo (mes actual + anterior).""" modulo = (modulo or "").lower() with _precarga_lock: gen = _precarga_gen.get(modulo, 0) + 1 _precarga_gen[modulo] = gen _precarga_estado[modulo] = {"total": 0, "hechas": 0, "fallidas": 0, "completo": False, "ano": ano} def _vigente(): with _precarga_lock: return _precarga_gen.get(modulo) == gen def _sumar(campo): with _precarga_lock: if _precarga_gen.get(modulo) == gen: _precarga_estado[modulo][campo] += 1 def _set_total(n): with _precarga_lock: if _precarga_gen.get(modulo) == gen: _precarga_estado[modulo]["total"] = n def _marcar_completo(): with _precarga_lock: if _precarga_gen.get(modulo) == gen: _precarga_estado[modulo]["completo"] = True def _fondo(): import time if modulo == "cobranza": combos = _combos_cobranza(ano) _set_total(len(combos)) for (yy, mm, sec, ag) in combos: if not _vigente(): return try: cobranza(str(yy), str(mm), sec, ag) _sumar("hechas") except Exception as e: print(f"[precarga cobranza] {yy}-{mm} {ag}/{sec}: {e}") _sumar("fallidas") time.sleep(_PRECARGA_PAUSA) _marcar_completo() print(f"[precarga cobranza] {ano} completado ({len(combos)} vistas)") elif modulo in ("ocupabilidad", "ventas", "rentabilidad"): periodos = _periodos_act_ant() _set_total(len(periodos)) for (yy, mm) in periodos: if not _vigente(): return try: if modulo == "ocupabilidad": ocupabilidad(str(yy), str(mm), "TODOS", "TODOS") elif modulo == "ventas": ventas(str(yy), str(mm), "TODOS", "TODOS") else: rentabilidad(str(yy), str(mm), "TODOS", "TODOS") _sumar("hechas") except Exception as e: print(f"[precarga {modulo}] {yy}-{mm}: {e}") _sumar("fallidas") time.sleep(_PRECARGA_PAUSA) _marcar_completo() print(f"[precarga {modulo}] {ano} completado ({len(periodos)} vistas)") else: print(f"[precarga] módulo no soportado aún: {modulo}") if en_hilo: _threading.Thread(target=_fondo, daemon=True).start() else: _fondo() return {"status": "precarga iniciada", "modulo": modulo, "ano": ano} def precargar_todos_modulos(): """Precarga (en 2º plano) el mes actual y anterior de TODOS los módulos, de forma SECUENCIAL en un solo hilo. Se llama al arrancar, cada 15 min y tras el refresco manual.""" from datetime import datetime ano = datetime.now().year def _todo(): for m in ("cobranza", "ocupabilidad", "ventas", "rentabilidad"): try: precargar_modulo(m, ano, en_hilo=False) except Exception as e: print(f"[precarga todos] {m}: {e}") print("[precarga todos] completado (todos los módulos)") _threading.Thread(target=_todo, daemon=True).start() # ── OPERACIONES EXTRA ────────────────────────────────────────────────────── def guardar_costos(num_indice, costos_inicial, costos_actual): dm = get_dm() ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial) ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual) from cache_manager import cache_invalidate cache_invalidate("rentabilidad") cache_invalidate("rentabilidad_costos") return bool(ok1 and ok2) def clasificar_programas(nombres): dm = get_dm() logic = CobranzaLogic(dm) return {n: str(logic.processor.clasificar_sede(n)) for n in nombres} def cobranza_detalle_todos(ano, mes, sectorista="TODOS"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") alumnos = [] for d in (brutos or []): prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-" cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0)) cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0)) cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0)) saldo = float(d.get("SALDO", 0.0)) alumnos.append({ "matricula": str(d.get("MATRICULA", "")), "alumno": d.get("ALUMNO", d.get("Alumno", "")), "programa": prog, "sede": str(logic.processor.clasificar_sede(prog)), "frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")), "num_cuota": d.get("NUM_CUOTA", "-"), "fch_venc": d.get("FCH_VENC", "-"), "cta_ant": cta_ant, "cob_ant": cob_ant, "cta_cur": cta_cur, "cob_cur": cob_cur, "cta_tot": cta_tot, "cob_tot": cob_tot, "saldo": saldo, }) return {"alumnos": alumnos} return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) # ── GESTIÓN DE USUARIOS ──────────────────────────────────────────────────── def _sb(): dm = get_dm() if not dm.supabase_client: raise RuntimeError("Supabase no está conectado") return dm.supabase_client # ── Estado/observaciones por alumno (tabla cobranza_estado_alumno) ── def alumno_estado_obtener(matricula): """Devuelve {estado, observaciones} de un alumno. Si no existe, valores por defecto.""" sb = _sb() res = sb.table("cobranza_estado_alumno").select("*").eq("matricula", str(matricula)).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, estado, observaciones, usuario=None): """Guarda (upsert) el estado y la lista de observaciones del alumno.""" sb = _sb() from datetime import datetime, timezone payload = { "matricula": str(matricula), "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).execute() return {"status": "ok"} def alumnos_por_retirar(): """Lista de matrículas marcadas como POR RETIRAR (para tachar la fila en la tabla).""" sb = _sb() res = sb.table("cobranza_estado_alumno").select("matricula").eq("estado", "POR RETIRAR").execute() return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]} def usuarios_listar(): sb = _sb() perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or [] try: users = sb.auth.admin.list_users() lista = users if isinstance(users, list) else getattr(users, "users", []) correo_por_id = {str(u.id): u.email for u in lista} except Exception: correo_por_id = {} out = [] for p in perfiles: out.append({ "id": p["id"], "email": correo_por_id.get(str(p["id"]), "-"), "nombre": p.get("nombre"), "rol": p.get("rol"), "activo": p.get("activo", True), }) return {"usuarios": out} def usuarios_crear(email, password, nombre, rol): sb = _sb() res = sb.auth.admin.create_user({ "email": email, "password": password, "email_confirm": True, }) user = getattr(res, "user", None) or res uid = str(user.id) sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute() return {"status": "ok", "id": uid} def usuarios_actualizar_rol(user_id, rol): sb = _sb() sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute() return {"status": "ok"} def usuarios_eliminar(user_id): sb = _sb() sb.auth.admin.delete_user(user_id) return {"status": "ok"} # ── CONFIG MENSUAL COMISIONES ────────────────────────────────────────────── def comisiones_config_listar(ano, mes): sb = _sb() res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute() return {"config": res.data or []} def comisiones_config_guardar(ano, mes, filas): sb = _sb() sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute() payload = [] for f in filas: payload.append({ "ano": int(ano), "mes": int(mes), "tipo_programa": f.get("tipo_programa", ""), "imp_tc": float(f.get("imp_tc", 0) or 0), "meta": float(f.get("meta", 0) or 0), "comision": float(f.get("comision", 0) or 0), }) if payload: sb.table("comisiones_config_mensual").insert(payload).execute() from cache_manager import cache_invalidate cache_invalidate("ventas") cache_invalidate("ventas_det") return {"status": "ok"} def comisiones_tc_del_mes(ano, mes): try: sb = _sb() res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute() for r in (res.data or []): tc = float(r.get("imp_tc", 0) or 0) if tc > 0: return tc except Exception: pass return None def vendedores_manuales_listar(ano, mes): sb = _sb() query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) if int(mes) != 0: query = query.eq("mes", int(mes)) res = query.execute() return {"vendedores": res.data or []} def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes): sb = _sb() payload = { "nombre": nombre, "descripcion": descripcion or "", "fch_emision": fch_emision or "", "monto": float(monto or 0), "ano": int(ano), "mes": int(mes), } res = sb.table("vendedores_manuales").insert(payload).execute() return {"status": "ok", "data": res.data} def vendedores_manuales_eliminar(id): sb = _sb() sb.table("vendedores_manuales").delete().eq("id", id).execute() return {"status": "ok"} def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): sb = _sb() sb.table("vendedores_manuales").update({ "descripcion": descripcion or "", "fch_emision": fch_emision or "", "monto": float(monto or 0), }).eq("id", id).execute() return {"status": "ok"} def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): sb = _sb() sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() payload = [] for f in filas: payload.append({ "nombre": nombre, "descripcion": f.get("descripcion","") or "", "fch_emision": f.get("fch_emision","") or "", "monto": float(f.get("monto",0) or 0), "ano": int(ano), "mes": int(mes), }) if payload: sb.table("vendedores_manuales").insert(payload).execute() return {"status": "ok"} # ── OVERRIDES DE COMISIONES ──────────────────────────────────────────────── def comisiones_override_guardar(registros): sb = _sb() from cache_manager import cache_invalidate validos = [r for r in registros if r.get("num_matricula")] if validos: sb.table("comisiones_overrides").upsert(validos).execute() cache_invalidate("ventas_det") cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") return {"status": "ok"} def comisiones_override_restaurar(num_matricula): sb = _sb() from cache_manager import cache_invalidate sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute() cache_invalidate("ventas_det") cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") return {"status": "ok"}