# modules/rentabilidad/processor.py from datetime import datetime import unicodedata class RentabilidadProcessor: """Procesador de datos para Rentabilidad - Hereda la lógica estricta de Ocupabilidad""" def __init__(self, data_manager): self.data_manager = data_manager # Columnas exclusivas de rentabilidad self.columnas = [ 'programa_frecuencia', 'fch_inicio', 'Descuento', # INSCRITOS REFRIPERU 'Inscritos_Continuidad', # INSCRITOS CONTINUIDAD 'Inscritos_Nuevos', # Calculado en Logic 'Inscritos_Totales', # TOTAL INSCRITOS 'Inscritos_PC', # INSCRITOS P.C 'Retirados', # INSCRITOS RETIRADOS 'Inscritos_En_Curso', # INSCRITOS ACTIVOS 'Promedio_Cuota', # Pendiente (0) 'Promedio_Desc_E', # Pendiente (0) 'Valor_Venta', # Pendiente (0) 'Opciones' # Fijo "en cu" ] def obtener_datos_procesados(self, ano, mes): try: from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=3) as executor: fut_cursos = executor.submit(self.data_manager.ejecutar_consulta_cursos, ano, mes) fut_raw = executor.submit(self.data_manager.ejecutar_consulta_matriculados_detalle, ano, mes) fut_matriculas = executor.submit(self.data_manager.cargar_datos_matriculas, ano, mes) datos_originales = fut_cursos.result() datos_raw_matriculas = fut_raw.result() datos_matriculas = fut_matriculas.result() lista_dnis = [] if datos_raw_matriculas: for alumno in datos_raw_matriculas: dni = alumno.get('dsc_documento') if dni: lista_dnis.append(dni) datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis) datos_procesados = self.aplicar_personalizaciones( datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano, mes ) return datos_procesados except Exception as e: print(f"❌ Error obteniendo datos rentabilidad: {e}") return [] def normalizar_texto(self, texto): if not texto: return "" texto = str(texto).upper().strip() texto = unicodedata.normalize('NFD', texto) return texto.encode('ascii', 'ignore').decode("utf-8") def obtener_categoria_programa(self, nombre_programa): try: nombre_limpio = self.normalizar_texto(nombre_programa) clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {}) for categoria, config in clasificaciones.items(): patrones = config.get('patrones', []) for patron in patrones: if self.normalizar_texto(patron) in nombre_limpio: return categoria return "OTROS" except: return "OTROS" def aplicar_personalizaciones(self, datos_originales, datos_matriculas, datos_raw_matriculas, datos_historial, ano_filtro, mes_filtro): print("💰 Procesando variables de Rentabilidad...") cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {}) datos_filtrados = [] try: ano_target, mes_target = int(ano_filtro), int(mes_filtro) except: ano_target, mes_target = 0, 0 # FILTRADO Y ACTUALIZADOR EN VIVO for curso in datos_originales: num_indice = str(curso.get('num_indice', '')) if num_indice in cursos_personalizados: curso.update(cursos_personalizados[num_indice]) if curso.get('flg_activo', '') == 'NO': continue # Si el curso está suspendido, lo ignoramos y no se muestra en Rentabilidad if str(curso.get('cod_estado', '')).strip().upper() == 'SUS': continue fch_valida = True try: raw_fecha = curso.get('fch_inicio') if raw_fecha: s_fecha = str(raw_fecha).strip() if len(s_fecha) == 10 and s_fecha[2] == '-': f_obj = datetime.strptime(s_fecha, '%d-%m-%Y') elif ' ' in s_fecha: f_obj = datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') else: f_obj = datetime.strptime(s_fecha, '%Y-%m-%d') if f_obj and (f_obj.year != ano_target or f_obj.month != mes_target): fch_valida = False except: pass if fch_valida: datos_filtrados.append(curso) datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados) datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados) for c in datos_filtrados: c['fch_inicio'] = self.formatear_fecha(c.get('fch_inicio', '')) # CÁLCULOS CENTRALES datos_filtrados, alumnos_refriperu = self.agregar_descuento(datos_filtrados, datos_raw_matriculas) datos_filtrados = self.agregar_continuidad(datos_filtrados, datos_raw_matriculas, datos_historial, alumnos_refriperu) datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas) datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas) # EXTRACCIÓN REAL DE BD Y VALORES PENDIENTES for c in datos_filtrados: c['Retirados'] = int(c.get('Inscritos_Retirados', 0)) # <--- AHORA JALA LA DATA REAL DEL SQL c['Promedio_Cuota'] = 0.0 c['Promedio_Desc_E'] = 0.0 c['Valor_Venta'] = 0.0 c['Opciones'] = "en cu" return datos_filtrados def aplicar_reemplazos_programas(self, datos): reemplazos = self.data_manager.replace_data.get('reemplazos_programas', {}) for curso in datos: prog = curso.get('dsc_programa', '') if prog in reemplazos: curso['dsc_programa'] = reemplazos[prog] return datos def concatenar_programa_frecuencia(self, datos): for c in datos: p, f = c.get('dsc_programa', ''), c.get('cod_frecuencia', '') c['programa_frecuencia'] = f"{p} - {f}" if p and f else p or f or "" return datos def formatear_fecha(self, fecha_str): try: if not fecha_str: return "" if len(str(fecha_str)) == 10 and str(fecha_str)[2] == '-': return str(fecha_str) if '.' in str(fecha_str): return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S.%f').strftime('%d-%m-%Y') return datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S').strftime('%d-%m-%Y') except: return str(fecha_str) def identificar_linea_carrera(self, nombre_programa): categoria = self.obtener_categoria_programa(nombre_programa) if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]: return "CARRERA_COMPATIBLE" return None def parse_fecha(self, fecha_raw): try: if isinstance(fecha_raw, datetime): return fecha_raw if not fecha_raw: return None s_fecha = str(fecha_raw).strip() if len(s_fecha) == 10 and s_fecha[2] == '-': return datetime.strptime(s_fecha, '%d-%m-%Y') if ' ' in s_fecha: return datetime.strptime(s_fecha.split('.')[0], '%Y-%m-%d %H:%M:%S') return datetime.strptime(s_fecha, '%Y-%m-%d') except: return None # Lógica estricta de Refriperu def agregar_descuento(self, datos, datos_raw_matriculas): conteo_por_curso = {} alumnos_refriperu = set() correcciones_descuento = self.data_manager.config_data.get('correcciones_descuento', {}) if datos_raw_matriculas: for alumno in datos_raw_matriculas: try: if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue num_indice = str(alumno.get('num_indice', '')) mat_id = str(alumno.get('num_matricula', '')).strip() if mat_id.endswith('.0'): mat_id = mat_id[:-2] if mat_id in correcciones_descuento: if str(correcciones_descuento[mat_id]).upper().strip() == "SI": conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 alumnos_refriperu.add(mat_id) continue elif str(correcciones_descuento[mat_id]).upper().strip() == "NO": continue nombre_programa = str(alumno.get('dsc_programa', '')) cod_moneda = str(alumno.get('cod_moneda', 'SOL')).strip().upper() try: inv_neta = float(alumno.get('INV_NETA', 0) or 0) imp_tc = float(alumno.get('imp_tc', 0) or 0) if imp_tc <= 0: imp_tc = 3.45 # TC por defecto si no hay comprobante except: inv_neta, imp_tc = 0.0, 3.45 inv_neta_final = round(inv_neta * imp_tc if cod_moneda == 'DOL' else inv_neta, 2) categoria_json = self.obtener_categoria_programa(nombre_programa) es_descuento = False if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_final <= 1700: es_descuento = True elif categoria_json in ["TEAC", "TERC"] and 1400 <= inv_neta_final <= 1900: es_descuento = True if es_descuento: conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1 alumnos_refriperu.add(mat_id) except: continue for curso in datos: curso['Descuento'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0) return datos, alumnos_refriperu # Lógica estricta de Continuidad def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu): conteo_por_curso = {} correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {}) if datos_raw_matriculas and datos_historial: # PRE-ÍNDICE: {dni: [(linea_carrera, fecha), ...]} # Calculamos identificar_linea_carrera UNA sola vez por programa del historial historial_indexado = {} for dni, antecedentes in datos_historial.items(): lineas = [] for ant in antecedentes: linea = self.identificar_linea_carrera(str(ant.get('programa', ''))) fecha = ant.get('fecha') if linea and fecha: lineas.append((linea, fecha)) if lineas: historial_indexado[dni] = lineas alumnos_por_curso = {} for alu in datos_raw_matriculas: idx = str(alu.get('num_indice', '')) if idx not in alumnos_por_curso: alumnos_por_curso[idx] = [] alumnos_por_curso[idx].append(alu) for curso in datos: num_indice = str(curso.get('num_indice', '')) fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio')) linea_actual = self.identificar_linea_carrera(str(curso.get('dsc_programa', ''))) if not linea_actual or not fecha_inicio_actual: continue contador_fieles = 0 for alumno in alumnos_por_curso.get(num_indice, []): if str(alumno.get('estado_matricula', '')).strip().upper() not in ['ALU', 'PRE']: continue mat_id = str(alumno.get('num_matricula', '')).strip() if mat_id.endswith('.0'): mat_id = mat_id[:-2] if mat_id in alumnos_refriperu: continue dni = str(alumno.get('dsc_documento', 'SD')).strip() es_fiel = False if mat_id in correcciones_continuidad: accion = str(correcciones_continuidad[mat_id]).upper().strip() if accion == "SI": es_fiel = True elif accion == "NO": continue if not es_fiel and dni in historial_indexado: for linea_pasada, fecha_pasada in historial_indexado[dni]: if fecha_pasada >= fecha_inicio_actual: continue if (fecha_inicio_actual - fecha_pasada).days < 60: continue if linea_pasada == linea_actual: es_fiel = True break if es_fiel: contador_fieles += 1 conteo_por_curso[num_indice] = contador_fieles for curso in datos: curso['Inscritos_Continuidad'] = conteo_por_curso.get(str(curso.get('num_indice', '')), 0) return datos def agregar_inscritos_mes(self, datos, datos_matriculas): dict_norm = {str(k): v for k, v in datos_matriculas.items()} for curso in datos: curso['Inscritos_Mes'] = dict_norm.get(str(curso.get('num_indice', '')), 0) return datos def agregar_inscritos_pc(self, datos, datos_raw_matriculas): conteo_pc = {} if datos_raw_matriculas: for m in datos_raw_matriculas: try: if str(m.get('estado_matricula', '')).strip() in ['ALU', 'PRE'] and float(m.get('imp_saldo_matricula', 0) or 0) < 1 and float(m.get('imp_saldo_cuota1', 0) or 0) < 1: nid = str(m.get('num_indice', '')) conteo_pc[nid] = conteo_pc.get(nid, 0) + 1 except: continue for curso in datos: curso['Inscritos_PC'] = conteo_pc.get(str(curso.get('num_indice', '')), 0) return datos