# 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" # --- 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.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, } 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={{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"): 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 # 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) _sede_sel = str(sede or "TODOS").upper() _prog_sel = str(programa or "TODOS").upper() 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) _dscp = row[10] if (len(row) > 10 and row[10]) else (row[12] if len(row) > 12 else "") if _sede_sel != "TODOS" and _clasif_sede(_dscp) != _sede_sel: continue if _prog_sel != "TODOS" and _clasif_prog(_dscp) != _prog_sel: 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 # ¿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 # COLUMNA 4 y 5 — MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro if matricula_mes_pasado and saldos_ok 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): 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() return results except Exception as e: print(f"❌ Error cronograma cobranza: {e}") return [] def ejecutar_consulta_facturas_cobranza(self, ano): 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() return results except Exception as e: print(f"❌ Error facturas cobranza: {e}") return [] 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