# 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