Files
dashboard-completo/backend/core/data_manager.py

992 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# core/data_manager.py
import os
import requests
import json
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
class DataManager:
def __init__(self):
# --- SQL SERVER ---
self.server = os.getenv("SQL_SERVER", "191.98.134.80")
self.database = os.getenv("SQL_DATABASE", "BDUS_CK000040_0001")
self.username = os.getenv("SQL_USERNAME", "ASEBASTIAN")
self.password = os.getenv("SQL_PASSWORD", "")
# --- POSTGRESQL ---
self.pg_host = os.getenv("PG_HOST", "191.98.134.81")
self.pg_database = os.getenv("PG_DATABASE", "chatwoot_production")
self.pg_user = os.getenv("PG_USER", "postgres")
self.pg_password = os.getenv("PG_PASSWORD", "")
self.pg_port = os.getenv("PG_PORT", "5432")
# --- SUPABASE ---
self.supabase_url = os.getenv("SUPABASE_URL", "")
self.supabase_key = os.getenv("SUPABASE_KEY", "")
self.supabase_client = None
self._init_supabase()
# --- GITHUB ---
base = os.getenv("GITHUB_BASE", "https://raw.githubusercontent.com/aron1798/prueba1/main/DASHBOARD")
self.github_json_url = f"{base}/actualizador.json"
self.github_replace_url = f"{base}/REPLACE_CURSO.json"
self.github_meta_url = f"{base}/REPLACE_META.json"
self.github_costos_url = f"{base}/REPLACE_COSTOS.json"
self.github_pronostico_url = f"{base}/BASE_PRONOSTICO.json"
self.github_pendientes_url = f"{base}/BASE_PENDIENTES.json"
self.github_sede_url = f"{base}/sede.json"
self.github_horario_url = f"{base}/HORARIO_ASESOR.json"
self.github_query_url = f"{base}/SQL_QUERY_2/BASE_CURSO_2.query"
self.github_cuota_url = f"{base}/SQL_QUERY_2/BASE_CUOTAS_2.query"
self.github_cronograma_url = f"{base}/SQL_QUERY_2/BASE_CRONOGRAMA_2.query"
self.github_facturas_url = f"{base}/SQL_QUERY_2/BASE_FACTURAS_2.query"
self.github_matriculas_url = f"{base}/SQL_QUERY_2/BASE_MATRICULADOS_2.query"
self.github_notascredito_url = f"{base}/SQL_QUERY_2/BASE_NOTASCREDITO_2.query"
# --- Variables de estado ---
self.config_data = {}
self.replace_data = {}
self.meta_data = {}
self.sede_data = {}
self.pronostico_data = {}
self.costos_data = {}
self.historico_pendientes = {}
self.pendientes_data = set()
self.query_sql = ""
self.query_matriculas_sql = ""
self.query_cronograma_sql = ""
self.query_facturas_sql = ""
self.query_notascredito_sql = ""
# Caché en memoria de las consultas ANUALES de cobranza. Evita re-ejecutar
# la consulta del año COMPLETO en cada combinación de filtros (mes/sectorista/
# agrupación), que era lo que saturaba el SQL Server. Se llena solo cuando la
# consulta tiene éxito y se limpia en cada refresco (manual o ciclo 15 min).
self._cache_cronograma = {} # str(ano) -> filas
self._cache_facturas = {} # str(ano) -> filas
self._cache_notascredito = {} # str -> filas (rango completo)
self.cargar_toda_configuracion()
# =========================================================================
# CARGA DE CONFIGURACIÓN
# =========================================================================
def cargar_toda_configuracion(self):
# OPTIMIZACIÓN: descargar todas las URLs en paralelo
from concurrent.futures import ThreadPoolExecutor
json_tasks = {
"config_data": (self.github_json_url, {"config_general": {"auto_update_minutos": 5}}),
"replace_data": (self.github_replace_url, {}),
"meta_data": (self.github_meta_url, {}),
"sede_data": (self.github_sede_url, {}),
"pronostico_data": (self.github_pronostico_url, {"pronosticos": []}),
"costos_data": (self.github_costos_url, {}),
"_pendientes_raw": (self.github_pendientes_url, {}),
}
text_tasks = {
"query_sql": self.github_query_url,
"query_matriculas_sql": self.github_matriculas_url,
"query_cronograma_sql": self.github_cronograma_url,
"query_facturas_sql": self.github_facturas_url,
"query_notascredito_sql": self.github_notascredito_url,
}
with ThreadPoolExecutor(max_workers=15) as ex:
json_futs = {k: ex.submit(self._get_json, url, default) for k,(url,default) in json_tasks.items()}
text_futs = {k: ex.submit(self._get_text, url) for k,url in text_tasks.items()}
for k, f in json_futs.items(): setattr(self, k, f.result())
for k, f in text_futs.items(): setattr(self, k, f.result())
# pendientes: parsear el dict ya descargado
try:
self.historico_pendientes = self._pendientes_raw.get("historico_pendientes", {})
except:
self.historico_pendientes = {}
self.pendientes_data = set()
def _get_json(self, url, default=None):
try:
r = requests.get(url, timeout=4)
r.raise_for_status()
return r.json()
except:
return default or {}
def _get_text(self, url):
try:
r = requests.get(url, timeout=4)
r.raise_for_status()
return r.text
except:
return ""
def _cargar_pendientes(self):
# Ya cargado en paralelo dentro de cargar_toda_configuracion
if not hasattr(self, 'historico_pendientes'):
self.historico_pendientes = {}
if not hasattr(self, 'pendientes_data'):
self.pendientes_data = set()
# =========================================================================
# CONEXIONES A BASE DE DATOS
# =========================================================================
def get_connection(self):
import pyodbc
conn_str = (
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
f"SERVER={self.server};"
f"DATABASE={self.database};"
f"UID={self.username};"
f"PWD={self.password}"
)
return pyodbc.connect(conn_str)
def get_pg_connection(self):
try:
import psycopg2
except ImportError:
raise ImportError(
"psycopg2 no está instalado. Para usar PostgreSQL ejecuta:\n"
"pip install psycopg2-binary"
)
try:
return psycopg2.connect(
host=self.pg_host,
database=self.pg_database,
user=self.pg_user,
password=self.pg_password,
port=self.pg_port
)
except Exception as e:
print(f"❌ Error PostgreSQL: {e}")
return None
def _init_supabase(self):
self.supabase_error = None
try:
from supabase import create_client
if self.supabase_url and self.supabase_key:
self.supabase_client = create_client(self.supabase_url, self.supabase_key)
print(f"✅ Supabase conectado: {self.supabase_url[:30]}...")
else:
self.supabase_error = "SUPABASE_URL o SUPABASE_KEY vacíos"
print(f"⚠️ {self.supabase_error}")
except Exception as e:
self.supabase_client = None
self.supabase_error = f"{type(e).__name__}: {e}"
print(f"❌ Error al conectar Supabase: {self.supabase_error}")
# =========================================================================
# CONSULTAS SQL
# =========================================================================
def ejecutar_consulta_cursos(self, ano, mes):
try:
if not self.query_sql:
raise Exception("No se pudo cargar la query BASE_CURSO")
ids_invitados = []
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
for id_curso, datos in cursos_personalizados.items():
if 'fch_inicio' in datos:
try:
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
ids_invitados.append(str(int(id_curso)))
except:
pass
sql_final = self.query_sql
if ids_invitados:
ids_str = ",".join(ids_invitados)
if "ORDER BY" in sql_final:
parts = sql_final.split("ORDER BY")
sql_final = parts[0] + f" OR rp.num_indice IN ({ids_str}) \nORDER BY" + parts[1]
else:
sql_final += f" OR rp.num_indice IN ({ids_str})"
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(sql_final, ano, mes)
rows = cursor.fetchall()
cols = ['num_indice', 'dsc_det_programa', 'flg_activo', 'fch_inicio',
'cod_frecuencia', 'cod_estado', 'dsc_programa',
'Inscritos_Totales', 'Inscritos_Retirados', 'Inscritos_Activos']
datos = [{cols[i]: row[i] for i in range(len(cols))} for row in rows]
conn.close()
return datos
except Exception as e:
print(f"❌ Error ejecutar_consulta_cursos: {e}")
raise
def cargar_datos_matriculas(self, ano, mes):
try:
if not self.query_matriculas_sql:
return {}
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_matriculas_sql, ano, mes)
rows = cursor.fetchall()
d = {}
for row in rows:
d[row[0]] = d.get(row[0], 0) + 1
conn.close()
return d
except:
return {}
def ejecutar_consulta_matriculados_detalle(self, ano, mes):
try:
conn = self.get_connection()
cursor = conn.cursor()
ids_invitados = []
cursos_personalizados = self.config_data.get('cursos_personalizados', {})
for id_curso, datos in cursos_personalizados.items():
if 'fch_inicio' in datos:
try:
f_obj = datetime.strptime(datos['fch_inicio'], '%d-%m-%Y')
if str(f_obj.year) == str(ano) and str(f_obj.month) == str(mes):
ids_invitados.append(str(int(id_curso)))
except:
pass
sql_or = ""
if ids_invitados:
sql_or = f" OR sgede_RP_programa.num_indice IN ({','.join(ids_invitados)}) "
query = f"""
SELECT
sgede_RP_programa.num_indice, sgeca_matricula.num_matricula,
sgeca_matricula.cod_estado AS estado_matricula,
sgeca_programa.dsc_programa, sgeca_matricula.cod_moneda,
sgema_alumno.dsc_documento,
ISNULL((SELECT SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1), 0) AS INV_NETA,
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 0), 0) AS imp_saldo_matricula,
ISNULL((SELECT sgede_cronograma_matricula.imp_saldo FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1), 0) AS imp_saldo_cuota1,
ISNULL((SELECT TOP 1 comp.imp_tc FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc
ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC), 0) AS imp_tc
FROM sgeca_matricula
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
LEFT JOIN sgede_RP_programa
ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
WHERE sgeca_matricula.cod_localidad LIKE 'SCENT'
AND ((YEAR(sgede_RP_programa.fch_inicio) = ? AND MONTH(sgede_RP_programa.fch_inicio) = ?) {sql_or})
AND sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS')
"""
cursor.execute(query, ano, mes)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
return results
except Exception as e:
print(f"❌ Error ejecutar_consulta_matriculados_detalle: {e}")
return []
def consultar_historial_continuidad(self, lista_documentos):
if not lista_documentos:
return {}
try:
dnis = list(set([str(d).strip() for d in lista_documentos if d]))
if not dnis:
return {}
placeholders = ','.join(['?'] * len(dnis))
query = f"""
SELECT a.dsc_documento, p.dsc_programa, rp.fch_inicio
FROM sgeca_matricula m
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
INNER JOIN sgeca_programa p ON m.cod_programa = p.cod_programa
INNER JOIN sgede_RP_programa rp
ON m.cod_periodo = rp.cod_detalle
AND m.cod_programa = rp.cod_programa
AND m.num_indice = rp.num_indice
WHERE a.dsc_documento IN ({placeholders})
AND m.cod_estado IN ('ALU', 'PRE', 'RET')
AND m.cod_localidad LIKE 'SCENT'
AND (p.dsc_programa LIKE '%TEAC%' OR p.dsc_programa LIKE '%TERC%'
OR p.dsc_programa LIKE '%AREQUIPA%' OR p.dsc_programa LIKE '%TRUJILLO%'
OR p.dsc_programa LIKE '%PIURA%' OR p.dsc_programa LIKE '%TECNICO ESPECIALISTA%')
"""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(query, dnis)
historial = {}
for row in cursor.fetchall():
dni = str(row[0]).strip()
if dni not in historial:
historial[dni] = []
historial[dni].append({'programa': row[1], 'fecha': row[2]})
conn.close()
return historial
except Exception as e:
print(f"❌ Error historial continuidad: {e}")
return {}
def _get_query_ventas_hibrido(self, lista_ids_vip):
if not lista_ids_vip:
sql_in_clause = "(-1)"
else:
ids_str = [str(x) for x in lista_ids_vip]
sql_in_clause = "(" + ",".join(ids_str) + ")"
return f"""
SELECT
(
SELECT
rhuma_trabajador.dsc_apellido_paterno + ' ' +
SUBSTRING(rhuma_trabajador.dsc_apellido_materno, 1, 1) + '. ' +
rhuma_trabajador.dsc_nombres
FROM rhuma_trabajador
WHERE rhuma_trabajador.cod_trabajador = sgeca_matricula.cod_vendedor
) AS dsc_vendedor, -- [0]
ISNULL((
SELECT
SUM(sgede_cronograma_matricula.imp_total - ISNULL(sgede_cronograma_matricula.imp_dscto, 0))
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
), 0) AS INV_NETA, -- [1]
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 0
), 0) AS imp_saldo_matricula, -- [2]
ISNULL((
SELECT sgede_cronograma_matricula.imp_saldo
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1
), 0) AS imp_saldo_cuota1, -- [3]
sgeca_matricula.num_matricula, -- [4]
YEAR(sgeca_matricula.fch_matricula) as anio_mat, -- [5]
MONTH(sgeca_matricula.fch_matricula) as mes_mat, -- [6]
(
SELECT TOP 1 sgede_cronograma_matricula.fch_cancelacion
FROM sgede_cronograma_matricula
WHERE sgede_cronograma_matricula.cod_localidad = sgeca_matricula.cod_localidad
AND sgede_cronograma_matricula.num_matricula = sgeca_matricula.num_matricula
AND sgede_cronograma_matricula.num_refinanciamiento = 1
AND sgede_cronograma_matricula.num_cuota = 1
) AS fch_cancelacion_cuota1, -- [7]
sgeca_matricula.cod_moneda, -- [8]
ISNULL((
SELECT TOP 1 comp.imp_tc
FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.num_matricula = sgeca_matricula.num_matricula
AND cxc.cod_localidad_c = sgeca_matricula.cod_localidad
AND comp.cod_estado NOT IN ('ANU', 'ELI')
ORDER BY comp.fch_emision DESC
), 1) AS imp_tc, -- [9]
sgede_RP_programa.dsc_det_programa, -- [10] PROGRAMA (mismo num_indice)
sgede_RP_programa.fch_inicio, -- [11] FECHA INICIO (mismo num_indice)
sgeca_programa.dsc_programa, -- [12] PROGRAMA GENERAL (respaldo)
sgeca_matricula.cod_estado -- [13] ESTADO (ALU/PRE/RET)
FROM sgeca_matricula
INNER JOIN sgeca_programa ON sgeca_matricula.cod_programa = sgeca_programa.cod_programa
LEFT JOIN sgede_RP_programa ON sgeca_matricula.cod_periodo = sgede_RP_programa.cod_detalle
AND sgeca_matricula.cod_programa = sgede_RP_programa.cod_programa
AND sgeca_matricula.num_indice = sgede_RP_programa.num_indice
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
INNER JOIN vtama_localidad ON sgeca_matricula.cod_localidad = vtama_localidad.cod_localidad
INNER JOIN vtama_tipo_documento ON sgema_alumno.cod_tipo_documento = vtama_tipo_documento.cod_tipo_documento
WHERE
sgeca_matricula.cod_localidad LIKE 'SCENT'
AND (
sgeca_matricula.cod_estado IN ('ALU', 'PRE', 'RET')
OR sgeca_matricula.num_matricula IN {sql_in_clause}
)
AND (
(YEAR(sgeca_matricula.fch_matricula) = ? AND MONTH(sgeca_matricula.fch_matricula) = ?)
OR
sgeca_matricula.num_matricula IN {sql_in_clause}
OR
EXISTS (
SELECT 1 FROM sgede_cronograma_matricula crono
WHERE crono.cod_localidad = sgeca_matricula.cod_localidad
AND crono.num_matricula = sgeca_matricula.num_matricula
AND crono.num_cuota = 1
AND crono.fch_cancelacion IS NOT NULL
AND YEAR(crono.fch_cancelacion) = ?
AND MONTH(crono.fch_cancelacion) = ?
)
)
ORDER BY
dsc_vendedor ASC
"""
def ejecutar_consulta_ventas(self, ano, mes, sede="TODOS", programa="TODOS", excluir_ret=False):
try:
key_mes = f"{int(mes):02d}-{ano}"
lista_raw = self.historico_pendientes.get(key_mes, [])
lista_vip_list = []
for item in lista_raw:
try: lista_vip_list.append(int(item))
except: pass
# Lista PURA de BASE_PENDIENTES (sin overrides). Venta Pendientes usa SOLO esto.
set_lista_pendientes = set(lista_vip_list)
# Incluir matrículas con override de fecha (para que la query las traiga, incluso ANU)
_fov = getattr(self, '_fecha_canc_overrides', None) or {}
for mk in _fov.keys():
try: lista_vip_list.append(int(float(str(mk))))
except: pass
correcciones = self.config_data.get("correcciones_matriculas", {})
query_hibrido = self._get_query_ventas_hibrido(lista_vip_list)
conn = self.get_connection()
cursor = conn.cursor()
# Clasificadores para filtros (mismos que Cobranza/Ocupabilidad).
# Acepta varios valores separados por coma (multi-selección). Vacío/TODOS = todo.
_sede_set = {s.strip().upper() for s in str(sede or "TODOS").split(",") if s.strip()}
_prog_set = {p.strip().upper() for p in str(programa or "TODOS").split(",") if p.strip()}
_sede_all = (not _sede_set) or ("TODOS" in _sede_set)
_prog_all = (not _prog_set) or ("TODOS" in _prog_set)
def _clasif_sede(dscp):
if not dscp: return "LIMA"
up = str(dscp).upper()
sedes_data = self.sede_data.get("clasificacion_sedes", {})
for s, data in sedes_data.items():
if s == "DEFAULT": continue
for pat in data.get("patrones", []):
if pat.upper() in up: return s
_def = self.sede_data.get("clasificacion_default", {})
return _def.get("sede", "LIMA") if isinstance(_def, dict) else "LIMA"
def _clasif_prog(dscp):
# Clasifica el programa en UNA sola categoría, con prioridad de orden.
# Específicas primero (SEMINARIOS, TEAC, TERC); OTROS y el sobrante al final.
up = str(dscp or "").upper()
fdata = self.meta_data.get("clasificacion_filtro_programa", {})
orden = ["OTROS", "SEMINARIOS", "TEAC", "TERC"]
for cat in orden:
data = fdata.get(cat, {})
for pat in data.get("patrones", []):
if pat.upper() in up:
return cat
# Sobrante (no coincide con nada) → DEFAULT
return fdata.get("DEFAULT", "OTROS") if isinstance(fdata.get("DEFAULT"), str) else "OTROS"
cursor.execute(query_hibrido, ano, mes, ano, mes)
rows = cursor.fetchall()
ventas_por_vendedor = {}
set_vip_actual = set(lista_vip_list)
vendedores_excluidos = {
"CASTILLO B. KARINA LISSET",
"CASTILLO B. KARINA LISSET",
"CALDERON S. LISSA GENA",
"CRUZ G. FIORELLA MELISSA",
"URIBE G. MARIA MERCEDES"
}
for row in rows:
vendedor = row[0] if row[0] else "SIN VENDEDOR"
if vendedor in vendedores_excluidos: continue
# Filtro SEDE/PROGRAMA (mismas reglas que Ocupabilidad/Cobranza).
# Multi-selección: pasa si su sede/programa está en el set marcado.
_dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "")
if not _sede_all and _clasif_sede(_dscp) not in _sede_set:
continue
if not _prog_all and _clasif_prog(_dscp) not in _prog_set:
continue
inv_neta_raw = float(row[1]) if row[1] else 0.0
cod_moneda = row[8]
imp_tc = float(row[9]) if row[9] else 1.0
saldo_mat = float(row[2]) if row[2] else 0.0
saldo_c1 = float(row[3]) if row[3] else 0.0
raw_mat = row[4]
row_year = row[5]
row_month = row[6]
fch_cancelacion_raw = row[7]
matricula_int = -1
matricula_str = ""
try:
if raw_mat is not None:
val_str = str(raw_mat).strip()
if val_str.endswith('.0'): val_str = val_str[:-2]
matricula_str = val_str
matricula_int = int(float(val_str))
except: pass
if matricula_str in correcciones:
datos_corregidos = correcciones[matricula_str]
if "imp_tc" in datos_corregidos: imp_tc = float(datos_corregidos["imp_tc"])
if "imp_saldo_cuota1" in datos_corregidos: saldo_c1 = float(datos_corregidos["imp_saldo_cuota1"])
if "imp_saldo_matricula" in datos_corregidos: saldo_mat = float(datos_corregidos["imp_saldo_matricula"])
if "fch_cancelacion_cuota1" in datos_corregidos: fch_cancelacion_raw = datos_corregidos["fch_cancelacion_cuota1"]
# Override de FECHA CANCELACIÓN 1 desde Supabase (prioridad para clasificación)
_fov = getattr(self, '_fecha_canc_overrides', None)
_vacio_forzado = False
if _fov and matricula_str in _fov:
fov_val = _fov[matricula_str]
s = str(fov_val).strip()
if s == "__VACIO__":
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
fch_cancelacion_raw = None
_vacio_forzado = True
else:
# Normalizar dd/mm/yyyy o d/m/yyyy → yyyy-mm-dd
if '/' in s:
p = s.split('/')
if len(p) == 3:
s = f"{p[2]}-{int(p[1]):02d}-{int(p[0]):02d}"
fch_cancelacion_raw = s
# Prioridad TC: Supabase (mes) > corrección manual > SQL
_tc_ov = getattr(self, '_tc_override_mes', None)
if _tc_ov: imp_tc = float(_tc_ov)
if cod_moneda == 'DOL': inv_neta = inv_neta_raw * imp_tc
else: inv_neta = inv_neta_raw
# Override de INVERSIÓN NETA (Supabase): monto final en soles, reemplaza al SQL.
_inv_ov = getattr(self, '_inv_neta_overrides', None)
if _inv_ov and matricula_str in _inv_ov:
inv_neta = float(_inv_ov[matricula_str])
if vendedor not in ventas_por_vendedor:
ventas_por_vendedor[vendedor] = {
'monto': 0.0, 'cantidad': 0, 'monto_pc': 0.0, 'cantidad_pc': 0,
'monto_pendientes': 0.0, 'cantidad_pendientes': 0
}
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes))
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
# Si hay override de fecha de cancelación 1 (con fecha), se considera PAGADO.
# Si el override la vació a propósito, NO se fuerza pagado.
tiene_override_fecha = bool(_fov and matricula_str in _fov) and not _vacio_forzado
if tiene_override_fecha:
saldos_ok = True
# Si es RET y NO pagó (sin override ni saldos), NO entra en la tabla principal.
estado_row = str(row[13]).strip().upper() if len(row) > 13 and row[13] else ""
if estado_row == "RET" and not saldos_ok:
continue
# En VENTAS (excluir_ret): los RET NO cuentan aunque hayan pagado.
if excluir_ret and estado_row == "RET":
continue
# ¿Pagó la 1° cuota en el MES del filtro?
pago_en_fecha_correcta = False
if fch_cancelacion_raw:
try:
if isinstance(fch_cancelacion_raw, str):
f_obj = datetime.strptime(fch_cancelacion_raw[:10], '%Y-%m-%d')
f_ano, f_mes = f_obj.year, f_obj.month
else:
f_ano, f_mes = fch_cancelacion_raw.year, fch_cancelacion_raw.month
if str(f_ano) == str(ano) and str(f_mes) == str(mes):
pago_en_fecha_correcta = True
except: pass
# ¿La matrícula es de un mes ANTERIOR al filtro? (mismo año mes menor, o año anterior)
matricula_mes_pasado = False
try:
ym_mat = int(row_year) * 100 + int(row_month)
ym_filtro = int(ano) * 100 + int(mes)
matricula_mes_pasado = ym_mat < ym_filtro
except: pass
# COLUMNA 2 y 3 — MES EN CURSO: matrícula del mes + pagó en el mes
if es_venta_del_mes:
ventas_por_vendedor[vendedor]['monto'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad'] += 1
if saldos_ok and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pc'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pc'] += 1
# VENTA PENDIENTES: SOLO de tu lista BASE_PENDIENTES y que pagaron su 1ª
# cuota EN EL MES/AÑO DEL FILTRO (pago_en_fecha_correcta). Si pagó otro mes, no.
if matricula_int in set_lista_pendientes and pago_en_fecha_correcta:
ventas_por_vendedor[vendedor]['monto_pendientes'] += inv_neta
ventas_por_vendedor[vendedor]['cantidad_pendientes'] += 1
datos_ventas = []
for vendedor, datos in ventas_por_vendedor.items():
datos_ventas.append({
'VENDEDOR': vendedor,
'MONTO': round(datos['monto'], 2),
'CANTIDAD': datos['cantidad'],
'VENTAS_PC': round(datos['monto_pc'], 2),
'INSCRITOS_PC': datos['cantidad_pc'],
'PENDIENTES': round(datos['monto_pendientes'], 2),
'INSCRITOS_PENDIENTES': datos['cantidad_pendientes']
})
datos_ventas.sort(key=lambda x: x['MONTO'], reverse=True)
conn.close()
return datos_ventas
except Exception as e:
return []
def ejecutar_consulta_cronograma_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_cronograma.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_cronograma_sql:
return []
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_cronograma_sql)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_cronograma[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error cronograma cobranza: {e}")
return []
def ejecutar_consulta_facturas_cobranza(self, ano):
clave = str(ano)
en_cache = self._cache_facturas.get(clave)
if en_cache is not None:
return en_cache
try:
if not self.query_facturas_sql:
return []
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_facturas_sql)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_facturas[clave] = results # cachear SOLO si tuvo éxito
return results
except Exception as e:
print(f"❌ Error facturas cobranza: {e}")
return []
def ejecutar_consulta_notascredito(self):
"""Notas de crédito (rango completo, cacheado). El query no tiene año."""
en_cache = self._cache_notascredito.get("all")
if en_cache is not None:
return en_cache
try:
if not self.query_notascredito_sql:
return []
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(self.query_notascredito_sql)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
self._cache_notascredito["all"] = results
return results
except Exception as e:
print(f"❌ Error notas de crédito: {e}")
return []
def traer_vendedor_cod_detalle(self, matriculas, ano=None, mes=None):
"""Dada una lista de num_matricula (POR RETIRAR, pocas), devuelve por cada una:
{num_matricula, dsc_vendedor, cod_detalle, ...}. Consulta ligera.
Las cuotas vencidas se cuentan HASTA EL FIN DEL MES (ano, mes) del filtro,
NO hasta la fecha de hoy (así septiembre no cambia al llegar octubre).
Si no se pasa ano/mes, usa la fecha de hoy (comportamiento anterior)."""
mats = [str(m).strip() for m in (matriculas or []) if str(m).strip()]
if not mats:
return {}
# Fecha de corte: primer día del mes SIGUIENTE al filtro (vencidas < corte).
# ini_mes = primer día del mes del filtro (para separar Anterior vs Curso).
corte_venc = "CAST(GETDATE() AS DATE)"
ini_mes = "CAST(GETDATE() AS DATE)"
if ano and mes:
try:
_a, _m = int(ano), int(mes)
_na, _nm = (_a + 1, 1) if _m == 12 else (_a, _m + 1)
corte_venc = f"CAST('{_na:04d}-{_nm:02d}-01' AS DATE)"
ini_mes = f"CAST('{_a:04d}-{_m:02d}-01' AS DATE)"
except Exception:
corte_venc = "CAST(GETDATE() AS DATE)"
ini_mes = "CAST(GETDATE() AS DATE)"
try:
conn = self.get_connection()
cursor = conn.cursor()
placeholders = ",".join("?" for _ in mats)
sql = f"""
SELECT
m.num_matricula,
(SELECT t.dsc_apellido_paterno + ' ' +
SUBSTRING(t.dsc_apellido_materno, 1, 1) + '. ' + t.dsc_nombres
FROM rhuma_trabajador t
WHERE t.cod_trabajador = m.cod_vendedor) AS dsc_vendedor,
m.cod_periodo AS cod_detalle,
a.dsc_apellido_paterno + ' ' + a.dsc_apellido_materno + ', ' + a.dsc_nombres AS dsc_alumno,
(SELECT COUNT(1)
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento
AND c.cod_tipo_cuota = 'ARM'
AND c.cod_estado = 'CAN') AS num_cuotas_pagadas,
(SELECT COUNT(1)
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento
AND c.cod_tipo_cuota = 'ARM'
AND c.cod_estado <> 'CAN'
AND c.fch_vencimiento < {corte_venc}) AS num_cuotas_vencidas,
STUFF((SELECT ' - ' + CONVERT(varchar(10), c.fch_vencimiento, 103)
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento
AND c.cod_tipo_cuota = 'ARM'
AND c.cod_estado <> 'CAN'
AND c.fch_vencimiento < {corte_venc}
ORDER BY c.fch_vencimiento
FOR XML PATH('')), 1, 3, '') AS fch_venc_mas_antigua,
ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0) - ISNULL(c.imp_total_pagado,0))
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento
AND c.cod_tipo_cuota = 'ARM'
AND c.cod_estado <> 'CAN'
AND c.fch_vencimiento < {ini_mes}), 0) AS monto_venc_ant,
ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto,0) - ISNULL(c.imp_total_pagado,0))
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento
AND c.cod_tipo_cuota = 'ARM'
AND c.cod_estado <> 'CAN'
AND c.fch_vencimiento >= {ini_mes}
AND c.fch_vencimiento < {corte_venc}), 0) AS monto_venc_cur,
(SELECT SUM(c.imp_total_pagado)
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = m.num_refinanciamiento) AS imp_total_pagado,
ISNULL((SELECT SUM(c.imp_total - ISNULL(c.imp_dscto, 0))
FROM sgede_cronograma_matricula c
WHERE c.cod_localidad = m.cod_localidad
AND c.num_matricula = m.num_matricula
AND c.num_refinanciamiento = 1), 0) AS inv_neta,
m.cod_moneda AS cod_moneda,
ISNULL((SELECT TOP 1 comp.imp_tc
FROM vtaca_comprobante comp
INNER JOIN sgevi_cuotas_x_comprobante cxc
ON comp.cod_localidad = cxc.cod_localidad
AND comp.num_correlativo = cxc.num_correlativo
WHERE cxc.cod_localidad_c = m.cod_localidad
AND cxc.num_matricula = m.num_matricula
ORDER BY comp.fch_emision DESC), 1) AS imp_tc
FROM sgeca_matricula m
INNER JOIN sgema_alumno a ON m.cod_alumno = a.cod_alumno
WHERE m.num_matricula IN ({placeholders})
"""
cursor.execute(sql, mats)
cols = [c[0] for c in cursor.description]
filas = [dict(zip(cols, row)) for row in cursor.fetchall()]
conn.close()
out = {}
for f in filas:
# Si la matrícula está en DÓLARES, convertir montos a soles (× imp_tc).
_moneda = str(f.get("cod_moneda") or "SOL").strip().upper()
try: _tc = float(f.get("imp_tc") or 1.0)
except: _tc = 1.0
if _moneda != "DOL" or _tc <= 0:
_tc = 1.0
_mult = _tc if _moneda == "DOL" else 1.0
_inv = float(f.get("inv_neta") or 0) * _mult
_pag = float(f.get("imp_total_pagado") or 0) * _mult
_mva = float(f.get("monto_venc_ant") or 0) * _mult
_mvc = float(f.get("monto_venc_cur") or 0) * _mult
out[str(f.get("num_matricula", "")).strip()] = {
"dsc_vendedor": str(f.get("dsc_vendedor") or "").strip(),
"cod_detalle": str(f.get("cod_detalle") or "").strip(),
"dsc_alumno": str(f.get("dsc_alumno") or "").strip(),
"num_cuotas_pagadas": int(f.get("num_cuotas_pagadas") or 0),
"num_cuotas_vencidas": int(f.get("num_cuotas_vencidas") or 0),
"fch_venc_mas_antigua": str(f.get("fch_venc_mas_antigua") or ""),
"imp_total_pagado": round(_pag, 2),
"inv_neta": round(_inv, 2),
"monto_venc_ant": round(_mva, 2) if _mva > 0 else 0,
"monto_venc_cur": round(_mvc, 2) if _mvc > 0 else 0,
}
return out
except Exception as e:
print(f"❌ Error traer_vendedor_cod_detalle: {e}")
return {}
def limpiar_cache_cobranza(self, ano=None):
"""Vacía el caché anual de cobranza para forzar re-lectura desde SQL.
Se llama en cada refresco (manual o ciclo de 15 min)."""
if ano is None:
self._cache_cronograma.clear()
self._cache_facturas.clear()
self._cache_notascredito.clear()
else:
self._cache_cronograma.pop(str(ano), None)
self._cache_facturas.pop(str(ano), None)
def ejecutar_consulta_saldos_anual(self, ano):
try:
if not self.query_matriculas_sql:
return []
sql_anual = self.query_matriculas_sql.replace(
"AND MONTH(sgeca_matricula.fch_matricula) = ?", ""
)
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute(sql_anual, ano)
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
return results
except Exception as e:
print(f"❌ Error saldos anual: {e}")
return []
# =========================================================================
# SUPABASE - OVERRIDES DE COSTOS
# =========================================================================
def cargar_overrides_costos(self):
if not self.supabase_client:
return {}
try:
response = self.supabase_client.table('costos_overrides').select('*').execute()
overrides = {}
for row in response.data:
idx = str(row.get('num_indice', '')).strip()
tipo = str(row.get('tipo_costo', '')).strip().lower()
if not idx or tipo not in ('inicial', 'actual'):
continue
if idx not in overrides:
overrides[idx] = {}
overrides[idx][tipo] = {
'epp': row.get('costo_epp'),
'certificado': row.get('costo_certificado'),
'docente': row.get('costo_docente'),
'consumibles': row.get('costo_consumibles'),
'marketing': row.get('costo_marketing'),
}
return overrides
except Exception as e:
print(f"⚠️ Error overrides Supabase: {e}")
return {}
def obtener_fechas_originales(self):
"""Descarga todas las fechas originales guardadas en Supabase (inicios reprogramados)."""
if not self.supabase_client:
return {}
try:
respuesta = self.supabase_client.table('fechas_originales_cursos').select('*').execute()
datos = respuesta.data
diccionario_fechas = {}
for fila in datos:
indice = str(fila.get('num_indice'))
fecha = fila.get('fecha_inicio_original')
programa = fila.get('programa', 'CURSO REPROGRAMADO')
diccionario_fechas[indice] = {'fecha': fecha, 'programa': programa}
return diccionario_fechas
except Exception as e:
print(f"Error al cargar fechas originales: {e}")
return {}
def guardar_fecha_original(self, num_indice, programa, fecha_inicio):
"""Guarda silenciosamente un nuevo curso y su fecha inicial en Supabase."""
if not self.supabase_client:
return False
try:
payload = {
'num_indice': str(num_indice),
'programa': str(programa),
'fecha_inicio_original': str(fecha_inicio)
}
self.supabase_client.table('fechas_originales_cursos').upsert(payload).execute()
return True
except Exception as e:
print(f"Error al guardar fecha original: {e}")
return False
def guardar_override_costo(self, num_indice, tipo_costo, costos_dict):
if not self.supabase_client:
return False
try:
payload = {
'num_indice': str(num_indice),
'tipo_costo': tipo_costo,
'costo_epp': costos_dict.get('epp'),
'costo_certificado': costos_dict.get('certificado'),
'costo_docente': costos_dict.get('docente'),
'costo_consumibles': costos_dict.get('consumibles'),
'costo_marketing': costos_dict.get('marketing'),
'fecha_actualizacion': datetime.now().isoformat(),
}
self.supabase_client.table('costos_overrides').upsert(payload).execute()
return True
except Exception as e:
print(f"❌ Error guardar override: {e}")
return False
def eliminar_overrides_costo(self, num_indice):
if not self.supabase_client:
return False
try:
self.supabase_client.table('costos_overrides').delete().eq(
'num_indice', str(num_indice)
).execute()
return True
except Exception as e:
print(f"❌ Error eliminar overrides: {e}")
return False
# =========================================================================
# HELPERS
# =========================================================================
def get_current_year(self):
return datetime.now().year
def get_current_month(self):
return datetime.now().month