""" 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 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) # ── VENTAS ───────────────────────────────────────────────────────────────── def ventas(ano, mes, sede="TODOS", programa="TODOS"): def _load(): dm = get_dm() 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: inversion_neta override} 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}") _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 == "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: cache_refresh_existing(_recalcular_clave) # ── OPERACIONES EXTRA ────────────────────────────────────────────────────── def guardar_costos(num_indice, costos_inicial, costos_actual): dm = get_dm() ok1 = dm.guardar_override_costo(num_indice, "inicial", costos_inicial) ok2 = dm.guardar_override_costo(num_indice, "actual", costos_actual) from cache_manager import cache_invalidate cache_invalidate("rentabilidad") cache_invalidate("rentabilidad_costos") return bool(ok1 and ok2) def clasificar_programas(nombres): dm = get_dm() logic = CobranzaLogic(dm) return {n: str(logic.processor.clasificar_sede(n)) for n in nombres} def cobranza_detalle_todos(ano, mes, sectorista="TODOS"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) brutos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") alumnos = [] for d in (brutos or []): prog = d.get("Programa", d.get("PROGRAMA", "-")) or "-" cta_ant = float(d.get("CTA_COB_ANT", 0.0)); cob_ant = float(d.get("COB_ANT", 0.0)) cta_cur = float(d.get("CTA_COB_MES_CURSO", 0.0)); cob_cur = float(d.get("COB_MES_CURSO", 0.0)) cta_tot = float(d.get("TOTAL_CTA_COB", 0.0)); cob_tot = float(d.get("TOTAL_COBRADO", 0.0)) saldo = float(d.get("SALDO", 0.0)) alumnos.append({ "matricula": str(d.get("MATRICULA", "")), "alumno": d.get("ALUMNO", d.get("Alumno", "")), "programa": prog, "sede": str(logic.processor.clasificar_sede(prog)), "frecuencia": d.get("FRECUENCIA", d.get("Frecuencia", "-")), "num_cuota": d.get("NUM_CUOTA", "-"), "fch_venc": d.get("FCH_VENC", "-"), "cta_ant": cta_ant, "cob_ant": cob_ant, "cta_cur": cta_cur, "cob_cur": cob_cur, "cta_tot": cta_tot, "cob_tot": cob_tot, "saldo": saldo, }) return {"alumnos": alumnos} return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) # ── GESTIÓN DE USUARIOS ──────────────────────────────────────────────────── def _sb(): dm = get_dm() if not dm.supabase_client: raise RuntimeError("Supabase no está conectado") return dm.supabase_client def usuarios_listar(): sb = _sb() perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or [] try: users = sb.auth.admin.list_users() lista = users if isinstance(users, list) else getattr(users, "users", []) correo_por_id = {str(u.id): u.email for u in lista} except Exception: correo_por_id = {} out = [] for p in perfiles: out.append({ "id": p["id"], "email": correo_por_id.get(str(p["id"]), "-"), "nombre": p.get("nombre"), "rol": p.get("rol"), "activo": p.get("activo", True), }) return {"usuarios": out} def usuarios_crear(email, password, nombre, rol): sb = _sb() res = sb.auth.admin.create_user({ "email": email, "password": password, "email_confirm": True, }) user = getattr(res, "user", None) or res uid = str(user.id) sb.table("perfiles").upsert({"id": uid, "nombre": nombre or email, "rol": rol, "activo": True}).execute() return {"status": "ok", "id": uid} def usuarios_actualizar_rol(user_id, rol): sb = _sb() sb.table("perfiles").update({"rol": rol}).eq("id", user_id).execute() return {"status": "ok"} def usuarios_eliminar(user_id): sb = _sb() sb.auth.admin.delete_user(user_id) return {"status": "ok"} # ── CONFIG MENSUAL COMISIONES ────────────────────────────────────────────── def comisiones_config_listar(ano, mes): sb = _sb() res = sb.table("comisiones_config_mensual").select("*").eq("ano", int(ano)).eq("mes", int(mes)).execute() return {"config": res.data or []} def comisiones_config_guardar(ano, mes, filas): sb = _sb() sb.table("comisiones_config_mensual").delete().eq("ano", int(ano)).eq("mes", int(mes)).execute() payload = [] for f in filas: payload.append({ "ano": int(ano), "mes": int(mes), "tipo_programa": f.get("tipo_programa", ""), "imp_tc": float(f.get("imp_tc", 0) or 0), "meta": float(f.get("meta", 0) or 0), "comision": float(f.get("comision", 0) or 0), }) if payload: sb.table("comisiones_config_mensual").insert(payload).execute() from cache_manager import cache_invalidate cache_invalidate("ventas") cache_invalidate("ventas_det") return {"status": "ok"} def comisiones_tc_del_mes(ano, mes): try: sb = _sb() res = sb.table("comisiones_config_mensual").select("imp_tc").eq("ano", int(ano)).eq("mes", int(mes)).execute() for r in (res.data or []): tc = float(r.get("imp_tc", 0) or 0) if tc > 0: return tc except Exception: pass return None def vendedores_manuales_listar(ano, mes): sb = _sb() query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) if int(mes) != 0: query = query.eq("mes", int(mes)) res = query.execute() return {"vendedores": res.data or []} def vendedores_manuales_crear(nombre, descripcion, fch_emision, monto, ano, mes): sb = _sb() payload = { "nombre": nombre, "descripcion": descripcion or "", "fch_emision": fch_emision or "", "monto": float(monto or 0), "ano": int(ano), "mes": int(mes), } res = sb.table("vendedores_manuales").insert(payload).execute() return {"status": "ok", "data": res.data} def vendedores_manuales_eliminar(id): sb = _sb() sb.table("vendedores_manuales").delete().eq("id", id).execute() return {"status": "ok"} def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): sb = _sb() sb.table("vendedores_manuales").update({ "descripcion": descripcion or "", "fch_emision": fch_emision or "", "monto": float(monto or 0), }).eq("id", id).execute() return {"status": "ok"} def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): sb = _sb() sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() payload = [] for f in filas: payload.append({ "nombre": nombre, "descripcion": f.get("descripcion","") or "", "fch_emision": f.get("fch_emision","") or "", "monto": float(f.get("monto",0) or 0), "ano": int(ano), "mes": int(mes), }) if payload: sb.table("vendedores_manuales").insert(payload).execute() return {"status": "ok"} # ── OVERRIDES DE COMISIONES ──────────────────────────────────────────────── def comisiones_override_guardar(registros): sb = _sb() from cache_manager import cache_invalidate validos = [r for r in registros if r.get("num_matricula")] if validos: sb.table("comisiones_overrides").upsert(validos).execute() cache_invalidate("ventas_det") cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") return {"status": "ok"} def comisiones_override_restaurar(num_matricula): sb = _sb() from cache_manager import cache_invalidate sb.table("comisiones_overrides").delete().eq("num_matricula", str(num_matricula)).execute() cache_invalidate("ventas_det") cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") return {"status": "ok"}