Estructura inicial del backend
This commit is contained in:
290
modules/rentabilidad/processor.py
Normal file
290
modules/rentabilidad/processor.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user