diff --git a/backend/services.py b/backend/services.py index cbf546e..8c56a99 100644 --- a/backend/services.py +++ b/backend/services.py @@ -1,3 +1,4 @@ +# 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. @@ -25,12 +26,14 @@ 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 []): @@ -40,6 +43,7 @@ def _serializar_dicts(datos): out.append(fila) return out + def _serializar_filas(filas, limite=None): out = [] for f in (filas or []): @@ -47,24 +51,14 @@ def _serializar_filas(filas, limite=None): 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), @@ -89,39 +83,29 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): # 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() + 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) - 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'] + datos = logic.obtener_datos_brutos_filtrado(str(ano), str(mes), sede, programa) \ + if hasattr(logic, "obtener_datos_brutos_filtrado") else logic.obtener_datos_brutos(str(ano), str(mes)) + 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: - det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), mes_str) + det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes)) 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() + # INV NETA está en la posición [7] como "S/ x" try: monto = float(str(f[7]).replace("S/", "").replace(",", "").strip()) except Exception: @@ -129,6 +113,7 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): 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 for d in datos: v = str(d.get("VENDEDOR", "")).strip() acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) @@ -138,12 +123,12 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): 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) + 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).""" @@ -155,6 +140,7 @@ def _clasif_filtro_programa(dm, dscp): 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() @@ -164,7 +150,8 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS 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) - + # Filtro por SEDE [14] y PROGRAMA (clasificado desde el nombre [16]), + # mismas reglas que la tabla principal. Omite la fila TOTAL GENERAL. _sede = str(sede or "TODOS").upper() _prog = str(programa or "TODOS").upper() if _sede != "TODOS" or _prog != "TODOS": @@ -176,6 +163,8 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS out.append(f); continue if _sede != "TODOS" and str(f[14]).strip().upper() != _sede: continue + # Clasificar programa por el nombre de CLASIFICACIÓN [16] (prioriza dsc_programa); + # si no existe, usar el mostrado [8]. _prog_nombre = f[16] if len(f) > 16 else f[8] if _prog != "TODOS" and _clasif_filtro_programa(dm, _prog_nombre) != _prog: continue @@ -184,7 +173,10 @@ def ventas_detalle(vendedor, ano, mes, tipo_lista, sede="TODOS", programa="TODOS 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): + """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) @@ -193,7 +185,9 @@ def comisiones_detalle_todos(ano, mes): 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() @@ -202,6 +196,8 @@ def comisiones_overrides_fecha(): 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 != "": @@ -210,7 +206,10 @@ def comisiones_overrides_fecha(): 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() @@ -222,14 +221,19 @@ def comisiones_overrides_inv_neta(): s = str(v).replace("S/", "").replace(",", "").strip() if s == "": continue - try: out[str(r["num_matricula"])] = float(s) + 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): @@ -238,6 +242,7 @@ def _aplicar_overrides_comisiones(filas): 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, @@ -248,8 +253,10 @@ def _aplicar_overrides_comisiones(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]) + f.append(f[8]) # buscador: no trae [16] → reutiliza el nombre mostrado if len(f) == 17: f.append("0") if mat in ov: @@ -259,59 +266,72 @@ def _aplicar_overrides_comisiones(filas): if val is None: continue if str(val) == "__VACIO__": - f[idx] = "" + f[idx] = "" # forzar vacío explícito (override "borrar") elif str(val) != "": f[idx] = val - f[17] = "1" + 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"): +def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"): 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}") + filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta) + 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), _load) + return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta), _load) -def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA"): + +def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) + proc = logic.processor + pf = str(prog_filtro).upper() + sf = str(sede_filtro).upper() + vh = venc_hasta + # Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META) + mats_prog = None + if pf not in ("TODOS", "", "NONE") or sf not in ("TODOS", "", "NONE"): + crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") + mats_prog = set() + for d in (crudos_pf or []): + prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") + ok_prog = pf in ("TODOS", "", "NONE") or str(proc.clasificar_filtro_programa(prog)).upper() == pf + ok_sede = sf in ("TODOS", "", "NONE") or str(proc.clasificar_sede_meta(prog)).upper() == sf + 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") + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh) elif agrupacion == "SEDE": - crudos = logic.processor.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") + # Filtrar por sede usando el MISMO clasificador del processor (sede.json) + crudos = proc.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() + 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") + todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh) + # 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: - datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo) + else: # PROGRAMA + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh) + # 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), _load) + return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load) + # ── RENTABILIDAD ─────────────────────────────────────────────────────────── def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): @@ -325,6 +345,7 @@ def rentabilidad(ano, mes, sede="TODOS", filtro_prog="TODOS"): 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() @@ -333,6 +354,7 @@ def rentabilidad_detalle(programa, ano, 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() @@ -340,6 +362,7 @@ def rentabilidad_costos(programa, ano, mes): 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(): @@ -349,7 +372,8 @@ def saldo_pendiente(tipo_cuota): return {"datos": _serializar_dicts(datos)} return cache_get_or_set("saldo", (tipo_cuota,), _load) -# ── PRECARGA GLOBAL ──────────────────────────────────────────────────────── + +# ── PRECARGA GLOBAL (calienta todo el caché) ─────────────────────────────── def precargar_todo(): from datetime import datetime ahora = datetime.now() @@ -372,13 +396,16 @@ def precargar_todo(): # 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]) @@ -396,18 +423,24 @@ def _recalcular_clave(key): return cobranza_detalle_todos(args[0], args[1], args[2]) except Exception as e: print(f"[recalcular] {key}: {e}") - return None + 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: @@ -424,25 +457,31 @@ def refrescar_todo(): 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. + 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(): @@ -455,21 +494,27 @@ def refrescar_manual(clave_actual=None): 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 = {} -_precarga_estado = {} +_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 +_PRECARGA_PAUSA = 0.15 # seg entre vistas, para no golpear las bases def precarga_estado(modulo): - """Progreso de la precarga de un módulo.""" + """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) @@ -485,11 +530,13 @@ def _periodos_act_ant(): def _combos_cobranza(ano): - """(ano, mes, sectorista, agrupacion) a calentar: SOLO mes actual y anterior.""" + """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"] @@ -503,7 +550,9 @@ def _combos_cobranza(ano): def precargar_modulo(modulo, ano, en_hilo=True): - """Precarga la tabla principal del módulo (mes actual + anterior).""" + """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 @@ -536,9 +585,9 @@ def precargar_modulo(modulo, ano, en_hilo=True): _set_total(len(combos)) for (yy, mm, sec, ag) in combos: if not _vigente(): - return + return # una precarga más nueva la reemplazó try: - cobranza(str(yy), str(mm), sec, ag) + 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}") @@ -547,6 +596,7 @@ def precargar_modulo(modulo, ano, en_hilo=True): _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: @@ -578,7 +628,8 @@ def precargar_modulo(modulo, ano, en_hilo=True): 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.""" + 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 @@ -593,7 +644,7 @@ def precargar_todos_modulos(): _threading.Thread(target=_todo, daemon=True).start() -# ── OPERACIONES EXTRA ────────────────────────────────────────────────────── +# ── 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) @@ -603,12 +654,30 @@ def guardar_costos(num_indice, costos_inicial, costos_actual): 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) @@ -636,7 +705,8 @@ def cobranza_detalle_todos(ano, mes, sectorista="TODOS"): return {"alumnos": alumnos} return cache_get_or_set("cobranza_det_todos", (ano, mes, sectorista), _load) -# ── GESTIÓN DE USUARIOS ──────────────────────────────────────────────────── + +# ── GESTIÓN DE USUARIOS (Supabase Auth + tabla perfiles) ─────────────────── def _sb(): dm = get_dm() if not dm.supabase_client: @@ -716,13 +786,17 @@ def usuarios_eliminar(user_id): sb.auth.admin.delete_user(user_id) return {"status": "ok"} -# ── CONFIG MENSUAL COMISIONES ────────────────────────────────────────────── + +# ── 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 = [] @@ -742,6 +816,7 @@ def comisiones_config_guardar(ano, mes, filas): 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() @@ -752,10 +827,11 @@ def comisiones_tc_del_mes(ano, mes): 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: + 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 []} @@ -775,6 +851,7 @@ def vendedores_manuales_eliminar(id): 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({ @@ -783,9 +860,14 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): }).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}. 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({ @@ -799,8 +881,12 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): sb.table("vendedores_manuales").insert(payload).execute() return {"status": "ok"} -# ── OVERRIDES DE COMISIONES ──────────────────────────────────────────────── + + + +# ── 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")] @@ -812,10 +898,11 @@ def comisiones_override_guardar(registros): 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"} \ No newline at end of file + return {"status": "ok"}