Solucionado submodulos y subiendo codigo real
This commit is contained in:
0
backend/modules/__init__.py
Normal file
0
backend/modules/__init__.py
Normal file
0
backend/modules/asesores/__init__.py
Normal file
0
backend/modules/asesores/__init__.py
Normal file
86
backend/modules/asesores/processor.py
Normal file
86
backend/modules/asesores/processor.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# modules/asesores/processor.py
|
||||
import requests
|
||||
import time
|
||||
|
||||
class AsesoresProcessor:
|
||||
def __init__(self):
|
||||
# Tus credenciales maestras de Chatwoot
|
||||
self.chatwoot_url = "https://gestor.escueladerefrigeracion.edu.pe"
|
||||
self.access_token = "4anazHvZnvKLtup8biu5Zuoh"
|
||||
self.account_id = "1"
|
||||
|
||||
def obtener_agentes_chatwoot(self):
|
||||
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents"
|
||||
headers = {
|
||||
"api_access_token": self.access_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
agentes = response.json()
|
||||
|
||||
# =================================================================
|
||||
# 🕵️♂️ ESCÁNER DEBUG (Se imprimirá en tu consola negra)
|
||||
# =================================================================
|
||||
print("\n" + "="*60)
|
||||
print("🕵️♂️ ESCÁNER: REVISANDO 'DESCONEXIÓN AUTOMÁTICA'")
|
||||
print("="*60)
|
||||
for ag in agentes:
|
||||
nombre = ag.get('available_name') or ag.get('name', 'Desconocido')
|
||||
estado = ag.get('availability_status', 'offline')
|
||||
auto_offline = ag.get('auto_offline', 'Desconocido')
|
||||
print(f"👤 {nombre} | Estado: {estado} | Radar: {auto_offline}")
|
||||
print("="*60 + "\n")
|
||||
|
||||
return agentes
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# =========================================================================
|
||||
# MOTOR DE ACTUALIZACIÓN (EL COMBO DE 2 GOLPES)
|
||||
# =========================================================================
|
||||
def cambiar_estado_agente(self, agent_id, is_online):
|
||||
url = f"{self.chatwoot_url}/api/v1/accounts/{self.account_id}/agents/{agent_id}"
|
||||
headers = {
|
||||
"api_access_token": self.access_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
estado_str = "online" if is_online else "offline"
|
||||
|
||||
try:
|
||||
# -----------------------------------------------------------------
|
||||
# GOLPE 1: Desactivar el botón automático primero
|
||||
# -----------------------------------------------------------------
|
||||
payload_radar = {
|
||||
"auto_offline": False
|
||||
}
|
||||
requests.patch(url, headers=headers, json=payload_radar, timeout=10)
|
||||
|
||||
# Le damos 0.5 segundos a la base de datos de Chatwoot para que
|
||||
# asimile que el radar de este usuario acaba de ser destruido.
|
||||
time.sleep(0.5)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# GOLPE 2: El código viejo y confiable para cambiar el estado
|
||||
# -----------------------------------------------------------------
|
||||
payload_estado = {
|
||||
"availability_status": estado_str,
|
||||
"availability": estado_str
|
||||
}
|
||||
response = requests.patch(url, headers=headers, json=payload_estado, timeout=10)
|
||||
|
||||
if response.status_code == 500:
|
||||
return False, "Error interno del servidor Chatwoot."
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# ⏳ Le damos 1.5 segundos para que le avise a la computadora
|
||||
# del asesor que su estado acaba de cambiar en pantalla.
|
||||
time.sleep(1.5)
|
||||
|
||||
return True, "Orden ejecutada con el combo de 2 pasos"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Fallo de red: {str(e)}"
|
||||
0
backend/modules/cobranza/__init__.py
Normal file
0
backend/modules/cobranza/__init__.py
Normal file
221
backend/modules/cobranza/logic.py
Normal file
221
backend/modules/cobranza/logic.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# modules/cobranza/logic.py
|
||||
import pandas as pd
|
||||
from .processor import CobranzaProcessor
|
||||
|
||||
class CobranzaLogic:
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self.processor = CobranzaProcessor(data_manager)
|
||||
|
||||
def obtener_lista_sectoristas(self, ano, mes):
|
||||
return self.processor.obtener_lista_sectoristas(ano, mes)
|
||||
|
||||
def obtener_datos_tabla(self, ano, mes, sectorista="TODOS", agrupacion="SEDE"):
|
||||
datos_brutos = self.processor.obtener_datos_procesados(ano, mes, sectorista, agrupacion)
|
||||
|
||||
sheet_data = []
|
||||
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||
|
||||
for d in datos_brutos:
|
||||
grupo = d.get('GRUPO', '')
|
||||
frecuencia = d.get('FRECUENCIA', '-')
|
||||
num_cuota = d.get('NUM_CUOTA', '-')
|
||||
fecha_venc = d.get('FCH_VENC_MODA', '-')
|
||||
|
||||
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||
r1 = float(d.get('RATIO_1', 0.0))
|
||||
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||
r2 = float(d.get('RATIO_2', 0.0))
|
||||
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||
r3 = float(d.get('RATIO_3', 0.0))
|
||||
saldo = float(d.get('SALDO', 0.0))
|
||||
opciones = d.get('OPCIONES', ' ≡ ▼ ')
|
||||
|
||||
t_cta_ant += cta_ant
|
||||
t_cta_cur += cta_cur
|
||||
t_cta_tot += cta_tot
|
||||
t_cob_ant += cob_ant
|
||||
t_cob_cur += cob_cur
|
||||
t_cob_tot += cob_tot
|
||||
t_saldo += saldo
|
||||
|
||||
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
fila = [
|
||||
grupo, frecuencia, num_cuota, fecha_venc,
|
||||
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||
f"S/ {saldo:,.0f}", opciones
|
||||
]
|
||||
else:
|
||||
fila = [
|
||||
grupo,
|
||||
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||
f"S/ {saldo:,.0f}", opciones
|
||||
]
|
||||
sheet_data.append(fila)
|
||||
|
||||
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
fila_total = [
|
||||
"TOTAL GENERAL", "-", "-", "-",
|
||||
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||
f"S/ {t_saldo:,.0f}", ""
|
||||
]
|
||||
else:
|
||||
fila_total = [
|
||||
"TOTAL GENERAL",
|
||||
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||
f"S/ {t_saldo:,.0f}", ""
|
||||
]
|
||||
sheet_data.append(fila_total)
|
||||
return sheet_data
|
||||
|
||||
def obtener_detalle_programa_formateado(self, ano, mes, sectorista, programa):
|
||||
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, programa)
|
||||
|
||||
sheet_data = []
|
||||
t_cta_ant = t_cta_cur = t_cta_tot = t_cob_ant = t_cob_cur = t_cob_tot = t_saldo = 0.0
|
||||
|
||||
for d in datos_brutos:
|
||||
mat = d.get('MATRICULA', '')
|
||||
alumno = d.get('ALUMNO', '')
|
||||
num_cuota = d.get('NUM_CUOTA', '-')
|
||||
fch_venc = d.get('FCH_VENC', '-')
|
||||
|
||||
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||
saldo = float(d.get('SALDO', 0.0))
|
||||
|
||||
t_cta_ant += cta_ant; t_cta_cur += cta_cur; t_cta_tot += cta_tot
|
||||
t_cob_ant += cob_ant; t_cob_cur += cob_cur; t_cob_tot += cob_tot; t_saldo += saldo
|
||||
|
||||
r1 = (cob_ant / cta_ant) * 100 if cta_ant > 0 else 0.0
|
||||
r2 = (cob_cur / cta_cur) * 100 if cta_cur > 0 else 0.0
|
||||
r3 = (cob_tot / cta_tot) * 100 if cta_tot > 0 else 0.0
|
||||
|
||||
avance_ant = f"{r1:.0f}%" if cta_ant > 0 else ""
|
||||
avance_cur = f"{r2:.0f}%" if cta_cur > 0 else ""
|
||||
avance_tot = f"{r3:.0f}%" if cta_tot > 0 else ""
|
||||
|
||||
fila = [
|
||||
mat, alumno, num_cuota, fch_venc,
|
||||
f"S/ {cta_ant:,.0f}", f"S/ {cob_ant:,.0f}", avance_ant,
|
||||
f"S/ {cta_cur:,.0f}", f"S/ {cob_cur:,.0f}", avance_cur,
|
||||
f"S/ {cta_tot:,.0f}", f"S/ {cob_tot:,.0f}", avance_tot,
|
||||
f"S/ {saldo:,.0f}"
|
||||
]
|
||||
sheet_data.append(fila)
|
||||
|
||||
r_tot_1 = (t_cob_ant / t_cta_ant) * 100 if t_cta_ant > 0 else 0.0
|
||||
r_tot_2 = (t_cob_cur / t_cta_cur) * 100 if t_cta_cur > 0 else 0.0
|
||||
r_tot_3 = (t_cob_tot / t_cta_tot) * 100 if t_cta_tot > 0 else 0.0
|
||||
|
||||
fila_total = [
|
||||
"TOTAL", "GENERAL", "-", "-",
|
||||
f"S/ {t_cta_ant:,.0f}", f"S/ {t_cob_ant:,.0f}", f"{r_tot_1:.0f}%",
|
||||
f"S/ {t_cta_cur:,.0f}", f"S/ {t_cob_cur:,.0f}", f"{r_tot_2:.0f}%",
|
||||
f"S/ {t_cta_tot:,.0f}", f"S/ {t_cob_tot:,.0f}", f"{r_tot_3:.0f}%",
|
||||
f"S/ {t_saldo:,.0f}"
|
||||
]
|
||||
sheet_data.append(fila_total)
|
||||
return sheet_data
|
||||
|
||||
def exportar_detalle_excel(self, programa, headers, datos):
|
||||
if not datos: raise ValueError("No hay datos para exportar")
|
||||
|
||||
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:30]
|
||||
archivo = f"Detalle_Cobranza_{prog_limpio}.xlsx"
|
||||
|
||||
df = pd.DataFrame(datos, columns=[h.replace('\n', ' ') for h in headers])
|
||||
try:
|
||||
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='Detalle')
|
||||
except:
|
||||
df.to_excel(archivo, index=False)
|
||||
return archivo
|
||||
|
||||
def exportar_reporte_global(self, ano, mes, sectorista):
|
||||
datos_brutos = self.processor.obtener_detalle_programa(ano, mes, sectorista, "TODOS")
|
||||
if not datos_brutos: raise ValueError("No hay datos para exportar en este mes.")
|
||||
|
||||
filas_excel = []
|
||||
for d in datos_brutos:
|
||||
cta_ant = float(d.get('CTA_COB_ANT', 0.0))
|
||||
cob_ant = float(d.get('COB_ANT', 0.0))
|
||||
cta_cur = float(d.get('CTA_COB_MES_CURSO', 0.0))
|
||||
cob_cur = float(d.get('COB_MES_CURSO', 0.0))
|
||||
cta_tot = float(d.get('TOTAL_CTA_COB', 0.0))
|
||||
cob_tot = float(d.get('TOTAL_COBRADO', 0.0))
|
||||
|
||||
filas_excel.append({
|
||||
"ALUMNO": d.get('ALUMNO', ''),
|
||||
"PROGRAMA": d.get('PROGRAMA', '-'),
|
||||
"FRECUENCIA": d.get('FRECUENCIA', '-'),
|
||||
"NUM CUOTA": d.get('NUM_CUOTA', '-'),
|
||||
"FCH VENCIMIENTO": d.get('FCH_VENC', '-'),
|
||||
"CUENTA PENDIENTE": cta_ant,
|
||||
"COBRADO PENDIENTE": cob_ant,
|
||||
"AVANCE % ": (cob_ant / cta_ant) if cta_ant > 0 else 0.0,
|
||||
"CUENTA EN CURSO": cta_cur,
|
||||
"COBRADO EN CURSO": cob_cur,
|
||||
"AVANCE % ": (cob_cur / cta_cur) if cta_cur > 0 else 0.0,
|
||||
"TOTAL CUENTA": cta_tot,
|
||||
"TOTAL COBRADO": cob_tot,
|
||||
"AVANCE %": (cob_tot / cta_tot) if cta_tot > 0 else 0.0,
|
||||
"SALDO": float(d.get('SALDO', 0.0))
|
||||
})
|
||||
|
||||
df = pd.DataFrame(filas_excel)
|
||||
archivo = f"Reporte_Cobranza_Global_{ano}_{mes}.xlsx"
|
||||
|
||||
try:
|
||||
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='Reporte Global')
|
||||
worksheet = writer.sheets['Reporte Global']
|
||||
from openpyxl.styles import PatternFill, Font, Alignment
|
||||
|
||||
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||
font_header = Font(color="FFFFFF", bold=True)
|
||||
for cell in worksheet[1]:
|
||||
cell.fill = fill_header
|
||||
cell.font = font_header
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
for row in range(2, len(filas_excel) + 2):
|
||||
for col in [6, 7, 9, 10, 12, 13, 15]:
|
||||
worksheet.cell(row=row, column=col).number_format = '"S/" #,##0.00'
|
||||
for col in [8, 11, 14]:
|
||||
worksheet.cell(row=row, column=col).number_format = '0%'
|
||||
|
||||
for col in worksheet.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
worksheet.column_dimensions[col_letter].width = min(max_len + 2, 40)
|
||||
except Exception:
|
||||
df.to_excel(archivo, index=False)
|
||||
|
||||
return archivo
|
||||
734
backend/modules/cobranza/processor.py
Normal file
734
backend/modules/cobranza/processor.py
Normal file
@@ -0,0 +1,734 @@
|
||||
# modules/cobranza/processor.py
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import unicodedata
|
||||
|
||||
class CobranzaProcessor:
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self.config_sedes = self._cargar_json_sedes()
|
||||
# ── Caché de consultas SQL ────────────────────────────────────────
|
||||
self._cache_cronograma = None
|
||||
self._cache_facturas = None
|
||||
self._cache_ano = None
|
||||
|
||||
def _obtener_datos_cached(self, ano):
|
||||
"""Devuelve cronograma y facturas usando caché si el año coincide."""
|
||||
if self._cache_ano == ano and self._cache_cronograma is not None:
|
||||
print(f"[CACHE] Reutilizando datos en caché para año {ano}")
|
||||
return self._cache_cronograma, self._cache_facturas
|
||||
print(f"[CACHE] Consultando BD para año {ano}...")
|
||||
cronograma = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||
facturas = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||
self._cache_cronograma = cronograma
|
||||
self._cache_facturas = facturas
|
||||
self._cache_ano = ano
|
||||
return cronograma, facturas
|
||||
|
||||
def invalidar_cache(self):
|
||||
"""Fuerza recarga desde BD en la siguiente consulta."""
|
||||
self._cache_cronograma = None
|
||||
self._cache_facturas = None
|
||||
self._cache_ano = None
|
||||
print("🧹 [CACHE] Memoria limpiada por actualización automática. La próxima consulta irá directo a la BD.")
|
||||
|
||||
# =========================================================================
|
||||
# CARGA DE CONFIGURACIÓN (sede.json)
|
||||
# =========================================================================
|
||||
def _cargar_json_sedes(self):
|
||||
datos = None
|
||||
try:
|
||||
url = getattr(self.data_manager, 'github_sede_url', None)
|
||||
if url:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
datos = response.json()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if datos is None:
|
||||
datos = {
|
||||
"clasificacion_sedes": {
|
||||
"AREQUIPA": {"patrones": ["AREQUIPA"]},
|
||||
"PIURA": {"patrones": ["PIURA"]},
|
||||
"TRUJILLO": {"patrones": ["TRUJILLO"]}
|
||||
},
|
||||
"clasificacion_default": {"sede": "LIMA"}
|
||||
}
|
||||
|
||||
self._reemplazos_nombre = datos.get("reemplazos_nombre", [])
|
||||
return datos
|
||||
|
||||
# =========================================================================
|
||||
# REEMPLAZOS DE NOMBRE DE PROGRAMA
|
||||
# =========================================================================
|
||||
@staticmethod
|
||||
def _normalizar(texto):
|
||||
texto = unicodedata.normalize('NFD', str(texto))
|
||||
return texto.encode('ascii', 'ignore').decode('utf-8')
|
||||
|
||||
def _aplicar_reemplazos_nombre(self, texto):
|
||||
if not texto or not self._reemplazos_nombre:
|
||||
return texto
|
||||
|
||||
resultado = texto
|
||||
for entrada in self._reemplazos_nombre:
|
||||
buscar = str(entrada.get("buscar", "")).strip()
|
||||
reemplazar = str(entrada.get("reemplazar", "")).strip()
|
||||
if not buscar:
|
||||
continue
|
||||
|
||||
buscar_norm = self._normalizar(buscar).upper()
|
||||
idx = self._normalizar(resultado).upper().find(buscar_norm)
|
||||
while idx != -1:
|
||||
resultado = resultado[:idx] + reemplazar + resultado[idx + len(buscar):]
|
||||
idx = self._normalizar(resultado).upper().find(buscar_norm, idx + len(reemplazar))
|
||||
|
||||
return resultado
|
||||
|
||||
# =========================================================================
|
||||
# CLASIFICACIÓN DE SEDE
|
||||
# =========================================================================
|
||||
def clasificar_sede(self, dsc_programa):
|
||||
if not dsc_programa:
|
||||
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
sedes = self.config_sedes.get("clasificacion_sedes", {})
|
||||
|
||||
for nombre_sede, datos in sedes.items():
|
||||
for patron in datos.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper:
|
||||
return nombre_sede
|
||||
|
||||
return self.config_sedes.get("clasificacion_default", {}).get("sede", "LIMA")
|
||||
|
||||
# =========================================================================
|
||||
# UTILIDADES
|
||||
# =========================================================================
|
||||
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
|
||||
|
||||
def obtener_fechas_filtro(self, ano, mes_numero):
|
||||
ano_int = int(ano)
|
||||
mes_int = int(mes_numero)
|
||||
fecha_filtro = datetime(ano_int, mes_int, 1)
|
||||
if mes_int == 12:
|
||||
fecha_filtro_siguiente = datetime(ano_int + 1, 1, 1)
|
||||
else:
|
||||
fecha_filtro_siguiente = datetime(ano_int, mes_int + 1, 1)
|
||||
return fecha_filtro, fecha_filtro_siguiente
|
||||
|
||||
def calcular_importe_final(self, fila):
|
||||
try: imp_total = float(fila.get('imp_total', 0) or 0)
|
||||
except: imp_total = 0.0
|
||||
try: imp_dscto = float(fila.get('imp_dscto', 0) or 0)
|
||||
except: imp_dscto = 0.0
|
||||
return imp_total - imp_dscto
|
||||
|
||||
def evaluar_ctaxcob_suma(self, fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats):
|
||||
sectorista = str(fila.get('dsc_sectorista', ''))
|
||||
if sectorista == " , " or sectorista.strip() == ",":
|
||||
return 0.0, 0.0
|
||||
|
||||
try: num_cuota = int(fila.get('num_cuota', 0))
|
||||
except: num_cuota = 0
|
||||
if not (num_cuota > 1):
|
||||
debug_stats['falla_cuota'] += 1
|
||||
return 0.0, 0.0
|
||||
|
||||
fch_vencimiento = self.parse_fecha(fila.get('fch_vencimiento'))
|
||||
if not fch_vencimiento:
|
||||
debug_stats['falla_vencimiento'] += 1
|
||||
return 0.0, 0.0
|
||||
|
||||
es_anterior = fch_vencimiento < fecha_filtro
|
||||
es_curso = (fch_vencimiento >= fecha_filtro) and (fch_vencimiento < fecha_filtro_siguiente)
|
||||
|
||||
if not (es_anterior or es_curso):
|
||||
debug_stats['falla_vencimiento'] += 1
|
||||
return 0.0, 0.0
|
||||
|
||||
fch_inicio = self.parse_fecha(fila.get('fch_inicio'))
|
||||
if not fch_inicio or not (fch_inicio <= hoy):
|
||||
debug_stats['falla_inicio'] += 1
|
||||
return 0.0, 0.0
|
||||
|
||||
estado = str(fila.get('estado_matricula', '')).strip().upper()
|
||||
if estado in ["ALU", "PRE"]:
|
||||
pass
|
||||
elif estado == "RET":
|
||||
fch_retiro = self.parse_fecha(fila.get('fch_retiro'))
|
||||
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||
debug_stats['falla_estado'] += 1
|
||||
return 0.0, 0.0
|
||||
else:
|
||||
debug_stats['falla_estado'] += 1
|
||||
return 0.0, 0.0
|
||||
|
||||
importe_convertido = importe_final
|
||||
moneda = str(fila.get('cod_moneda', '')).strip().upper()
|
||||
if moneda == 'DOL':
|
||||
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||
except: tc = 1.0
|
||||
importe_convertido *= tc
|
||||
|
||||
debug_stats['pasa_todo'] += 1
|
||||
|
||||
if es_anterior: return importe_convertido, 0.0
|
||||
if es_curso: return 0.0, importe_convertido
|
||||
return 0.0, 0.0
|
||||
|
||||
def evaluar_ctaxcob_resta(self, fila_factura, fecha_filtro, fecha_filtro_siguiente, hoy, debug_stats, tc_correcto):
|
||||
sectorista = str(fila_factura.get('dsc_sectorista', ''))
|
||||
if sectorista == " , " or sectorista.strip() == ",":
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
try: num_cuota = int(fila_factura.get('num_cuota', 0))
|
||||
except: num_cuota = 0
|
||||
if not (num_cuota > 1):
|
||||
debug_stats['falla_cuota'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
cod_estado = str(fila_factura.get('cod_estado', '')).strip().upper()
|
||||
if cod_estado != "CAN":
|
||||
debug_stats['falla_estado_can'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
flg_nc = str(fila_factura.get('flg_nc', '')).strip().upper()
|
||||
if flg_nc == "SI":
|
||||
debug_stats['falla_nc'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
fch_venc_cuota = self.parse_fecha(fila_factura.get('fch_vencimiento_cuota'))
|
||||
if not fch_venc_cuota:
|
||||
debug_stats['falla_venc_cuota'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
es_anterior = fch_venc_cuota < fecha_filtro
|
||||
es_curso = (fch_venc_cuota >= fecha_filtro) and (fch_venc_cuota < fecha_filtro_siguiente)
|
||||
|
||||
if not (es_anterior or es_curso):
|
||||
debug_stats['falla_venc_cuota'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
fch_cancelacion = self.parse_fecha(fila_factura.get('fch_cancelacion'))
|
||||
if not fch_cancelacion:
|
||||
debug_stats['falla_cancelacion'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
pasa_anterior = es_anterior and (fch_cancelacion < fecha_filtro)
|
||||
pasa_curso = es_curso and (fch_cancelacion < fecha_filtro)
|
||||
pasa_cobrado_ant = es_anterior and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||
pasa_cobrado_curso = es_curso and (fch_cancelacion >= fecha_filtro) and (fch_cancelacion < fecha_filtro_siguiente)
|
||||
|
||||
if not (pasa_anterior or pasa_curso or pasa_cobrado_ant or pasa_cobrado_curso):
|
||||
debug_stats['falla_cancelacion'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
fch_inicio = self.parse_fecha(fila_factura.get('fch_inicio'))
|
||||
if not fch_inicio or not (fch_inicio <= hoy):
|
||||
debug_stats['falla_inicio'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
estado_mat = str(fila_factura.get('estado_matricula', '')).strip().upper()
|
||||
if estado_mat in ["ALU", "PRE"]: pass
|
||||
elif estado_mat == "RET":
|
||||
fch_retiro = self.parse_fecha(fila_factura.get('fch_retiro'))
|
||||
if not fch_retiro or not (fch_retiro >= fecha_filtro_siguiente):
|
||||
debug_stats['falla_estado_mat'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
else:
|
||||
debug_stats['falla_estado_mat'] += 1
|
||||
return 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
try: imp_emitido = float(fila_factura.get('imp_emitido', 0) or 0)
|
||||
except: imp_emitido = 0.0
|
||||
|
||||
imp_emitido_convertido = imp_emitido
|
||||
moneda = str(fila_factura.get('cod_moneda', '')).strip().upper()
|
||||
if moneda == 'DOL':
|
||||
imp_emitido_convertido *= tc_correcto
|
||||
|
||||
debug_stats['pasa_todo'] += 1
|
||||
|
||||
val_resta_ant = imp_emitido_convertido if pasa_anterior else 0.0
|
||||
val_resta_cur = imp_emitido_convertido if pasa_curso else 0.0
|
||||
val_cobrado_ant = imp_emitido_convertido if pasa_cobrado_ant else 0.0
|
||||
val_cobrado_curso = imp_emitido_convertido if pasa_cobrado_curso else 0.0
|
||||
|
||||
return val_resta_ant, val_resta_cur, val_cobrado_ant, val_cobrado_curso
|
||||
|
||||
# =========================================================================
|
||||
# LISTA DE SECTORISTAS
|
||||
# =========================================================================
|
||||
def obtener_lista_sectoristas(self, ano, mes):
|
||||
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||
hoy = datetime.now()
|
||||
|
||||
try:
|
||||
cronograma_raw = self.data_manager.ejecutar_consulta_cronograma_cobranza(ano)
|
||||
facturas_raw = self.data_manager.ejecutar_consulta_facturas_cobranza(ano)
|
||||
except Exception: return ["TODOS"]
|
||||
|
||||
sectoristas_neto = {}
|
||||
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||
|
||||
indices_cronograma = set()
|
||||
mapa_tc = {}
|
||||
mapa_sectorista = {}
|
||||
|
||||
for fila in cronograma_raw:
|
||||
sec_str = str(fila.get('dsc_sectorista', ''))
|
||||
sec_clean = sec_str.strip()
|
||||
if sec_clean in ["", ",", "None"] or sec_str == " , ": continue
|
||||
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx:
|
||||
indices_cronograma.add(idx)
|
||||
mapa_sectorista[idx] = sec_clean
|
||||
|
||||
mat = str(fila.get('num_matricula', '')).strip()
|
||||
if mat and mat not in mapa_tc:
|
||||
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||
except: tc = 1.0
|
||||
mapa_tc[mat] = tc
|
||||
|
||||
for fila in cronograma_raw:
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx not in indices_cronograma: continue
|
||||
|
||||
sec_clean = mapa_sectorista[idx]
|
||||
importe_final = self.calcular_importe_final(fila)
|
||||
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||
|
||||
if sec_clean not in sectoristas_neto: sectoristas_neto[sec_clean] = 0.0
|
||||
sectoristas_neto[sec_clean] += (suma_ant + suma_cur)
|
||||
|
||||
for fila in facturas_raw:
|
||||
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||
if idx_factura not in indices_cronograma: continue
|
||||
|
||||
sec_clean = mapa_sectorista.get(idx_factura, "")
|
||||
if not sec_clean: continue
|
||||
|
||||
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||
|
||||
resta_ant, resta_cur, _, _ = self.evaluar_ctaxcob_resta(
|
||||
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||
)
|
||||
|
||||
if sec_clean in sectoristas_neto: sectoristas_neto[sec_clean] -= (resta_ant + resta_cur)
|
||||
|
||||
lista_final = []
|
||||
for sec, total_cta_x_cob in sectoristas_neto.items():
|
||||
if round(total_cta_x_cob, 2) > 0: lista_final.append(sec)
|
||||
|
||||
lista_final = sorted(lista_final)
|
||||
lista_final.insert(0, "TODOS")
|
||||
return lista_final
|
||||
|
||||
# =========================================================================
|
||||
# DATOS PROCESADOS (TABLA PRINCIPAL CON VISTA DE ASESORES)
|
||||
# =========================================================================
|
||||
|
||||
def obtener_datos_procesados(self, ano, mes, sectorista_filtro="TODOS", agrupacion="SEDE"):
|
||||
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||
hoy = datetime.now()
|
||||
datos_agrupados = {}
|
||||
|
||||
frecuencias_cuota = {}
|
||||
frecuencias_fecha_venc = {}
|
||||
mapa_frecuencia = {}
|
||||
|
||||
alumnos_tracking = {}
|
||||
|
||||
try:
|
||||
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||
except Exception as e:
|
||||
print(f"Error crítico: {e}")
|
||||
return []
|
||||
|
||||
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||
|
||||
indices_cronograma = set()
|
||||
mapa_tc = {}
|
||||
mapa_programa = {}
|
||||
mapa_sectorista = {} # <-- NUEVO MAPA PARA CORREGIR ASESORES
|
||||
|
||||
for fila in cronograma_raw:
|
||||
sectorista_str = str(fila.get('dsc_sectorista', ''))
|
||||
sectorista_clean = sectorista_str.strip()
|
||||
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx:
|
||||
# Guardamos el asesor real del cronograma para usarlo en las facturas
|
||||
mapa_sectorista[idx] = sectorista_clean if sectorista_clean and sectorista_clean not in [",", "None"] else "SIN ASESOR"
|
||||
|
||||
if sectorista_clean in [",", "", "None"] or sectorista_str == " , ": continue
|
||||
if sectorista_filtro != "TODOS" and sectorista_clean != sectorista_filtro: continue
|
||||
|
||||
if idx: indices_cronograma.add(idx)
|
||||
|
||||
mat = str(fila.get('num_matricula', '')).strip()
|
||||
if mat:
|
||||
if mat not in mapa_tc:
|
||||
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||
except: tc = 1.0
|
||||
mapa_tc[mat] = tc
|
||||
|
||||
if mat not in mapa_programa:
|
||||
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||
mapa_programa[mat] = prog if (prog and prog != "None") else "SIN PROGRAMA"
|
||||
|
||||
for fila in cronograma_raw:
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx not in indices_cronograma: continue
|
||||
|
||||
mat = str(fila.get('num_matricula', '')).strip()
|
||||
|
||||
if agrupacion == "SEDE":
|
||||
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||
elif agrupacion == "ASESOR":
|
||||
llave = mapa_sectorista.get(idx, "SIN ASESOR")
|
||||
else:
|
||||
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||
|
||||
if llave not in datos_agrupados:
|
||||
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||
|
||||
importe_final = self.calcular_importe_final(fila)
|
||||
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||
|
||||
datos_agrupados[llave]["Suma_Ant"] += suma_ant
|
||||
datos_agrupados[llave]["Suma_Cur"] += suma_cur
|
||||
|
||||
clave_mat = (mat, llave)
|
||||
if clave_mat not in alumnos_tracking:
|
||||
alumnos_tracking[clave_mat] = {"suma": 0.0, "resta": 0.0, "cobrado": 0.0, "cuota_final": None, "fecha_final": None}
|
||||
|
||||
alumnos_tracking[clave_mat]["suma"] += (suma_ant + suma_cur)
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
if llave not in mapa_frecuencia:
|
||||
frec_val = str(fila.get('cod_frecuencia', '-')).strip()
|
||||
mapa_frecuencia[llave] = frec_val if frec_val and frec_val != 'None' and frec_val != '' else '-'
|
||||
|
||||
fch_venc_raw = fila.get('fch_vencimiento')
|
||||
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||
try:
|
||||
n_cuota = int(fila.get('num_cuota', -1))
|
||||
if n_cuota >= 0: alumnos_tracking[clave_mat]["cuota_final"] = n_cuota
|
||||
except: pass
|
||||
try:
|
||||
alumnos_tracking[clave_mat]["fecha_final"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||
except: pass
|
||||
|
||||
for fila in facturas_raw:
|
||||
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||
if idx_factura not in indices_cronograma: continue
|
||||
|
||||
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||
|
||||
if agrupacion == "SEDE":
|
||||
llave = self.clasificar_sede(fila.get('dsc_programa', ''))
|
||||
elif agrupacion == "ASESOR":
|
||||
# AQUÍ ESTÁ LA MAGIA: usa el asesor guardado desde el cronograma
|
||||
llave = mapa_sectorista.get(idx_factura, "SIN ASESOR")
|
||||
else:
|
||||
llave = mapa_programa.get(mat_factura, "")
|
||||
if not llave:
|
||||
llave = str(fila.get('dsc_det_programa', '')).strip()
|
||||
if not llave or llave == "None": llave = "SIN PROGRAMA"
|
||||
|
||||
if llave not in datos_agrupados:
|
||||
datos_agrupados[llave] = {"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0}
|
||||
|
||||
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||
)
|
||||
|
||||
datos_agrupados[llave]["Resta_Ant"] += resta_ant
|
||||
datos_agrupados[llave]["Resta_Cur"] += resta_cur
|
||||
datos_agrupados[llave]["Cob_Ant"] += cobrado_ant
|
||||
datos_agrupados[llave]["Cob_Cur"] += cobrado_curso
|
||||
|
||||
clave_mat = (mat_factura, llave)
|
||||
if clave_mat in alumnos_tracking:
|
||||
alumnos_tracking[clave_mat]["resta"] += (resta_ant + resta_cur)
|
||||
alumnos_tracking[clave_mat]["cobrado"] += (cobrado_ant + cobrado_curso)
|
||||
|
||||
for (mat, llave), track in alumnos_tracking.items():
|
||||
saldo_alumno = (track["suma"] - track["resta"]) - track["cobrado"]
|
||||
if round(saldo_alumno, 2) > 0:
|
||||
c = track["cuota_final"]
|
||||
f = track["fecha_final"]
|
||||
|
||||
if c is not None:
|
||||
if llave not in frecuencias_cuota: frecuencias_cuota[llave] = {}
|
||||
frecuencias_cuota[llave][c] = frecuencias_cuota[llave].get(c, 0) + 1
|
||||
if f is not None:
|
||||
if llave not in frecuencias_fecha_venc: frecuencias_fecha_venc[llave] = {}
|
||||
frecuencias_fecha_venc[llave][f] = frecuencias_fecha_venc[llave].get(f, 0) + 1
|
||||
|
||||
datos_limpios = []
|
||||
llaves_ordenadas = sorted(datos_agrupados.keys())
|
||||
|
||||
asesores_validos = []
|
||||
if agrupacion == "ASESOR":
|
||||
asesores_validos = [s for s in self.obtener_lista_sectoristas(ano, mes) if s != "TODOS"]
|
||||
|
||||
if agrupacion == "SEDE":
|
||||
orden_sedes = ["LIMA", "AREQUIPA", "TRUJILLO", "PIURA"]
|
||||
llaves_ordenadas = [s for s in orden_sedes if s in datos_agrupados] + [s for s in llaves_ordenadas if s not in orden_sedes]
|
||||
|
||||
for llave in llaves_ordenadas:
|
||||
if agrupacion == "ASESOR" and llave not in asesores_validos:
|
||||
continue
|
||||
|
||||
totales = datos_agrupados[llave]
|
||||
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||
total_cta = cta_cob_ant + cta_cob_curso
|
||||
|
||||
cobrado_meses_ant = totales["Cob_Ant"]
|
||||
cobrado_mes_curso = totales["Cob_Cur"]
|
||||
total_cobrado_sede = cobrado_meses_ant + cobrado_mes_curso
|
||||
|
||||
if round(total_cta, 2) <= 0 and round(total_cobrado_sede, 2) <= 0: continue
|
||||
|
||||
ratio_1 = (cobrado_meses_ant / cta_cob_ant) * 100 if cta_cob_ant > 0 else 0.0
|
||||
ratio_2 = (cobrado_mes_curso / cta_cob_curso) * 100 if cta_cob_curso > 0 else 0.0
|
||||
ratio_3 = (total_cobrado_sede / total_cta) * 100 if total_cta > 0 else 0.0
|
||||
saldo_sede = total_cta - total_cobrado_sede
|
||||
|
||||
frec_str = "-"
|
||||
moda_cuota = "-"
|
||||
moda_fecha = "-"
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
frec_str = mapa_frecuencia.get(llave, "-")
|
||||
if llave in frecuencias_cuota and frecuencias_cuota[llave]:
|
||||
moda_cuota = str(max(frecuencias_cuota[llave], key=frecuencias_cuota[llave].get))
|
||||
if llave in frecuencias_fecha_venc and frecuencias_fecha_venc[llave]:
|
||||
moda_fecha = str(max(frecuencias_fecha_venc[llave], key=frecuencias_fecha_venc[llave].get))
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
grupo_display = self._aplicar_reemplazos_nombre(llave)
|
||||
else:
|
||||
grupo_display = llave
|
||||
|
||||
datos_limpios.append({
|
||||
'GRUPO': grupo_display,
|
||||
'GRUPO_ORIGINAL': llave,
|
||||
'FRECUENCIA': frec_str,
|
||||
'NUM_CUOTA': moda_cuota,
|
||||
'FCH_VENC_MODA': moda_fecha,
|
||||
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||
'COB_ANT': cobrado_meses_ant, 'RATIO_1': ratio_1, 'COB_MES_CURSO': cobrado_mes_curso, 'RATIO_2': ratio_2,
|
||||
'TOTAL_COBRADO': total_cobrado_sede, 'RATIO_3': ratio_3, 'SALDO': saldo_sede, 'OPCIONES': " ≡ ▼ "
|
||||
})
|
||||
|
||||
if agrupacion == "PROGRAMA":
|
||||
def orden_personalizado_programa(x):
|
||||
fecha_str = x['FCH_VENC_MODA']
|
||||
if fecha_str == "-":
|
||||
return (0, datetime.min, x['GRUPO'])
|
||||
else:
|
||||
try:
|
||||
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||
return (1, dt, x['GRUPO'])
|
||||
except:
|
||||
return (1, datetime.max, x['GRUPO'])
|
||||
|
||||
return sorted(datos_limpios, key=orden_personalizado_programa)
|
||||
|
||||
return datos_limpios
|
||||
|
||||
# =========================================================================
|
||||
# DESGLOSE POR ALUMNO/MATRÍCULA
|
||||
# =========================================================================
|
||||
def obtener_detalle_programa(self, ano, mes, sectorista_filtro, programa_nombre):
|
||||
fecha_filtro, fecha_filtro_siguiente = self.obtener_fechas_filtro(ano, mes)
|
||||
hoy = datetime.now()
|
||||
|
||||
try:
|
||||
cronograma_raw, facturas_raw = self._obtener_datos_cached(ano)
|
||||
except Exception: return []
|
||||
|
||||
stats_suma = {'falla_cuota': 0, 'falla_vencimiento': 0, 'falla_inicio': 0, 'falla_estado': 0, 'pasa_todo': 0}
|
||||
stats_resta = {'falla_cuota': 0, 'falla_estado_can': 0, 'falla_nc': 0, 'falla_cancelacion': 0, 'falla_venc_cuota': 0, 'falla_inicio': 0, 'falla_estado_mat': 0, 'falla_treatas': 0, 'pasa_todo': 0}
|
||||
|
||||
indices_cronograma = set()
|
||||
mat_validas = set()
|
||||
mapa_tc = {}
|
||||
mapa_programa_frec = {}
|
||||
|
||||
for fila in cronograma_raw:
|
||||
sectorista_str = str(fila.get('dsc_sectorista', '')).strip()
|
||||
if sectorista_str in [",", ""] or fila.get('dsc_sectorista') == " , ": continue
|
||||
if sectorista_filtro != "TODOS" and sectorista_str != sectorista_filtro: continue
|
||||
|
||||
prog = str(fila.get('dsc_det_programa', '')).strip()
|
||||
if not prog or prog == "None": prog = "SIN PROGRAMA"
|
||||
|
||||
if programa_nombre != "TODOS":
|
||||
if self._aplicar_reemplazos_nombre(prog) != programa_nombre: continue
|
||||
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx: indices_cronograma.add(idx)
|
||||
|
||||
mat = str(fila.get('num_matricula', '')).strip()
|
||||
if mat:
|
||||
mat_validas.add(mat)
|
||||
if mat not in mapa_tc:
|
||||
try: tc = float(fila.get('imp_tc', 1.0) or 1.0)
|
||||
except: tc = 1.0
|
||||
mapa_tc[mat] = tc
|
||||
if mat not in mapa_programa_frec:
|
||||
prog_bd = str(fila.get('dsc_det_programa', '')).strip()
|
||||
frec_bd = str(fila.get('cod_frecuencia', '')).strip()
|
||||
mapa_programa_frec[mat] = {
|
||||
"prog": prog_bd if prog_bd and prog_bd != "None" else "-",
|
||||
"frec": frec_bd if frec_bd and frec_bd != "None" else "-"
|
||||
}
|
||||
|
||||
mapa_alumnos = {}
|
||||
if mat_validas:
|
||||
try:
|
||||
conn = self.data_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
lista_mats = list(mat_validas)
|
||||
|
||||
for i in range(0, len(lista_mats), 1000):
|
||||
chunk = lista_mats[i:i+1000]
|
||||
chunk_str = ",".join([f"'{m}'" for m in chunk])
|
||||
|
||||
sql = f"""
|
||||
SELECT sgeca_matricula.num_matricula,
|
||||
sgema_alumno.dsc_apellido_paterno + ' ' + sgema_alumno.dsc_apellido_materno + ', ' + sgema_alumno.dsc_nombres AS dsc_alumno
|
||||
FROM sgeca_matricula
|
||||
INNER JOIN sgema_alumno ON sgeca_matricula.cod_alumno = sgema_alumno.cod_alumno
|
||||
WHERE sgeca_matricula.num_matricula IN ({chunk_str})
|
||||
"""
|
||||
cursor.execute(sql)
|
||||
for row in cursor.fetchall():
|
||||
num_mat_bd = str(row[0]).strip()
|
||||
nombre_bd = str(row[1]).strip()
|
||||
mapa_alumnos[num_mat_bd] = nombre_bd
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"Error consultando nombres de alumnos en BD: {e}")
|
||||
|
||||
datos_agrupados = {}
|
||||
|
||||
for fila in cronograma_raw:
|
||||
idx = str(fila.get('num_indice', '')).strip()
|
||||
if idx not in indices_cronograma: continue
|
||||
|
||||
mat = str(fila.get('num_matricula', '')).strip()
|
||||
if mat not in datos_agrupados:
|
||||
prog_info = mapa_programa_frec.get(mat, {"prog": "-", "frec": "-"})
|
||||
datos_agrupados[mat] = {
|
||||
"Alumno": mapa_alumnos.get(mat, "SIN NOMBRE"),
|
||||
"Programa": prog_info["prog"],
|
||||
"Frecuencia": prog_info["frec"],
|
||||
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||
}
|
||||
|
||||
fch_venc_raw = fila.get('fch_vencimiento')
|
||||
fch_venc_dt = self.parse_fecha(fch_venc_raw)
|
||||
if fch_venc_dt and (fecha_filtro <= fch_venc_dt < fecha_filtro_siguiente):
|
||||
try: datos_agrupados[mat]["Cuota_Mes"] = str(int(fila.get('num_cuota', -1)))
|
||||
except: pass
|
||||
datos_agrupados[mat]["Fch_Venc_Mes"] = fch_venc_dt.strftime('%d/%m/%Y')
|
||||
|
||||
importe_final = self.calcular_importe_final(fila)
|
||||
suma_ant, suma_cur = self.evaluar_ctaxcob_suma(fila, importe_final, fecha_filtro, fecha_filtro_siguiente, hoy, stats_suma)
|
||||
|
||||
datos_agrupados[mat]["Suma_Ant"] += suma_ant
|
||||
datos_agrupados[mat]["Suma_Cur"] += suma_cur
|
||||
|
||||
for fila in facturas_raw:
|
||||
idx_factura = str(fila.get('num_indice', '')).strip()
|
||||
if idx_factura not in indices_cronograma: continue
|
||||
|
||||
mat_factura = str(fila.get('num_matricula', '')).strip()
|
||||
tc_correcto = mapa_tc.get(mat_factura, 1.0)
|
||||
|
||||
if mat_factura not in datos_agrupados:
|
||||
prog_info = mapa_programa_frec.get(mat_factura, {"prog": "-", "frec": "-"})
|
||||
datos_agrupados[mat_factura] = {
|
||||
"Alumno": mapa_alumnos.get(mat_factura, "SIN NOMBRE"),
|
||||
"Programa": prog_info["prog"],
|
||||
"Frecuencia": prog_info["frec"],
|
||||
"Suma_Ant": 0.0, "Suma_Cur": 0.0, "Resta_Ant": 0.0, "Resta_Cur": 0.0, "Cob_Ant": 0.0, "Cob_Cur": 0.0,
|
||||
"Cuota_Mes": "-", "Fch_Venc_Mes": "-"
|
||||
}
|
||||
|
||||
resta_ant, resta_cur, cobrado_ant, cobrado_curso = self.evaluar_ctaxcob_resta(
|
||||
fila, fecha_filtro, fecha_filtro_siguiente, hoy, stats_resta, tc_correcto
|
||||
)
|
||||
|
||||
datos_agrupados[mat_factura]["Resta_Ant"] += resta_ant
|
||||
datos_agrupados[mat_factura]["Resta_Cur"] += resta_cur
|
||||
datos_agrupados[mat_factura]["Cob_Ant"] += cobrado_ant
|
||||
datos_agrupados[mat_factura]["Cob_Cur"] += cobrado_curso
|
||||
|
||||
datos_limpios = []
|
||||
for mat, totales in datos_agrupados.items():
|
||||
cta_cob_ant = totales["Suma_Ant"] - totales["Resta_Ant"]
|
||||
cta_cob_curso = totales["Suma_Cur"] - totales["Resta_Cur"]
|
||||
total_cta = cta_cob_ant + cta_cob_curso
|
||||
|
||||
cob_ant = totales["Cob_Ant"]
|
||||
cob_cur = totales["Cob_Cur"]
|
||||
total_cob = cob_ant + cob_cur
|
||||
saldo = total_cta - total_cob
|
||||
|
||||
if round(total_cta, 2) <= 0 and round(total_cob, 2) <= 0: continue
|
||||
|
||||
cuota_final = totales["Cuota_Mes"]
|
||||
fecha_final = totales["Fch_Venc_Mes"]
|
||||
if round(saldo, 2) <= 0:
|
||||
cuota_final = "-"
|
||||
fecha_final = "-"
|
||||
|
||||
datos_limpios.append({
|
||||
'MATRICULA': mat, 'ALUMNO': totales["Alumno"],
|
||||
'PROGRAMA': totales["Programa"], 'FRECUENCIA': totales["Frecuencia"],
|
||||
'NUM_CUOTA': cuota_final,
|
||||
'FCH_VENC': fecha_final,
|
||||
'CTA_COB_ANT': cta_cob_ant, 'CTA_COB_MES_CURSO': cta_cob_curso, 'TOTAL_CTA_COB': total_cta,
|
||||
'COB_ANT': cob_ant, 'COB_MES_CURSO': cob_cur, 'TOTAL_COBRADO': total_cob, 'SALDO': saldo
|
||||
})
|
||||
|
||||
def orden_personalizado(x):
|
||||
fecha_str = x['FCH_VENC']
|
||||
if fecha_str == "-":
|
||||
return (0, datetime.min, x['ALUMNO'])
|
||||
else:
|
||||
try:
|
||||
dt = datetime.strptime(fecha_str, '%d/%m/%Y')
|
||||
return (1, dt, x['ALUMNO'])
|
||||
except:
|
||||
return (1, datetime.max, x['ALUMNO'])
|
||||
|
||||
return sorted(datos_limpios, key=orden_personalizado)
|
||||
0
backend/modules/ocupabilidad/__init__.py
Normal file
0
backend/modules/ocupabilidad/__init__.py
Normal file
237
backend/modules/ocupabilidad/logic.py
Normal file
237
backend/modules/ocupabilidad/logic.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# modules/ocupabilidad/logic.py
|
||||
import pandas as pd
|
||||
from .processor import CursoProcessor
|
||||
|
||||
class AnalizadorCursos:
|
||||
"""Analizador de cursos - Lógica de cálculo y estadísticas"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self.curso_processor = CursoProcessor(self.data_manager)
|
||||
self.columnas = self.curso_processor.columnas
|
||||
|
||||
def obtener_sedes(self):
|
||||
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||
|
||||
def identificar_sede(self, dsc_programa):
|
||||
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||
if not dsc_programa: return "LIMA"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||
for sede, data in sedes_data.items():
|
||||
if sede == "DEFAULT": continue
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper: return sede
|
||||
return sedes_data.get("DEFAULT", "LIMA")
|
||||
|
||||
def obtener_filtros_programa(self):
|
||||
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||
|
||||
def identificar_filtro_programa(self, dsc_programa):
|
||||
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||
if not dsc_programa: return "OTROS"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||
for categoria, data in filtro_data.items():
|
||||
if categoria == "DEFAULT": continue
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper: return categoria
|
||||
return filtro_data.get("DEFAULT", "OTROS")
|
||||
|
||||
def get_current_month(self):
|
||||
return self.data_manager.get_current_month()
|
||||
|
||||
def get_current_year(self):
|
||||
return self.data_manager.get_current_year()
|
||||
|
||||
def actualizar_configuracion(self):
|
||||
print("🔄 Actualización automática de configuración...")
|
||||
self.data_manager.cargar_toda_configuracion()
|
||||
|
||||
# --- AGREGADO EL PARÁMETRO "TIPO" ---
|
||||
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||
# Sincronizamos el estado del toggle UI con el procesador
|
||||
self.curso_processor.mostrar_reprogramados = getattr(self, 'mostrar_reprogramados', True)
|
||||
|
||||
datos = self.curso_processor.obtener_datos_procesados(ano, mes)
|
||||
programas_disponibles = set()
|
||||
|
||||
if datos:
|
||||
for d in datos:
|
||||
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||
# Inyectamos variables para el filtro UI
|
||||
d['Sede'] = self.identificar_sede(prog)
|
||||
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||
|
||||
if sede == "TODOS" or d['Sede'] == sede:
|
||||
programas_disponibles.add(d['Filtro_Programa'])
|
||||
|
||||
# Ordenar lista dinámica
|
||||
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||
|
||||
# Auto-corrección si el programa no existe en la sede actual
|
||||
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||
filtro_prog = "TODOS"
|
||||
self.filtro_corregido = "TODOS"
|
||||
else:
|
||||
self.filtro_corregido = None
|
||||
|
||||
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||
datos_filtrados = []
|
||||
for d in datos:
|
||||
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||
if cumple_sede and cumple_prog:
|
||||
datos_filtrados.append(d)
|
||||
return datos_filtrados
|
||||
|
||||
return datos
|
||||
|
||||
def formatear_datos_para_tabla(self, datos):
|
||||
if not datos:
|
||||
return [], {}
|
||||
|
||||
sheet_data = []
|
||||
totales = {
|
||||
'mes': 0, 'retirados': 0, 'totales': 0,
|
||||
'activos': 0, 'pc': 0, 'continuidad': 0, 'meta': 0,
|
||||
'descuento': 0
|
||||
}
|
||||
|
||||
for registro in datos:
|
||||
fila = []
|
||||
for col in self.columnas:
|
||||
if col != 'Avance_Inscritos':
|
||||
valor = registro.get(col, '')
|
||||
fila.append(str(valor))
|
||||
self._acumular_totales(totales, col, registro)
|
||||
else:
|
||||
total_insc = registro.get('Inscritos_Totales', 0)
|
||||
meta = registro.get('Meta_Curso', 0)
|
||||
|
||||
if total_insc == "-" or meta == "-":
|
||||
fila.append("-")
|
||||
else:
|
||||
porcentaje = self.calcular_porcentaje_avance(total_insc, meta)
|
||||
fila.append(self.crear_barra_texto_color(porcentaje))
|
||||
|
||||
sheet_data.append(fila)
|
||||
|
||||
fila_totales = self.crear_fila_totales(totales)
|
||||
sheet_data.append(fila_totales)
|
||||
|
||||
return sheet_data, totales
|
||||
|
||||
def _acumular_totales(self, totales, col, registro):
|
||||
val = registro.get(col, 0)
|
||||
if val == "-": return # Ignoramos los guiones para que no rompa la suma
|
||||
|
||||
if col == 'Descuento':
|
||||
totales['descuento'] += val
|
||||
elif col == 'Inscritos_Mes':
|
||||
totales['mes'] += val
|
||||
elif col == 'Retirados':
|
||||
totales['retirados'] += val
|
||||
elif col == 'Inscritos_Totales':
|
||||
totales['totales'] += val
|
||||
elif col == 'Inscritos_Activos':
|
||||
totales['activos'] += val
|
||||
elif col == 'Inscritos_PC':
|
||||
totales['pc'] += val
|
||||
elif col == 'Inscritos_Continuidad':
|
||||
totales['continuidad'] += val
|
||||
elif col == 'Meta_Curso':
|
||||
totales['meta'] += val
|
||||
|
||||
def crear_fila_totales(self, totales):
|
||||
porcentaje = self.calcular_porcentaje_avance(totales['totales'], totales['meta'])
|
||||
# REORGANIZADO SEGÚN NUEVO ORDEN DE COLUMNAS SOLICITADO:
|
||||
# [0]Prog, [1]Fch, [2]Dias, [3]Mes, [4]Total, [5]Retirados, [6]Activos, [7]PC, [8]Refriperu, [9]Cont, [10]Meta, [11]Avance
|
||||
return [
|
||||
"TOTAL GENERAL", # PROGRAMA
|
||||
"", # FECHA INICIO
|
||||
"", # DIAS PARA INICIO
|
||||
str(totales['mes']), # INSCRITOS MES
|
||||
str(totales['totales']), # TOTAL INSCRITOS
|
||||
str(totales['retirados']), # RETIRADOS
|
||||
str(totales['activos']), # INSCRITOS EN CURSO
|
||||
str(totales['pc']), # INSCRITOS P.C
|
||||
str(totales['descuento']), # INSCRITOS REFRIPERU (Descuento)
|
||||
str(totales['continuidad']), # INSCRITOS CONTINUIDAD
|
||||
str(totales['meta']), # META
|
||||
self.crear_barra_texto_color(porcentaje) # AVANCE INSCRITOS
|
||||
]
|
||||
|
||||
def calcular_porcentaje_avance(self, activos, meta):
|
||||
return (activos / meta * 100) if meta > 0 else 0
|
||||
|
||||
def crear_barra_texto_color(self, porcentaje):
|
||||
longitud = 10
|
||||
llenas = int((min(porcentaje, 100) / 100) * longitud)
|
||||
vacias = longitud - llenas
|
||||
barra = '█' * llenas + '░' * vacias
|
||||
porc_text = f"{porcentaje:.1f}%"
|
||||
espacios = " " * (6 - len(porc_text))
|
||||
return f"{porc_text}{espacios}{barra}"
|
||||
|
||||
def get_intervalo_actualizacion(self):
|
||||
return self.data_manager.config_data.get(
|
||||
'config_general', {}
|
||||
).get('auto_update_minutos', 5) * 60000
|
||||
|
||||
def exportar_a_excel(self, datos, ano, mes):
|
||||
df = pd.DataFrame(datos)
|
||||
df_exportar = df[self.columnas]
|
||||
archivo = f"cursos_{ano}_{mes}.xlsx"
|
||||
df_exportar.to_excel(archivo, index=False)
|
||||
return archivo
|
||||
|
||||
def calcular_metricas_generales(self, datos):
|
||||
if not datos: return {}
|
||||
# Filtramos las filas fantasmas para no romper la matemática de Pandas
|
||||
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||
if not datos_validos: return {}
|
||||
|
||||
df = pd.DataFrame(datos_validos)
|
||||
metricas = {
|
||||
'total_cursos': len(datos), # Mostramos el total real de filas (incluyendo fantasmas)
|
||||
'total_inscritos': df['Inscritos_Activos'].sum(),
|
||||
'total_meta': df['Meta_Curso'].sum(),
|
||||
'porcentaje_avance_general': (df['Inscritos_Activos'].sum() / df['Meta_Curso'].sum() * 100) if df['Meta_Curso'].sum() > 0 else 0,
|
||||
'cursos_sobre_meta': len(df[df['Inscritos_Activos'] >= df['Meta_Curso']]),
|
||||
'cursos_bajo_meta': len(df[df['Inscritos_Activos'] < df['Meta_Curso']]),
|
||||
'total_inscritos_pc': df['Inscritos_PC'].sum()
|
||||
}
|
||||
return metricas
|
||||
|
||||
def obtener_top_programas(self, datos, top_n=5):
|
||||
if not datos: return []
|
||||
# Filtramos las filas fantasmas
|
||||
datos_validos = [d for d in datos if d.get('dias_para_inicio') != 'REPROGRAMADO']
|
||||
if not datos_validos: return []
|
||||
|
||||
df = pd.DataFrame(datos_validos)
|
||||
df['Porcentaje_Avance'] = df.apply(
|
||||
lambda x: self.calcular_porcentaje_avance(x['Inscritos_Activos'], x['Meta_Curso']),
|
||||
axis=1
|
||||
)
|
||||
top_programas = df.nlargest(top_n, 'Porcentaje_Avance')[
|
||||
['programa_frecuencia', 'Inscritos_Activos', 'Meta_Curso', 'Porcentaje_Avance']
|
||||
].to_dict('records')
|
||||
return top_programas
|
||||
|
||||
def calcular_tendencias_mensuales(self, ano):
|
||||
tendencias = {}
|
||||
for mes in range(1, 13):
|
||||
try:
|
||||
datos_mes = self.obtener_datos_procesados(str(ano), str(mes))
|
||||
if datos_mes:
|
||||
metricas = self.calcular_metricas_generales(datos_mes)
|
||||
tendencias[mes] = metricas
|
||||
except Exception as e:
|
||||
print(f"Error procesando mes {mes}: {e}")
|
||||
continue
|
||||
return tendencias
|
||||
560
backend/modules/ocupabilidad/processor.py
Normal file
560
backend/modules/ocupabilidad/processor.py
Normal file
@@ -0,0 +1,560 @@
|
||||
# modules/ocupabilidad/processor.py
|
||||
from datetime import datetime
|
||||
import unicodedata
|
||||
|
||||
class CursoProcessor:
|
||||
"""Procesador de datos de cursos - Aplica transformaciones y cálculos"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
|
||||
# --- COLUMNAS REORDENADAS Y REORGANIZADAS ---
|
||||
self.columnas = [
|
||||
'programa_frecuencia',
|
||||
'fch_inicio',
|
||||
'dias_para_inicio',
|
||||
'Inscritos_Mes',
|
||||
'Inscritos_Totales',
|
||||
'Retirados',
|
||||
'Inscritos_Activos',
|
||||
'Inscritos_PC',
|
||||
'Descuento', # Esto es 'INSCRITOS REFRIPERU'
|
||||
'Inscritos_Continuidad',
|
||||
'Meta_Curso',
|
||||
'Avance_Inscritos'
|
||||
]
|
||||
|
||||
def obtener_datos_procesados(self, ano, mes):
|
||||
try:
|
||||
# 1. Cargar datos básicos
|
||||
datos_matriculas = self.data_manager.cargar_datos_matriculas(ano, mes)
|
||||
datos_raw_matriculas = self.data_manager.ejecutar_consulta_matriculados_detalle(ano, mes)
|
||||
datos_originales = self.data_manager.ejecutar_consulta_cursos(ano, mes)
|
||||
|
||||
# --- NUEVO: EL GUARDIÁN DE FECHAS (SUPABASE) ---
|
||||
fechas_historicas = self.data_manager.obtener_fechas_originales()
|
||||
indices_en_sql = set()
|
||||
|
||||
for fila in datos_originales:
|
||||
indice = str(fila.get('num_indice', '')).strip()
|
||||
fecha_actual = fila.get('fch_inicio')
|
||||
programa = fila.get('dsc_programa', '')
|
||||
|
||||
if indice and fecha_actual:
|
||||
indices_en_sql.add(indice) # Registramos que sí vino en SQL este mes
|
||||
if indice not in fechas_historicas:
|
||||
fecha_str = fecha_actual.strftime('%d/%m/%Y') if isinstance(fecha_actual, datetime) else str(fecha_actual)
|
||||
exito = self.data_manager.guardar_fecha_original(indice, programa, fecha_str)
|
||||
if exito:
|
||||
fechas_historicas[indice] = {'fecha': fecha_str, 'programa': programa}
|
||||
# -----------------------------------------------
|
||||
|
||||
# 2. CONTINUIDAD
|
||||
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)
|
||||
|
||||
# 3. Procesar todo
|
||||
datos_procesados = self.aplicar_personalizaciones(
|
||||
datos_originales,
|
||||
datos_matriculas,
|
||||
datos_raw_matriculas,
|
||||
datos_historial,
|
||||
ano, mes
|
||||
)
|
||||
|
||||
# --- INYECTAR CURSOS REPROGRAMADOS (FILAS FANTASMAS) ---
|
||||
if getattr(self, 'mostrar_reprogramados', True):
|
||||
for idx, info in fechas_historicas.items():
|
||||
if idx not in indices_en_sql: # Si el curso NO vino este mes desde SQL...
|
||||
# Aseguramos compatibilidad si guardaste data vieja como texto o la nueva como diccionario
|
||||
if isinstance(info, dict):
|
||||
fecha_orig = info.get('fecha', '')
|
||||
prog_orig = info.get('programa', 'CURSO REPROGRAMADO')
|
||||
else:
|
||||
fecha_orig = str(info)
|
||||
prog_orig = 'CURSO REPROGRAMADO'
|
||||
|
||||
# Verificamos si la fecha original correspondía a ESTE mes Y AÑO filtrado
|
||||
mes_orig = ""
|
||||
ano_orig = ""
|
||||
if "/" in fecha_orig:
|
||||
partes = fecha_orig.split("/")
|
||||
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||
elif "-" in fecha_orig:
|
||||
partes = fecha_orig.split("-")
|
||||
if len(partes) >= 2: mes_orig = str(int(partes[1]))
|
||||
if len(partes) >= 3: ano_orig = str(int(partes[2]))
|
||||
|
||||
# Mes debe coincidir; y si la fecha tiene año, el año también debe coincidir.
|
||||
# Si la fecha NO tiene año (formato viejo dd/mm), no se inyecta para evitar
|
||||
# mostrarla en años equivocados.
|
||||
coincide_mes = (mes_orig == str(int(mes)))
|
||||
coincide_ano = (ano_orig == str(int(ano))) if ano_orig else False
|
||||
if coincide_mes and coincide_ano:
|
||||
# Inyectamos la fila falsa para alertar en pantalla
|
||||
datos_procesados.append({
|
||||
'num_indice': idx,
|
||||
'programa_frecuencia': prog_orig,
|
||||
'dsc_programa': prog_orig,
|
||||
'fch_inicio': fecha_orig,
|
||||
'dias_para_inicio': 'REPROGRAMADO',
|
||||
'Inscritos_Mes': "-",
|
||||
'Inscritos_Totales': "-",
|
||||
'Retirados': "-",
|
||||
'Inscritos_Activos': "-",
|
||||
'Inscritos_PC': "-",
|
||||
'Descuento': "-",
|
||||
'Inscritos_Continuidad': "-",
|
||||
'Meta_Curso': "-",
|
||||
'Avance_Inscritos': "-"
|
||||
})
|
||||
# -----------------------------------------------
|
||||
|
||||
## --- ORDENAR: NORMALES POR FECHA, REPROGRAMADOS POR SEDE ---
|
||||
def parse_fecha_orden(fecha_str):
|
||||
if not fecha_str or fecha_str == "-":
|
||||
return datetime.max
|
||||
s = str(fecha_str).replace("/", "-").strip()
|
||||
try:
|
||||
if len(s) == 5:
|
||||
return datetime.strptime(f"{s}-{ano}", "%d-%m-%Y")
|
||||
if len(s) >= 10:
|
||||
return datetime.strptime(s[:10], "%d-%m-%Y")
|
||||
except:
|
||||
pass
|
||||
return datetime.max
|
||||
|
||||
def obtener_orden_sede(programa):
|
||||
prog_upper = str(programa).upper()
|
||||
if 'LIMA' in prog_upper: return 1
|
||||
if 'PIURA' in prog_upper: return 2
|
||||
if 'TRUJILLO' in prog_upper: return 3
|
||||
if 'AREQUIPA' in prog_upper: return 4
|
||||
return 5
|
||||
|
||||
def logica_ordenamiento(x):
|
||||
if x.get('dias_para_inicio') == 'REPROGRAMADO':
|
||||
# Reprogramados: Van al final (1), ordenados por sede, luego alfabéticamente
|
||||
return (1, obtener_orden_sede(x.get('dsc_programa', '')), x.get('dsc_programa', ''))
|
||||
else:
|
||||
# Normales: Van al inicio (0), ordenados por fecha
|
||||
return (0, 0, parse_fecha_orden(x.get('fch_inicio', '')))
|
||||
|
||||
# Aplicamos el ordenamiento inteligente
|
||||
datos_procesados.sort(key=logica_ordenamiento)
|
||||
|
||||
return datos_procesados
|
||||
except Exception as e:
|
||||
print(f"❌ Error obteniendo datos procesados: {e}")
|
||||
return []
|
||||
|
||||
def normalizar_texto(self, texto):
|
||||
if not texto: return ""
|
||||
texto = str(texto).upper().strip()
|
||||
texto = unicodedata.normalize('NFD', texto)
|
||||
texto = texto.encode('ascii', 'ignore').decode("utf-8")
|
||||
return texto
|
||||
|
||||
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:
|
||||
patron_limpio = self.normalizar_texto(patron)
|
||||
if patron_limpio 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("🎯 Aplicando personalizaciones...")
|
||||
cursos_personalizados = self.data_manager.config_data.get('cursos_personalizados', {})
|
||||
datos_filtrados = []
|
||||
|
||||
try:
|
||||
ano_target = int(ano_filtro)
|
||||
mes_target = int(mes_filtro)
|
||||
except ValueError:
|
||||
ano_target = 0
|
||||
mes_target = 0
|
||||
|
||||
# 1. FILTRADO
|
||||
for curso in datos_originales:
|
||||
num_indice = str(curso.get('num_indice', ''))
|
||||
|
||||
if num_indice in cursos_personalizados:
|
||||
personalizacion = cursos_personalizados[num_indice]
|
||||
if 'fch_inicio' in personalizacion:
|
||||
curso['fch_inicio'] = personalizacion['fch_inicio']
|
||||
if 'flg_activo' in personalizacion:
|
||||
curso['flg_activo'] = personalizacion['flg_activo']
|
||||
if 'dsc_programa' in personalizacion:
|
||||
curso['dsc_programa'] = personalizacion['dsc_programa']
|
||||
if 'dsc_det_programa' in personalizacion:
|
||||
curso['dsc_det_programa'] = personalizacion['dsc_det_programa']
|
||||
|
||||
if curso.get('flg_activo', '') == 'NO':
|
||||
continue
|
||||
|
||||
fch_valida = True
|
||||
try:
|
||||
raw_fecha = curso.get('fch_inicio')
|
||||
if raw_fecha:
|
||||
f_obj = None
|
||||
s_fecha = str(raw_fecha).strip()
|
||||
if len(s_fecha) == 10 and s_fecha[2] == '-' and s_fecha[5] == '-':
|
||||
f_obj = datetime.strptime(s_fecha, '%d-%m-%Y')
|
||||
elif '-' in s_fecha:
|
||||
if ' ' 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:
|
||||
if f_obj.year != ano_target or f_obj.month != mes_target:
|
||||
fch_valida = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if fch_valida:
|
||||
datos_filtrados.append(curso)
|
||||
|
||||
# 2. TRANSFORMACIONES
|
||||
datos_filtrados = self.aplicar_reemplazos_programas(datos_filtrados)
|
||||
datos_filtrados = self.concatenar_programa_frecuencia(datos_filtrados)
|
||||
|
||||
for curso in datos_filtrados:
|
||||
if 'fch_inicio' in curso:
|
||||
curso['fch_inicio'] = self.formatear_fecha(curso['fch_inicio'])
|
||||
|
||||
# 3. DATOS CALCULADOS CON PRIORIDAD (REFRIPERU > CONTINUIDAD)
|
||||
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.calcular_dias_para_inicio_cursos(datos_filtrados)
|
||||
datos_filtrados = self.agregar_inscritos_mes(datos_filtrados, datos_matriculas)
|
||||
datos_filtrados = self.agregar_inscritos_pc(datos_filtrados, datos_raw_matriculas)
|
||||
datos_filtrados = self.agregar_meta_curso(datos_filtrados)
|
||||
datos_filtrados = self.agregar_avance_inscritos(datos_filtrados)
|
||||
|
||||
return datos_filtrados
|
||||
|
||||
def aplicar_reemplazos_programas(self, datos):
|
||||
if 'reemplazos_programas' in self.data_manager.replace_data:
|
||||
reemplazos = self.data_manager.replace_data['reemplazos_programas']
|
||||
for curso in datos:
|
||||
programa_original = curso.get('dsc_programa', '')
|
||||
if programa_original in reemplazos:
|
||||
curso['dsc_programa'] = reemplazos[programa_original]
|
||||
return datos
|
||||
|
||||
def concatenar_programa_frecuencia(self, datos):
|
||||
for curso in datos:
|
||||
programa = curso.get('dsc_programa', '')
|
||||
frecuencia = curso.get('cod_frecuencia', '')
|
||||
if programa and frecuencia:
|
||||
curso['programa_frecuencia'] = f"{programa} - {frecuencia}"
|
||||
elif programa:
|
||||
curso['programa_frecuencia'] = programa
|
||||
elif frecuencia:
|
||||
curso['programa_frecuencia'] = frecuencia
|
||||
else:
|
||||
curso['programa_frecuencia'] = ""
|
||||
return datos
|
||||
|
||||
def formatear_fecha(self, fecha_str):
|
||||
try:
|
||||
if not fecha_str: return ""
|
||||
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-': return 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)
|
||||
|
||||
# --- AQUÍ ESTÁ EL CAMBIO ---
|
||||
# Si es cualquiera de estas 5, las unimos bajo una misma "Línea Universal" de compatibilidad
|
||||
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||
return "CARRERA_COMPATIBLE"
|
||||
|
||||
# Si es GESTION, MASTERCLASS, SEMINARIOS, etc., retorna None (las sigue ignorando)
|
||||
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
|
||||
|
||||
# =========================================================================
|
||||
# ❄️ REFRIPERU / DESCUENTO (ALTA PRIORIDAD)
|
||||
# =========================================================================
|
||||
def agregar_descuento(self, datos, datos_raw_matriculas):
|
||||
print("\n💰 Calculando REFRIPERU (Descuentos)...")
|
||||
print("="*80)
|
||||
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA':<20} | {'PAGO':<10} | {'ZONA/MOTIVO'}")
|
||||
print("-" * 80)
|
||||
|
||||
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:
|
||||
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||
if estado not in ['ALU', 'PRE']: continue
|
||||
|
||||
num_indice = str(alumno.get('num_indice', ''))
|
||||
|
||||
dni = str(alumno.get('dsc_documento', 'SD')).strip()
|
||||
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||
|
||||
mat_id = str(alumno.get('num_matricula', '')).strip()
|
||||
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||
|
||||
if mat_id in correcciones_descuento:
|
||||
accion = str(correcciones_descuento[mat_id]).upper().strip()
|
||||
if accion == "SI":
|
||||
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||
alumnos_refriperu.add(mat_id)
|
||||
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {'---':<20} | {'---':<10} | MANUAL (JSON)")
|
||||
continue
|
||||
elif accion == "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', 1) or 1)
|
||||
if imp_tc <= 0: imp_tc = 1.0
|
||||
except:
|
||||
inv_neta = 0.0; imp_tc = 1.0
|
||||
|
||||
if cod_moneda == 'DOL':
|
||||
inv_neta_final = inv_neta * imp_tc
|
||||
else:
|
||||
inv_neta_final = inv_neta
|
||||
|
||||
inv_neta_final = round(inv_neta_final, 2)
|
||||
|
||||
categoria_json = self.obtener_categoria_programa(nombre_programa)
|
||||
es_descuento = False
|
||||
motivo_debug = ""
|
||||
|
||||
if categoria_json in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||
if 1200 <= inv_neta_final <= 1700:
|
||||
es_descuento = True
|
||||
motivo_debug = f"{categoria_json} (Rango Prov)"
|
||||
|
||||
elif categoria_json in ["TEAC", "TERC"]:
|
||||
if 1400 <= inv_neta_final <= 1900:
|
||||
es_descuento = True
|
||||
motivo_debug = f"{categoria_json} (Rango Lima)"
|
||||
|
||||
if es_descuento:
|
||||
conteo_por_curso[num_indice] = conteo_por_curso.get(num_indice, 0) + 1
|
||||
alumnos_refriperu.add(mat_id)
|
||||
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_programa[:20]:<20} | S/{inv_neta_final:<8} | {motivo_debug}")
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for curso in datos:
|
||||
idx = str(curso.get('num_indice', ''))
|
||||
curso['Descuento'] = conteo_por_curso.get(idx, 0)
|
||||
|
||||
return datos, alumnos_refriperu
|
||||
|
||||
# =========================================================================
|
||||
# 🔄 CONTINUIDAD (BAJA PRIORIDAD)
|
||||
# =========================================================================
|
||||
def agregar_continuidad(self, datos, datos_raw_matriculas, datos_historial, alumnos_refriperu=None):
|
||||
if alumnos_refriperu is None:
|
||||
alumnos_refriperu = set()
|
||||
|
||||
print("🔄 Calculando CONTINUIDAD...")
|
||||
print("="*80)
|
||||
print(f"{'DNI':<12} | {'NOMBRE ALUMNO':<35} | {'PROGRAMA ACTUAL':<30} | {'MOTIVO/ORIGEN'}")
|
||||
print("-" * 80)
|
||||
|
||||
conteo_por_curso = {}
|
||||
|
||||
correcciones_continuidad = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||
MIN_DIAS_DIFERENCIA = 60
|
||||
|
||||
if datos_raw_matriculas and datos_historial:
|
||||
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', ''))
|
||||
nombre_curso_actual = str(curso.get('dsc_programa', ''))
|
||||
fecha_inicio_actual = self.parse_fecha(curso.get('fch_inicio'))
|
||||
|
||||
# Identifica si el curso actual pertenece a la "Línea Universal" de 5 categorías
|
||||
linea_actual = self.identificar_linea_carrera(nombre_curso_actual)
|
||||
|
||||
if not linea_actual or not fecha_inicio_actual: continue
|
||||
|
||||
lista_alumnos = alumnos_por_curso.get(num_indice, [])
|
||||
contador_fieles = 0
|
||||
|
||||
for alumno in lista_alumnos:
|
||||
estado = str(alumno.get('estado_matricula', '')).strip().upper()
|
||||
if estado 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()
|
||||
nombre_alumno = str(alumno.get('nombre_alumno', 'SIN NOMBRE')).strip()
|
||||
|
||||
es_manual_si = False
|
||||
if mat_id in correcciones_continuidad:
|
||||
accion = str(correcciones_continuidad[mat_id]).upper().strip()
|
||||
if accion == "SI": es_manual_si = True
|
||||
elif accion == "NO": continue
|
||||
|
||||
es_fiel = False
|
||||
motivo = ""
|
||||
|
||||
if es_manual_si:
|
||||
es_fiel = True
|
||||
motivo = "MANUAL (JSON)"
|
||||
elif dni and dni in datos_historial:
|
||||
historial_alumno = datos_historial[dni]
|
||||
for antecedente in historial_alumno:
|
||||
nombre_pasado = str(antecedente.get('programa', ''))
|
||||
fecha_pasada = antecedente.get('fecha')
|
||||
if not fecha_pasada: continue
|
||||
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||
if dias_diff < MIN_DIAS_DIFERENCIA: continue
|
||||
|
||||
# Revisa si el curso pasado también es de esa misma línea universal
|
||||
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||
|
||||
# Si ambos devuelven "CARRERA_COMPATIBLE", entonces hacen match
|
||||
if linea_pasada == linea_actual:
|
||||
es_fiel = True
|
||||
motivo = f"Viene de: {nombre_pasado}"
|
||||
break
|
||||
|
||||
if es_fiel:
|
||||
contador_fieles += 1
|
||||
print(f"{dni:<12} | {nombre_alumno[:35]:<35} | {nombre_curso_actual[:30]:<30} | {motivo}")
|
||||
|
||||
conteo_por_curso[num_indice] = contador_fieles
|
||||
|
||||
for curso in datos:
|
||||
idx = str(curso.get('num_indice', ''))
|
||||
curso['Inscritos_Continuidad'] = conteo_por_curso.get(idx, 0)
|
||||
|
||||
return datos
|
||||
|
||||
def calcular_dias_para_inicio(self, fecha_str, cod_estado):
|
||||
try:
|
||||
if cod_estado == 'SUS': return "SUSPENDIDO"
|
||||
if not fecha_str: return ""
|
||||
if isinstance(fecha_str, str) and len(fecha_str) == 10 and fecha_str[2] == '-':
|
||||
fecha_curso = datetime.strptime(fecha_str, '%d-%m-%Y')
|
||||
else:
|
||||
fecha_curso = datetime.strptime(str(fecha_str), '%Y-%m-%d %H:%M:%S')
|
||||
hoy = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fecha_curso = fecha_curso.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
diferencia = (fecha_curso - hoy).days
|
||||
if diferencia < 0: return "INICIADO"
|
||||
return str(diferencia) if diferencia != 0 else "0"
|
||||
except: return ""
|
||||
|
||||
def calcular_dias_para_inicio_cursos(self, datos):
|
||||
for curso in datos:
|
||||
curso['dias_para_inicio'] = self.calcular_dias_para_inicio(curso.get('fch_inicio'), curso.get('cod_estado'))
|
||||
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:
|
||||
num_indice = str(curso.get('num_indice', ''))
|
||||
curso['Inscritos_Mes'] = dict_norm.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:
|
||||
est = str(m.get('estado_matricula', '')).strip()
|
||||
nid = str(m.get('num_indice', ''))
|
||||
sm = float(m.get('imp_saldo_matricula', 0) or 0)
|
||||
sc1 = float(m.get('imp_saldo_cuota1', 0) or 0)
|
||||
if est in ['ALU', 'PRE'] and sm < 1 and sc1 < 1:
|
||||
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)
|
||||
curso['Retirados'] = int(curso.get('Inscritos_Retirados', 0)) # <--- NUEVA EXTRACCIÓN
|
||||
return datos
|
||||
|
||||
def calcular_meta_curso(self, nombre_programa, cod_estado):
|
||||
try:
|
||||
if cod_estado == 'SUS': return 0
|
||||
|
||||
categoria = self.obtener_categoria_programa(nombre_programa)
|
||||
|
||||
clasificaciones = self.data_manager.meta_data.get('clasificacion_programas', {})
|
||||
if categoria in clasificaciones:
|
||||
return clasificaciones[categoria].get('valor', 0)
|
||||
|
||||
return self.data_manager.meta_data.get('clasificacion_default', {}).get('valor', 15)
|
||||
except: return 0
|
||||
|
||||
def agregar_meta_curso(self, datos):
|
||||
print("🎯 Calculando META_CURSO...")
|
||||
for curso in datos:
|
||||
curso['Meta_Curso'] = self.calcular_meta_curso(curso.get('dsc_programa', ''), curso.get('cod_estado', ''))
|
||||
return datos
|
||||
|
||||
def calcular_avance_inscritos(self, inscritos_activos, meta_curso):
|
||||
try:
|
||||
if meta_curso > 0:
|
||||
porcentaje = (inscritos_activos / meta_curso) * 100
|
||||
return f"{porcentaje:.1f}%"
|
||||
else: return "0%"
|
||||
except: return "0%"
|
||||
|
||||
def agregar_avance_inscritos(self, datos):
|
||||
print("📊 Calculando AVANCE_INSCRITOS...")
|
||||
for curso in datos:
|
||||
curso['Avance_Inscritos'] = self.calcular_avance_inscritos(curso.get('Inscritos_Totales', 0), curso.get('Meta_Curso', 0))
|
||||
return datos
|
||||
0
backend/modules/rentabilidad/__init__.py
Normal file
0
backend/modules/rentabilidad/__init__.py
Normal file
950
backend/modules/rentabilidad/logic.py
Normal file
950
backend/modules/rentabilidad/logic.py
Normal file
@@ -0,0 +1,950 @@
|
||||
# modules/rentabilidad/logic.py
|
||||
import pandas as pd
|
||||
import requests
|
||||
import re
|
||||
from datetime import datetime
|
||||
from .processor import RentabilidadProcessor
|
||||
|
||||
class RentabilidadLogic:
|
||||
"""Controlador lógico de Rentabilidad - Estructura la tabla y gestiona variables de clasificación"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self.processor = RentabilidadProcessor(self.data_manager)
|
||||
|
||||
def obtener_categorias(self):
|
||||
"""Devuelve la lista simplificada y agrupada para el filtro del Dashboard"""
|
||||
return ["AREQUIPA", "TRUJILLO", "PIURA", "PROGRAMAS", "SEMINARIOS", "OTROS"]
|
||||
|
||||
def obtener_sedes(self):
|
||||
"""Devuelve la lista de sedes para el nuevo filtro"""
|
||||
return ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]
|
||||
|
||||
def identificar_sede(self, dsc_programa):
|
||||
"""Identifica la sede basada en el nombre del programa y el JSON de GitHub"""
|
||||
if not dsc_programa: return "LIMA"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
sedes_data = self.data_manager.meta_data.get("clasificacion_sedes", {})
|
||||
|
||||
for sede, data in sedes_data.items():
|
||||
if sede == "DEFAULT": continue
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper:
|
||||
return sede
|
||||
return sedes_data.get("DEFAULT", "LIMA")
|
||||
|
||||
def obtener_filtros_programa(self):
|
||||
"""Devuelve la lista de programas para el nuevo filtro combinado"""
|
||||
return ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||
|
||||
def identificar_filtro_programa(self, dsc_programa):
|
||||
"""Identifica la categoría del programa para el filtro de la UI"""
|
||||
if not dsc_programa: return "OTROS"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
filtro_data = self.data_manager.meta_data.get("clasificacion_filtro_programa", {})
|
||||
|
||||
for categoria, data in filtro_data.items():
|
||||
if categoria == "DEFAULT": continue
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper:
|
||||
return categoria
|
||||
return filtro_data.get("DEFAULT", "OTROS")
|
||||
|
||||
def clasificar_programa(self, dsc_programa):
|
||||
if not dsc_programa: return "OTROS"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
meta_data = self.data_manager.meta_data
|
||||
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||
|
||||
for clase, data in clasificaciones.items():
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper:
|
||||
return clase
|
||||
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||
|
||||
def identificar_linea_carrera(self, nombre_programa):
|
||||
"""Identifica si el programa es de la familia de Carreras Técnicas"""
|
||||
categoria = self.clasificar_programa(nombre_programa)
|
||||
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]:
|
||||
return "CARRERA_COMPATIBLE"
|
||||
return None
|
||||
|
||||
def parse_fecha(self, fecha_raw):
|
||||
"""Convierte diferentes formatos de fecha a objeto datetime"""
|
||||
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
|
||||
|
||||
def calcular_descuento(self, clasificacion, inv_neta):
|
||||
try: inv_neta = float(inv_neta)
|
||||
except: inv_neta = 0.0
|
||||
|
||||
if clasificacion in ["TEAC", "TERC"]: return 3499.0 - inv_neta
|
||||
elif clasificacion in ["AREQUIPA", "PIURA", "TRUJILLO"]: return 2839.0 - inv_neta
|
||||
elif clasificacion == "CARRERA": return 4299.0 - inv_neta
|
||||
else: return None
|
||||
|
||||
def calcular_estado_descuento(self, clasificacion, descuento):
|
||||
if descuento is None: return ""
|
||||
es_provincia = clasificacion in ["TRUJILLO", "AREQUIPA", "PIURA"]
|
||||
tope = 1000.0 if es_provincia else 800.0
|
||||
|
||||
if descuento > tope or descuento < 0: return "NO"
|
||||
return "SI"
|
||||
|
||||
# ========================================================================
|
||||
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||
# ========================================================================
|
||||
# ========================================================================
|
||||
# MOTOR DINÁMICO DE COSTOS - BÚSQUEDA INTELIGENTE
|
||||
# ========================================================================
|
||||
def _obtener_costo_segun_categoria(self, diccionario_costo, categoria, dsc_programa):
|
||||
"""Busca el costo inteligente. Si es 'otros', busca similitud de palabras."""
|
||||
cat_lower = str(categoria).lower()
|
||||
|
||||
# 1. Agrupamos los técnicos dentro de 'programas'
|
||||
if cat_lower in ["teac", "terc"]:
|
||||
cat_lower = "programas"
|
||||
|
||||
# 2. EL EMBUDO: Si la categoría no existe en el diccionario (ej. seminarios, masterclass, etc.),
|
||||
# la forzamos a que caiga siempre en la bolsa de "otros"
|
||||
if cat_lower not in diccionario_costo:
|
||||
cat_lower = "otros"
|
||||
|
||||
# 3. Buscamos el valor
|
||||
if cat_lower in diccionario_costo:
|
||||
valor = diccionario_costo[cat_lower]
|
||||
|
||||
# Si el valor es un bloque de similitudes (Como sucede ahora con "otros")
|
||||
if isinstance(valor, dict):
|
||||
dsc_upper = str(dsc_programa).upper()
|
||||
|
||||
# Buscamos coincidencias con el nombre completo del curso
|
||||
for patron, monto in valor.items():
|
||||
if patron != "DEFAULT" and patron.upper() in dsc_upper:
|
||||
return float(monto)
|
||||
|
||||
# Si lee todo el diccionario y no encuentra coincidencia, usamos el DEFAULT
|
||||
return float(valor.get("DEFAULT", 0))
|
||||
else:
|
||||
# Si es un número directo (ej. piura, trujillo, carrera)
|
||||
return float(valor)
|
||||
|
||||
return 0.0
|
||||
|
||||
# ========================================================================
|
||||
|
||||
def obtener_datos_procesados(self, ano, mes, sede="TODOS", filtro_prog="TODOS"):
|
||||
"""Obtiene datos, inyecta Categoria_Programa, Sede, Filtro_Programa y calcula promedios."""
|
||||
datos = self.processor.obtener_datos_procesados(ano, mes)
|
||||
programas_disponibles = set()
|
||||
|
||||
if datos:
|
||||
for d in datos:
|
||||
prog = str(d.get('programa_frecuencia', d.get('dsc_programa', '')))
|
||||
d['Categoria_Programa'] = self.clasificar_programa(prog)
|
||||
d['Sede'] = self.identificar_sede(prog)
|
||||
d['Filtro_Programa'] = self.identificar_filtro_programa(prog)
|
||||
|
||||
# Recolectar qué programas existen REALMENTE en la Sede seleccionada
|
||||
if sede == "TODOS" or d['Sede'] == sede:
|
||||
programas_disponibles.add(d['Filtro_Programa'])
|
||||
|
||||
# Ordenar para el Dropdown en la UI
|
||||
orden_deseado = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]
|
||||
self.programas_disponibles_filtro = [p for p in orden_deseado if p in programas_disponibles]
|
||||
|
||||
# AUTO-CORRECCIÓN: Si el programa elegido ya no existe en la nueva sede, forzamos a TODOS
|
||||
if filtro_prog != "TODOS" and filtro_prog not in programas_disponibles:
|
||||
filtro_prog = "TODOS"
|
||||
self.filtro_corregido = "TODOS"
|
||||
else:
|
||||
self.filtro_corregido = None
|
||||
|
||||
if datos and (sede != "TODOS" or filtro_prog != "TODOS"):
|
||||
datos_filtrados = []
|
||||
for d in datos:
|
||||
cumple_sede = (sede == "TODOS" or d.get('Sede') == sede)
|
||||
cumple_prog = (filtro_prog == "TODOS" or d.get('Filtro_Programa') == filtro_prog)
|
||||
|
||||
if cumple_sede and cumple_prog:
|
||||
datos_filtrados.append(d)
|
||||
datos = datos_filtrados
|
||||
|
||||
if not datos: return []
|
||||
|
||||
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||
sql_cuotas = self._get_sql_cuotas()
|
||||
|
||||
matriculados_raw = []
|
||||
cuotas_raw = []
|
||||
|
||||
if sql_matriculas and sql_cuotas:
|
||||
try:
|
||||
conn = self.data_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql_matriculas, ano, mes)
|
||||
cols_mat = [c[0] for c in cursor.description]
|
||||
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(sql_cuotas, ano, mes)
|
||||
cols_cuo = [c[0] for c in cursor.description]
|
||||
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"❌ Error en consultas DAX: {e}")
|
||||
|
||||
promedios_dax = self._calcular_promedios_dax(matriculados_raw, cuotas_raw)
|
||||
|
||||
for d in datos:
|
||||
idx = str(d.get('num_indice', '')).strip()
|
||||
if idx.endswith('.0'): idx = idx[:-2]
|
||||
|
||||
if idx in promedios_dax:
|
||||
d['Promedio_Cuota'] = promedios_dax[idx]['promedio']
|
||||
d['Suma_Dax_Real'] = promedios_dax[idx]['suma_real']
|
||||
d['Conteo_Dax_Real'] = promedios_dax[idx]['conteo_real']
|
||||
d['Promedio_Desc_E'] = promedios_dax[idx]['promedio_desc_e']
|
||||
d['Suma_Desc_E_Real'] = promedios_dax[idx]['suma_desc_e_real']
|
||||
d['Conteo_Desc_E_Real'] = promedios_dax[idx]['conteo_desc_e_real']
|
||||
d['Valor_Venta'] = promedios_dax[idx]['suma_valor_venta']
|
||||
d['Valor_Venta_Actual'] = promedios_dax[idx]['suma_valor_venta_actual']
|
||||
else:
|
||||
d['Promedio_Cuota'] = 0.0
|
||||
d['Suma_Dax_Real'] = 0.0
|
||||
d['Conteo_Dax_Real'] = 0
|
||||
d['Promedio_Desc_E'] = 0.0
|
||||
d['Suma_Desc_E_Real'] = 0.0
|
||||
d['Conteo_Desc_E_Real'] = 0
|
||||
d['Valor_Venta'] = 0.0
|
||||
d['Valor_Venta_Actual'] = 0.0
|
||||
|
||||
return datos
|
||||
|
||||
def _calcular_promedios_dax(self, matriculados, cuotas):
|
||||
alumnos_dict = {}
|
||||
agrupado_indices = {}
|
||||
|
||||
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||
|
||||
for m in matriculados:
|
||||
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||
|
||||
num_matricula = str(m.get('num_matricula', '')).strip()
|
||||
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||
|
||||
num_indice = str(m.get('num_indice', '')).strip()
|
||||
if num_indice.endswith('.0'): num_indice = num_indice[:-2]
|
||||
|
||||
prog = str(m.get('dsc_programa', ''))
|
||||
try: inv_neta = float(m.get('INV_NETA', 0.0))
|
||||
except: inv_neta = 0.0
|
||||
|
||||
tc_override = None
|
||||
if num_matricula in correcciones_mat:
|
||||
if "imp_tc" in correcciones_mat[num_matricula]:
|
||||
tc_override = float(correcciones_mat[num_matricula]["imp_tc"])
|
||||
|
||||
# TC del COMPROBANTE — único TC para todo (INV_NETA + cuotas)
|
||||
if tc_override is not None:
|
||||
imp_tc_comp = tc_override
|
||||
else:
|
||||
try: imp_tc_comp = float(m.get('imp_tc', 0) or 0)
|
||||
except: imp_tc_comp = 0
|
||||
if imp_tc_comp < 2: imp_tc_comp = 3.45 # TC por defecto
|
||||
|
||||
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||
if num_matricula in correcciones_inv_neta:
|
||||
try: inv_neta = float(correcciones_inv_neta[num_matricula])
|
||||
except: pass
|
||||
else:
|
||||
cod_moneda_mat = str(m.get('cod_moneda', '')).strip().upper()
|
||||
if cod_moneda_mat == "DOL":
|
||||
inv_neta = inv_neta * imp_tc_comp
|
||||
|
||||
clase = self.clasificar_programa(prog)
|
||||
descuento = self.calcular_descuento(clase, inv_neta)
|
||||
estado_desc = self.calcular_estado_descuento(clase, descuento)
|
||||
|
||||
alumnos_dict[num_matricula] = {
|
||||
'num_indice': num_indice,
|
||||
'clasificacion': clase,
|
||||
'programa': prog,
|
||||
'estado_descuento': estado_desc,
|
||||
'inv_neta': inv_neta,
|
||||
'estado_mat': estado_mat,
|
||||
'monto_real_cuotas': 0.0,
|
||||
'imp_tc_comprobante': imp_tc_comp
|
||||
}
|
||||
|
||||
if num_indice not in agrupado_indices:
|
||||
agrupado_indices[num_indice] = {
|
||||
'suma': 0.0, 'conteo': 0,
|
||||
'suma_desc_e': 0.0, 'conteo_desc_e': 0,
|
||||
'suma_valor_venta': 0.0,
|
||||
'suma_valor_venta_actual': 0.0
|
||||
}
|
||||
|
||||
agrupado_indices[num_indice]['suma_valor_venta'] += inv_neta
|
||||
|
||||
# ALU y PRE: suma INV_NETA completa
|
||||
if estado_mat in ['ALU', 'PRE']:
|
||||
agrupado_indices[num_indice]['suma_valor_venta_actual'] += inv_neta
|
||||
# RET: suma solo lo que ya pagó
|
||||
elif estado_mat == 'RET':
|
||||
try: imp_pagado = float(m.get('imp_total_pagado', 0) or 0)
|
||||
except: imp_pagado = 0.0
|
||||
agrupado_indices[num_indice]['suma_valor_venta_actual'] += imp_pagado
|
||||
|
||||
if clase not in lista_mirar_cuotas:
|
||||
agrupado_indices[num_indice]['suma'] += inv_neta
|
||||
agrupado_indices[num_indice]['conteo'] += 1
|
||||
|
||||
for c in cuotas:
|
||||
num_matricula = str(c.get('num_matricula', '')).strip()
|
||||
if num_matricula.endswith('.0'): num_matricula = num_matricula[:-2]
|
||||
|
||||
if num_matricula not in alumnos_dict: continue
|
||||
|
||||
alumno = alumnos_dict[num_matricula]
|
||||
num_idx = alumno['num_indice']
|
||||
|
||||
if alumno['clasificacion'] in lista_mirar_cuotas:
|
||||
try: num_cuota = int(c.get('num_cuota', 0))
|
||||
except: num_cuota = 0
|
||||
|
||||
t_val = c.get('imp_total')
|
||||
d_val = c.get('imp_dscto')
|
||||
|
||||
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||
except: t_monto = 0.0
|
||||
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||
except: d_monto = 0.0
|
||||
|
||||
imp_cuota = t_monto - d_monto
|
||||
|
||||
cod_moneda_cuo = str(c.get('cod_moneda', '')).strip().upper()
|
||||
if cod_moneda_cuo == "DOL":
|
||||
# USAR el TC del COMPROBANTE (ya validado con fallback 3.45)
|
||||
tc_cuo = alumno['imp_tc_comprobante']
|
||||
imp_cuota = imp_cuota * tc_cuo
|
||||
|
||||
cumple_cuota_normal = (alumno['estado_descuento'] == "SI" and num_cuota > 0 and imp_cuota < 1000)
|
||||
cumple_desc_especial = (alumno['estado_descuento'] == "SI" and num_cuota > 0)
|
||||
|
||||
if cumple_cuota_normal:
|
||||
agrupado_indices[num_idx]['suma'] += imp_cuota
|
||||
agrupado_indices[num_idx]['conteo'] += 1
|
||||
|
||||
if cumple_desc_especial:
|
||||
alumno['monto_real_cuotas'] += imp_cuota
|
||||
|
||||
for mat_id, alumno in alumnos_dict.items():
|
||||
idx = alumno['num_indice']
|
||||
clase = alumno['clasificacion']
|
||||
estado_desc = alumno['estado_descuento']
|
||||
|
||||
if clase in lista_mirar_cuotas:
|
||||
monto_real = alumno['monto_real_cuotas']
|
||||
else:
|
||||
monto_real = alumno['inv_neta']
|
||||
|
||||
valor_final = 0.0
|
||||
|
||||
if monto_real >= 1900 and estado_desc == "SI":
|
||||
if clase in ["TEAC", "TERC"]:
|
||||
desc_calc = 3400 - monto_real
|
||||
elif clase in ["AREQUIPA", "PIURA", "TRUJILLO"]:
|
||||
desc_calc = 2740 - monto_real
|
||||
else:
|
||||
desc_calc = 0
|
||||
|
||||
resta = desc_calc - 200
|
||||
valor_final = min(max(resta, 0.0), 200.0)
|
||||
|
||||
agrupado_indices[idx]['suma_desc_e'] += valor_final
|
||||
agrupado_indices[idx]['conteo_desc_e'] += 1
|
||||
|
||||
promedios = {}
|
||||
for idx, totales in agrupado_indices.items():
|
||||
suma_total = totales['suma']
|
||||
conteo_total = totales['conteo']
|
||||
suma_desc_e = totales['suma_desc_e']
|
||||
conteo_desc_e = totales['conteo_desc_e']
|
||||
|
||||
promedios[idx] = {
|
||||
'promedio': suma_total / conteo_total if conteo_total > 0 else 0.0,
|
||||
'suma_real': suma_total,
|
||||
'conteo_real': conteo_total,
|
||||
'promedio_desc_e': suma_desc_e / conteo_desc_e if conteo_desc_e > 0 else 0.0,
|
||||
'suma_desc_e_real': suma_desc_e,
|
||||
'conteo_desc_e_real': conteo_desc_e,
|
||||
'suma_valor_venta': totales['suma_valor_venta'],
|
||||
'suma_valor_venta_actual': totales['suma_valor_venta_actual'] # <-- NUEVO
|
||||
}
|
||||
|
||||
return promedios
|
||||
|
||||
def formatear_datos_para_tabla(self, datos):
|
||||
"""Estructura las filas finales para tksheet (14 columnas)"""
|
||||
if not datos: return []
|
||||
|
||||
cfg_costos = self.data_manager.costos_data
|
||||
if not cfg_costos:
|
||||
cfg_costos = {
|
||||
"epp": {"piura": 28, "trujillo": 28, "arequipa": 28, "programas": 28, "carrera": 28, "otros": 0},
|
||||
"certificado": {"piura": 7, "trujillo": 7, "arequipa": 7, "programas": 7, "carrera": 7, "otros": 0},
|
||||
"consumibles": {"piura": 676, "trujillo": 676, "arequipa": 676, "programas": 614, "carrera": 614, "otros": 0},
|
||||
"marketing": {"piura": 2839, "trujillo": 2839, "arequipa": 2839, "programas": 3499, "carrera": 3499, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 1000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 900, "DIPLOMADO INTERNACIONAL": 1300, "SEMINARIO VIRTUAL METRADO , COSTEO": 800, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 1000, "DEFAULT": 0}},
|
||||
"docente": {"piura": 5115, "trujillo": 5115, "arequipa": 6500, "programas": 4560, "carrera": 4560, "otros": {"VRV/VRF": 4500, "INDUSTRIAL POR AMONIACO (NH3)": 4000, "VIRTUAL SEMINARIO DISEÑO DE CHILLERS": 3000, "DIPLOMADO INTERNACIONAL": 7000, "SEMINARIO VIRTUAL METRADO , COSTEO": 2000, "VIRTUAL SEMINARIO DISEÑO DE CAMARAS": 4000, "DEFAULT": 0}}
|
||||
}
|
||||
|
||||
# CARGA OVERRIDES DESDE SUPABASE (una sola vez por render)
|
||||
overrides_supabase = self.data_manager.cargar_overrides_costos()
|
||||
|
||||
sheet_data = []
|
||||
tot_total = tot_retirados = tot_curso = 0
|
||||
tot_venta = 0.0
|
||||
tot_venta_actual = 0.0
|
||||
tot_costo = 0.0
|
||||
tot_costo_actual = 0.0
|
||||
|
||||
bolsa_plata_global = 0.0
|
||||
bolsa_recibos_global = 0
|
||||
bolsa_desc_e_plata_global = 0.0
|
||||
bolsa_desc_e_recibos_global = 0
|
||||
|
||||
for d in datos:
|
||||
try: total_inscritos = int(d.get('Inscritos_Totales', 0))
|
||||
except: total_inscritos = 0
|
||||
try: retirados = int(d.get('Retirados', 0))
|
||||
except: retirados = 0
|
||||
try: en_curso = int(d.get('Inscritos_En_Curso', d.get('Inscritos_Activos', 0)))
|
||||
except: en_curso = 0
|
||||
|
||||
p_cuota = float(d.get('Promedio_Cuota', 0.0))
|
||||
p_desc = float(d.get('Promedio_Desc_E', 0.0))
|
||||
venta = float(d.get('Valor_Venta', 0.0))
|
||||
venta_actual = float(d.get('Valor_Venta_Actual', 0.0))
|
||||
|
||||
conteo_cuota_real = int(d.get('Conteo_Dax_Real', 0))
|
||||
str_cuota_tabla = f"S/ {p_cuota:,.0f}" if conteo_cuota_real > 0 else ""
|
||||
|
||||
conteo_desc_e = int(d.get('Conteo_Desc_E_Real', 0))
|
||||
str_desc_e_tabla = f"S/ {p_desc:,.0f}" if conteo_desc_e > 0 else ""
|
||||
|
||||
cat = d.get('Categoria_Programa', 'OTROS')
|
||||
nombre_prog_crudo = d.get('dsc_programa', '')
|
||||
|
||||
# Identificador de curso para buscar override
|
||||
num_idx_curso = str(d.get('num_indice', '')).strip()
|
||||
if num_idx_curso.endswith('.0'): num_idx_curso = num_idx_curso[:-2]
|
||||
ov_curso = overrides_supabase.get(num_idx_curso, {})
|
||||
ov_inicial = ov_curso.get('inicial', {})
|
||||
ov_actual = ov_curso.get('actual', {})
|
||||
|
||||
# ============= COSTO INICIAL (con override si existe) =============
|
||||
def _costo_inicial(key_supa, key_cfg, multiplicar):
|
||||
val = ov_inicial.get(key_supa)
|
||||
if val is not None:
|
||||
return float(val) # Supabase manda directo, sin multiplicar
|
||||
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||
return total_inscritos * base if multiplicar else base
|
||||
|
||||
costo_epp = _costo_inicial('epp', 'epp', True)
|
||||
costo_cert = _costo_inicial('certificado', 'certificado', True)
|
||||
costo_cons = _costo_inicial('consumibles', 'consumibles', True)
|
||||
costo_mkt = _costo_inicial('marketing', 'marketing', False)
|
||||
costo_doc = _costo_inicial('docente', 'docente', False)
|
||||
valor_costo_inicial = costo_epp + costo_cert + costo_cons + costo_mkt + costo_doc
|
||||
|
||||
# ============= COSTO ACTUAL (con override si existe) =============
|
||||
def _costo_actual(key_supa, key_cfg, multiplicador):
|
||||
val = ov_actual.get(key_supa)
|
||||
if val is not None:
|
||||
return float(val) # Supabase manda directo, sin multiplicar
|
||||
base = self._obtener_costo_segun_categoria(cfg_costos.get(key_cfg, {}), cat, nombre_prog_crudo)
|
||||
return multiplicador * base if multiplicador is not None else base
|
||||
|
||||
costo_epp_actual = _costo_actual('epp', 'epp', total_inscritos)
|
||||
costo_cert_actual = _costo_actual('certificado', 'certificado', en_curso)
|
||||
costo_cons_actual = _costo_actual('consumibles', 'consumibles', total_inscritos)
|
||||
costo_mkt_actual = _costo_actual('marketing', 'marketing', None)
|
||||
costo_doc_actual = _costo_actual('docente', 'docente', None)
|
||||
costo_actual = costo_epp_actual + costo_cert_actual + costo_cons_actual + costo_mkt_actual + costo_doc_actual
|
||||
|
||||
# MARGEN BRUTO %
|
||||
if venta > 0:
|
||||
margen_bruto = 1 - (valor_costo_inicial / venta)
|
||||
str_margen = f"{margen_bruto * 100:,.1f}%"
|
||||
else:
|
||||
str_margen = ""
|
||||
|
||||
# MARGEN BRUTO ACTUAL %
|
||||
if venta_actual > 0:
|
||||
margen_bruto_actual = 1 - (costo_actual / venta_actual)
|
||||
str_margen_actual = f"{margen_bruto_actual * 100:,.1f}%"
|
||||
else:
|
||||
str_margen_actual = ""
|
||||
|
||||
tot_total += total_inscritos
|
||||
tot_retirados += retirados
|
||||
tot_curso += en_curso
|
||||
tot_venta += venta
|
||||
tot_venta_actual += venta_actual
|
||||
tot_costo += valor_costo_inicial
|
||||
tot_costo_actual += costo_actual
|
||||
|
||||
bolsa_plata_global += float(d.get('Suma_Dax_Real', 0.0))
|
||||
bolsa_recibos_global += int(d.get('Conteo_Dax_Real', 0))
|
||||
bolsa_desc_e_plata_global += float(d.get('Suma_Desc_E_Real', 0.0))
|
||||
bolsa_desc_e_recibos_global += int(d.get('Conteo_Desc_E_Real', 0))
|
||||
|
||||
nombre_prog = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||
|
||||
fila = [
|
||||
nombre_prog,
|
||||
d.get('fch_inicio', ''),
|
||||
total_inscritos,
|
||||
retirados,
|
||||
en_curso,
|
||||
str_cuota_tabla,
|
||||
str_desc_e_tabla,
|
||||
f"S/ {venta:,.0f}",
|
||||
f"S/ {valor_costo_inicial:,.0f}",
|
||||
str_margen,
|
||||
f"S/ {venta_actual:,.0f}",
|
||||
f"S/ {costo_actual:,.0f}",
|
||||
str_margen_actual,
|
||||
" ≡ ▼ "
|
||||
]
|
||||
sheet_data.append(fila)
|
||||
|
||||
promedio_total_final = (bolsa_plata_global / bolsa_recibos_global) if bolsa_recibos_global > 0 else 0.0
|
||||
promedio_desc_e_final = (bolsa_desc_e_plata_global / bolsa_desc_e_recibos_global) if bolsa_desc_e_recibos_global > 0 else 0.0
|
||||
|
||||
str_total_cuota = f"S/ {promedio_total_final:,.0f}" if bolsa_recibos_global > 0 else ""
|
||||
str_total_desc_e = f"S/ {promedio_desc_e_final:,.0f}" if bolsa_desc_e_recibos_global > 0 else ""
|
||||
|
||||
margen_total = 1 - (tot_costo / tot_venta) if tot_venta > 0 else 0
|
||||
margen_total_actual = 1 - (tot_costo_actual / tot_venta_actual) if tot_venta_actual > 0 else 0
|
||||
fila_total = [
|
||||
"TOTAL GENERAL", "",
|
||||
tot_total,
|
||||
tot_retirados,
|
||||
tot_curso,
|
||||
str_total_cuota,
|
||||
str_total_desc_e,
|
||||
f"S/ {tot_venta:,.0f}",
|
||||
f"S/ {tot_costo:,.0f}",
|
||||
f"{margen_total * 100:,.1f}%",
|
||||
f"S/ {tot_venta_actual:,.0f}",
|
||||
f"S/ {tot_costo_actual:,.0f}",
|
||||
f"{margen_total_actual * 100:,.1f}%",
|
||||
""
|
||||
]
|
||||
sheet_data.append(fila_total)
|
||||
|
||||
return sheet_data
|
||||
|
||||
def exportar_detalle_alumnos_excel(self, programa, headers, datos):
|
||||
if not datos:
|
||||
raise ValueError("No hay datos para exportar")
|
||||
|
||||
prog_limpio = "".join([c if c.isalnum() else "_" for c in str(programa)])[:40]
|
||||
archivo = f"Detalle_Alumnos_{prog_limpio}.xlsx"
|
||||
|
||||
df = pd.DataFrame(datos, columns=headers)
|
||||
|
||||
try:
|
||||
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='Detalle_Alumnos')
|
||||
worksheet = writer.sheets['Detalle_Alumnos']
|
||||
|
||||
try:
|
||||
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||
font_header = Font(color="FFFFFF", bold=True)
|
||||
thin_border = Border(
|
||||
left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD")
|
||||
)
|
||||
|
||||
for cell in worksheet[1]:
|
||||
cell.fill = fill_header
|
||||
cell.font = font_header
|
||||
cell.border = thin_border
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
for row in worksheet.iter_rows(min_row=2):
|
||||
for cell in row:
|
||||
cell.border = thin_border
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
|
||||
for col in worksheet.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
df.to_excel(archivo, index=False)
|
||||
|
||||
return archivo
|
||||
|
||||
def obtener_detalle_programa(self, programa_target, ano, mes_numero):
|
||||
prog_t = str(programa_target).strip().upper()
|
||||
|
||||
sql_matriculas = self._get_sql_matriculas_modificado()
|
||||
sql_cuotas = self._get_sql_cuotas()
|
||||
|
||||
matriculados_raw = []
|
||||
cuotas_raw = []
|
||||
|
||||
if sql_matriculas and sql_cuotas:
|
||||
try:
|
||||
conn = self.data_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql_matriculas, ano, mes_numero)
|
||||
cols_mat = [c[0] for c in cursor.description]
|
||||
matriculados_raw = [dict(zip(cols_mat, row)) for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(sql_cuotas, ano, mes_numero)
|
||||
cols_cuo = [c[0] for c in cursor.description]
|
||||
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"❌ Error en consultas detalle: {e}")
|
||||
|
||||
cuotas_por_mat = {}
|
||||
for c in cuotas_raw:
|
||||
m_id = str(c.get('num_matricula', '')).strip()
|
||||
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||
cuotas_por_mat[m_id].append(c)
|
||||
|
||||
cursos = self.processor.obtener_datos_procesados(ano, mes_numero)
|
||||
indices_target = []
|
||||
fecha_inicio_actual = None
|
||||
linea_actual = self.identificar_linea_carrera(prog_t)
|
||||
|
||||
for c in cursos:
|
||||
nombre = str(c.get('programa_frecuencia', c.get('dsc_programa', ''))).strip().upper()
|
||||
if nombre == prog_t:
|
||||
idx = str(c.get('num_indice', '')).strip()
|
||||
if idx.endswith('.0'): idx = idx[:-2]
|
||||
indices_target.append(idx)
|
||||
if not fecha_inicio_actual:
|
||||
fecha_inicio_actual = self.parse_fecha(c.get('fch_inicio'))
|
||||
|
||||
lista_dnis = []
|
||||
for m in matriculados_raw:
|
||||
dni = m.get('dsc_documento')
|
||||
if dni: lista_dnis.append(str(dni).strip())
|
||||
|
||||
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||
except: datos_historial = {}
|
||||
|
||||
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||
correcciones_mat = self.data_manager.config_data.get('correcciones_matriculas', {})
|
||||
correcciones_inv_neta = self.data_manager.config_data.get('Actualizar_INV_NETA', {})
|
||||
|
||||
lista_mirar_cuotas = ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC"]
|
||||
|
||||
acum_inv_neta_global = 0.0
|
||||
acum_suma_cuotas_global = 0.0
|
||||
acum_cont_cuotas_global = 0
|
||||
acum_suma_desc_e_global = 0.0
|
||||
acum_cont_desc_e_global = 0
|
||||
|
||||
alumnos_lista = []
|
||||
|
||||
for m in matriculados_raw:
|
||||
estado_mat = str(m.get('estado_matricula', '')).strip().upper()
|
||||
if estado_mat not in ['ALU', 'PRE', 'RET']: continue
|
||||
|
||||
idx_mat = str(m.get('num_indice', '')).strip()
|
||||
if idx_mat.endswith('.0'): idx_mat = idx_mat[:-2]
|
||||
|
||||
nombre_prog_crudo = str(m.get('dsc_programa', '')).strip().upper()
|
||||
|
||||
if (idx_mat in indices_target) or (prog_t in nombre_prog_crudo) or (nombre_prog_crudo in prog_t):
|
||||
|
||||
vendedor = str(m.get('dsc_vendedor', 'SIN VENDEDOR')).strip()
|
||||
if vendedor == "None" or not vendedor: vendedor = "SIN VENDEDOR"
|
||||
|
||||
alumno_nombre = str(m.get('nombre_alumno', m.get('dsc_alumno', 'SIN NOMBRE'))).strip()
|
||||
if alumno_nombre == "None" or not alumno_nombre: alumno_nombre = "SIN NOMBRE"
|
||||
|
||||
mat_id = str(m.get('num_matricula', '')).strip()
|
||||
if mat_id.endswith('.0'): mat_id = mat_id[:-2]
|
||||
dni = str(m.get('dsc_documento', '')).strip()
|
||||
|
||||
try: inv_neta_raw = float(m.get('INV_NETA', 0.0))
|
||||
except: inv_neta_raw = 0.0
|
||||
cod_moneda = str(m.get('cod_moneda', 'SOL')).strip().upper()
|
||||
try: tc = float(m.get('imp_tc', 0) or 0)
|
||||
except: tc = 0
|
||||
if tc < 2: tc = 3.45 # TC por defecto si comprobante inválido
|
||||
|
||||
fch_canc_raw = m.get('fch_cancelacion_cuota1', '')
|
||||
|
||||
if mat_id in correcciones_mat:
|
||||
datos_corregidos = correcciones_mat[mat_id]
|
||||
if "imp_tc" in datos_corregidos: tc = float(datos_corregidos["imp_tc"])
|
||||
if "fch_cancelacion_cuota1" in datos_corregidos: fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||
|
||||
# CORRECCIÓN MANUAL DE INV_NETA (siempre en SOL, sin multiplicar)
|
||||
if mat_id in correcciones_inv_neta:
|
||||
try: inv_neta_soles = float(correcciones_inv_neta[mat_id])
|
||||
except: inv_neta_soles = inv_neta_raw
|
||||
elif cod_moneda == "DOL":
|
||||
inv_neta_soles = inv_neta_raw * tc
|
||||
else:
|
||||
inv_neta_soles = inv_neta_raw
|
||||
inv_neta_soles = round(inv_neta_soles, 2)
|
||||
|
||||
acum_inv_neta_global += inv_neta_soles
|
||||
|
||||
fch_mat = m.get('fch_matricula', '')
|
||||
fecha_mat_limpia = ""
|
||||
if fch_mat:
|
||||
try:
|
||||
if hasattr(fch_mat, 'strftime'): fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||
else:
|
||||
tmp = str(fch_mat)[:10]
|
||||
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fecha_mat_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||
else: fecha_mat_limpia = tmp
|
||||
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||
|
||||
fch_canc_limpia = ""
|
||||
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||
try:
|
||||
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||
else:
|
||||
tmp = str(fch_canc_raw)[:10]
|
||||
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||
else: fch_canc_limpia = tmp
|
||||
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||
|
||||
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||
|
||||
es_refriperu = False
|
||||
if mat_id in correcciones_desc:
|
||||
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||
else:
|
||||
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||
elif clase_alumno in ["TEAC", "TERC"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||
|
||||
tipo_final = "NUEVO"
|
||||
if es_refriperu: tipo_final = "REFRIPERU"
|
||||
else:
|
||||
es_cont = False
|
||||
if mat_id in correcciones_cont:
|
||||
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||
else:
|
||||
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||
for antecedente in datos_historial[dni]:
|
||||
nombre_pasado = str(antecedente.get('programa', ''))
|
||||
fecha_pasada = antecedente.get('fecha')
|
||||
if not fecha_pasada: continue
|
||||
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||
if (fecha_inicio_actual - fecha_pasada).days < 60: continue
|
||||
if self.identificar_linea_carrera(nombre_pasado) == linea_actual:
|
||||
es_cont = True; break
|
||||
if es_cont: tipo_final = "CONTINUIDAD"
|
||||
|
||||
descuento = self.calcular_descuento(clase_alumno, inv_neta_soles)
|
||||
estado_desc = self.calcular_estado_descuento(clase_alumno, descuento)
|
||||
|
||||
str_cuota_val = ""
|
||||
monto_eval = 0.0
|
||||
|
||||
if clase_alumno in lista_mirar_cuotas:
|
||||
suma_c = 0.0; cont_c = 0; monto_real_c = 0.0
|
||||
for c_dict in cuotas_por_mat.get(mat_id, []):
|
||||
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||
except: num_c = 0
|
||||
t_val = c_dict.get('imp_total'); d_val = c_dict.get('imp_dscto')
|
||||
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||
except: t_monto = 0.0
|
||||
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||
except: d_monto = 0.0
|
||||
imp_c = t_monto - d_monto
|
||||
tc_over = None
|
||||
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||
cod_m_c = str(c_dict.get('cod_moneda', '')).strip().upper()
|
||||
if cod_m_c == "DOL":
|
||||
# USAR TC del COMPROBANTE (no del cronograma)
|
||||
tc_c = tc # tc ya está validado arriba (línea 654)
|
||||
imp_c *= tc_c
|
||||
if estado_desc == "SI" and num_c > 0 and imp_c < 1000:
|
||||
suma_c += imp_c; cont_c += 1
|
||||
if estado_desc == "SI" and num_c > 0:
|
||||
monto_real_c += imp_c
|
||||
if cont_c > 0:
|
||||
str_cuota_val = f"S/ {suma_c / cont_c:,.0f}"
|
||||
monto_eval = monto_real_c
|
||||
acum_suma_cuotas_global += suma_c
|
||||
acum_cont_cuotas_global += cont_c
|
||||
else:
|
||||
str_cuota_val = f"S/ {inv_neta_soles:,.0f}"
|
||||
monto_eval = inv_neta_soles
|
||||
acum_suma_cuotas_global += inv_neta_soles
|
||||
acum_cont_cuotas_global += 1
|
||||
|
||||
str_desc_e_val = ""
|
||||
if monto_eval >= 1900 and estado_desc == "SI":
|
||||
if clase_alumno in ["TEAC", "TERC"]: d_calc = 3400 - monto_eval
|
||||
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - monto_eval
|
||||
else: d_calc = 0
|
||||
desc_e_val = min(max(d_calc - 200, 0.0), 200.0)
|
||||
str_desc_e_val = f"S/ {desc_e_val:,.0f}"
|
||||
acum_suma_desc_e_global += desc_e_val
|
||||
acum_cont_desc_e_global += 1
|
||||
|
||||
alumnos_lista.append([
|
||||
vendedor, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||
estado_mat # columna oculta para detectar RET
|
||||
])
|
||||
|
||||
alumnos_lista.sort(key=lambda x: x[1])
|
||||
|
||||
prom_gral_cuota = (acum_suma_cuotas_global / acum_cont_cuotas_global) if acum_cont_cuotas_global > 0 else 0.0
|
||||
prom_gral_desc = (acum_suma_desc_e_global / acum_cont_desc_e_global) if acum_cont_desc_e_global > 0 else 0.0
|
||||
|
||||
str_gral_cuota = f"S/ {prom_gral_cuota:,.0f}" if acum_cont_cuotas_global > 0 else ""
|
||||
str_gral_desc = f"S/ {prom_gral_desc:,.0f}" if acum_cont_desc_e_global > 0 else ""
|
||||
|
||||
alumnos_lista.append([
|
||||
"TOTAL GENERAL", "", "", "", "",
|
||||
str_gral_cuota, str_gral_desc, f"S/ {acum_inv_neta_global:,.0f}",
|
||||
"" # columna oculta extra para TOTAL
|
||||
])
|
||||
|
||||
return alumnos_lista
|
||||
|
||||
def exportar_a_excel(self, datos, ano, mes):
|
||||
if not datos: raise ValueError("No hay datos para exportar")
|
||||
df = pd.DataFrame(datos)
|
||||
archivo = f"reporte_rentabilidad_{ano}_{mes}.xlsx"
|
||||
df.to_excel(archivo, index=False)
|
||||
return archivo
|
||||
|
||||
def _get_sql_matriculas_modificado(self):
|
||||
if not hasattr(self, '_sql_matriculas_cache') or not self._sql_matriculas_cache:
|
||||
sql = self.data_manager.query_matriculas_sql
|
||||
if not sql: return ""
|
||||
sql = re.sub(
|
||||
r'YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||
'YEAR(sgede_RP_programa.fch_inicio)',
|
||||
sql, flags=re.IGNORECASE
|
||||
)
|
||||
sql = re.sub(
|
||||
r'MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)',
|
||||
'MONTH(sgede_RP_programa.fch_inicio)',
|
||||
sql, flags=re.IGNORECASE
|
||||
)
|
||||
self._sql_matriculas_cache = sql
|
||||
return self._sql_matriculas_cache
|
||||
|
||||
def _get_sql_cuotas(self):
|
||||
if not hasattr(self, '_sql_cuotas_cache') or not self._sql_cuotas_cache:
|
||||
try:
|
||||
res = requests.get(self.data_manager.github_cuota_url)
|
||||
self._sql_cuotas_cache = res.text
|
||||
except:
|
||||
self._sql_cuotas_cache = ""
|
||||
return self._sql_cuotas_cache
|
||||
def obtener_datos_costos_programa(self, programa, ano, mes):
|
||||
"""Devuelve el desglose de costos e ingresos para el modal Editar"""
|
||||
datos = self.obtener_datos_procesados(ano, mes)
|
||||
if not datos: return None
|
||||
|
||||
curso = None
|
||||
for d in datos:
|
||||
nombre = d.get('programa_frecuencia', d.get('dsc_programa', ''))
|
||||
if str(nombre).strip() == str(programa).strip():
|
||||
curso = d; break
|
||||
if not curso: return None
|
||||
|
||||
cfg_costos = self.data_manager.costos_data
|
||||
if not cfg_costos: return None
|
||||
|
||||
# OVERRIDES desde Supabase
|
||||
num_idx = str(curso.get('num_indice', '')).strip()
|
||||
if num_idx.endswith('.0'): num_idx = num_idx[:-2]
|
||||
overrides = self.data_manager.cargar_overrides_costos()
|
||||
ov_curso = overrides.get(num_idx, {})
|
||||
ov_inicial = ov_curso.get('inicial', {})
|
||||
ov_actual = ov_curso.get('actual', {})
|
||||
|
||||
cat = curso.get('Categoria_Programa', 'OTROS')
|
||||
prog_crudo = curso.get('dsc_programa', '')
|
||||
try: total_inscritos = int(curso.get('Inscritos_Totales', 0))
|
||||
except: total_inscritos = 0
|
||||
try: en_curso = int(curso.get('Inscritos_En_Curso', curso.get('Inscritos_Activos', 0)))
|
||||
except: en_curso = 0
|
||||
|
||||
epp_u = self._obtener_costo_segun_categoria(cfg_costos.get("epp", {}), cat, prog_crudo)
|
||||
cert_u = self._obtener_costo_segun_categoria(cfg_costos.get("certificado", {}), cat, prog_crudo)
|
||||
cons_u = self._obtener_costo_segun_categoria(cfg_costos.get("consumibles", {}), cat, prog_crudo)
|
||||
mkt = self._obtener_costo_segun_categoria(cfg_costos.get("marketing", {}), cat, prog_crudo)
|
||||
doc = self._obtener_costo_segun_categoria(cfg_costos.get("docente", {}), cat, prog_crudo)
|
||||
|
||||
# INICIAL
|
||||
epp_i = float(ov_inicial['epp']) if ov_inicial.get('epp') is not None else total_inscritos * epp_u
|
||||
cert_i = float(ov_inicial['certificado']) if ov_inicial.get('certificado') is not None else total_inscritos * cert_u
|
||||
cons_i = float(ov_inicial['consumibles']) if ov_inicial.get('consumibles') is not None else total_inscritos * cons_u
|
||||
mkt_i = float(ov_inicial['marketing']) if ov_inicial.get('marketing') is not None else mkt
|
||||
doc_i = float(ov_inicial['docente']) if ov_inicial.get('docente') is not None else doc
|
||||
costo_i = epp_i + cert_i + cons_i + mkt_i + doc_i
|
||||
|
||||
# ACTUAL
|
||||
epp_a = float(ov_actual['epp']) if ov_actual.get('epp') is not None else total_inscritos * epp_u
|
||||
cert_a = float(ov_actual['certificado']) if ov_actual.get('certificado') is not None else en_curso * cert_u
|
||||
cons_a = float(ov_actual['consumibles']) if ov_actual.get('consumibles') is not None else total_inscritos * cons_u
|
||||
mkt_a = float(ov_actual['marketing']) if ov_actual.get('marketing') is not None else mkt
|
||||
doc_a = float(ov_actual['docente']) if ov_actual.get('docente') is not None else doc
|
||||
costo_a = epp_a + cert_a + cons_a + mkt_a + doc_a
|
||||
|
||||
vi = float(curso.get('Valor_Venta', 0.0))
|
||||
va = float(curso.get('Valor_Venta_Actual', 0.0))
|
||||
|
||||
mbi = (1 - costo_i / vi) * 100 if vi > 0 else 0.0
|
||||
mba = (1 - costo_a / va) * 100 if va > 0 else 0.0
|
||||
|
||||
return {
|
||||
'num_indice': num_idx,
|
||||
'venta_inicial': vi, 'costo_inicial': costo_i, 'mb_inicial': mbi,
|
||||
'venta_actual': va, 'costo_actual': costo_a, 'mb_actual': mba,
|
||||
'epp_inicial': epp_i, 'cert_inicial': cert_i, 'cons_inicial': cons_i,
|
||||
'mkt_inicial': mkt_i, 'doc_inicial': doc_i,
|
||||
'epp_actual': epp_a, 'cert_actual': cert_a, 'cons_actual': cons_a,
|
||||
'mkt_actual': mkt_a, 'doc_actual': doc_a,
|
||||
# Lo que ya estaba en Supabase, para detectar qué fue editado
|
||||
'ov_inicial': ov_inicial,
|
||||
'ov_actual': ov_actual,
|
||||
}
|
||||
290
backend/modules/rentabilidad/processor.py
Normal file
290
backend/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
|
||||
0
backend/modules/saldo_pendiente/__init__.py
Normal file
0
backend/modules/saldo_pendiente/__init__.py
Normal file
193
backend/modules/saldo_pendiente/logic.py
Normal file
193
backend/modules/saldo_pendiente/logic.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# modules/saldo_pendiente/logic.py
|
||||
from .processor import SaldoProcessor
|
||||
from datetime import datetime
|
||||
|
||||
class SaldoLogic:
|
||||
"""
|
||||
Controlador lógico optimizado.
|
||||
1. Filtro Global Manual (lista_saldo_pendiente).
|
||||
2. Filtro REFRIPERU (Precio Neto < 1900/1000 o Etiquetas).
|
||||
3. Filtros de Negocio (Vendedor, Fecha, Estado).
|
||||
"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self.processor = SaldoProcessor(self.data_manager)
|
||||
|
||||
def obtener_saldos_consolidados(self, tipo_cuota):
|
||||
# 1. Recargar Configuración (Para leer tu nueva lista en vivo)
|
||||
try: self.data_manager.cargar_toda_configuracion()
|
||||
except: pass
|
||||
|
||||
all_debtors = []
|
||||
years_to_scan = [2025, 2026]
|
||||
|
||||
print(f"⚡ [Logic] Iniciando carga para {tipo_cuota}...")
|
||||
|
||||
for year in years_to_scan:
|
||||
try:
|
||||
# Usamos la consulta anual optimizada
|
||||
raw_year_data = self.data_manager.ejecutar_consulta_saldos_anual(str(year))
|
||||
if raw_year_data:
|
||||
datos_procesados = self._procesar_lote_local(raw_year_data, tipo_cuota)
|
||||
all_debtors.extend(datos_procesados)
|
||||
except AttributeError:
|
||||
print("⚠️ Error: DataManager no actualizado.")
|
||||
continue
|
||||
|
||||
# Ordenar alfabéticamente
|
||||
all_debtors.sort(key=lambda x: (x.get('VENDEDOR', '') or "ZZZ", x.get('ALUMNO', '') or ""))
|
||||
print(f"✅ Carga Finalizada. Total en tabla: {len(all_debtors)}")
|
||||
return all_debtors
|
||||
|
||||
def _procesar_lote_local(self, raw_data, tipo_cuota):
|
||||
datos_limpios = []
|
||||
|
||||
# --- Configuración ---
|
||||
ahora = datetime.now()
|
||||
ano_actual = ahora.year
|
||||
mes_actual = ahora.month
|
||||
|
||||
config = self.data_manager.config_data
|
||||
|
||||
# 1. CARGAMOS TUS LISTAS DEL JSON
|
||||
correcciones_refri = config.get("correcciones_descuento", {})
|
||||
exclusion_manual_global = config.get("lista_saldo_pendiente", {}) # <--- TU NUEVA LISTA
|
||||
|
||||
# 2. Lista Vendedores Permitidos
|
||||
lista_raw = config.get("lista_pendientes", [])
|
||||
if not lista_raw:
|
||||
lista_raw = ["AGUILAR U. JUAN CARLOS", "CHAVEZ P. DIANA", "HUAMAN C. ALONSO AGUSTIN",
|
||||
"LA ROSA C. VERONICA ASTRID", "LAZARO Q. DIEGO ARTURO",
|
||||
"MONTOYA D. CARMEN ISABEL", "PERALTA C. ALMENDRA LUCIA"]
|
||||
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||
|
||||
# 3. Mapeo de columnas
|
||||
mapa_cols = {
|
||||
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||
"4° Cuota": ("imp_saldo_cuota4", "fch_venc_cuota4"),
|
||||
"5° Cuota": ("imp_saldo_cuota5", "fch_venc_cuota5"),
|
||||
}
|
||||
col_saldo, col_venc = mapa_cols.get(tipo_cuota, (None, None))
|
||||
if not col_saldo: return []
|
||||
|
||||
for row in raw_data:
|
||||
# Identificadores
|
||||
num_mat = str(row.get('num_matricula', '')).split('.')[0].strip()
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 0: EXCLUSIÓN GLOBAL MANUAL (TU NUEVO PEDIDO)
|
||||
# =================================================================
|
||||
# Si la matrícula está en "lista_saldo_pendiente" con valor "NO", ADIÓS.
|
||||
if num_mat in exclusion_manual_global:
|
||||
val_excl = str(exclusion_manual_global[num_mat]).strip().upper()
|
||||
if val_excl == "NO":
|
||||
continue # Se salta inmediatamente, no importa nada más.
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 1: DETECCIÓN DE REFRIPERU (PRECIO NETO / ETIQUETAS)
|
||||
# =================================================================
|
||||
es_refriperu = False
|
||||
|
||||
# A) Manual (correcciones_descuento)
|
||||
if num_mat in correcciones_refri:
|
||||
val = str(correcciones_refri[num_mat]).strip().upper()
|
||||
if val == "SI": es_refriperu = True
|
||||
elif val == "NO": es_refriperu = False # Forzamos a cobrar
|
||||
else:
|
||||
# B) Automático (Etiquetas)
|
||||
dsc_beca = str(row.get('dsc_beca', '')).strip().upper()
|
||||
dsc_prog = str(row.get('dsc_programa', '')).strip().upper()
|
||||
dsc_prom = str(row.get('dsc_promocion', '')).strip().upper()
|
||||
|
||||
if 'REFRIPERU' in dsc_beca or '100%' in dsc_beca or 'BECA' in dsc_beca:
|
||||
es_refriperu = True
|
||||
elif 'REFRIPERU' in dsc_prog or 'REFRIPERU' in dsc_prom:
|
||||
es_refriperu = True
|
||||
|
||||
# C) Automático por PRECIO FINAL (INV_NETA)
|
||||
if not es_refriperu:
|
||||
try:
|
||||
# Usamos INV_NETA (Precio Real)
|
||||
inv_neta = float(row.get('INV_NETA', 0))
|
||||
except:
|
||||
inv_neta = 0.0
|
||||
|
||||
if inv_neta > 0:
|
||||
# Rango Técnicos/Especialistas
|
||||
if ("TECNICO" in dsc_prog or "ESPECIALISTA" in dsc_prog or "TEAC" in dsc_prog):
|
||||
if inv_neta < 1900: es_refriperu = True # ej: 1799
|
||||
|
||||
# Rango Gestión/Ventas
|
||||
elif "GESTION" in dsc_prog or "VENTA" in dsc_prog:
|
||||
if inv_neta < 1000: es_refriperu = True
|
||||
|
||||
if es_refriperu:
|
||||
continue
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTROS ESTÁNDAR (VENDEDOR, ESTADO, FECHA)
|
||||
# =================================================================
|
||||
|
||||
# Vendedor
|
||||
vendedor = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||
if lista_permitidos:
|
||||
encontrado = False
|
||||
if vendedor in lista_permitidos: encontrado = True
|
||||
else:
|
||||
for p in lista_permitidos:
|
||||
if p.replace(" ","") in vendedor.replace(" ",""):
|
||||
encontrado = True; break
|
||||
if not encontrado: continue
|
||||
|
||||
# Estado
|
||||
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||
if estado not in ('ALU', 'PRE'): continue
|
||||
|
||||
# Fecha Futura (Solo 2da cuota en adelante)
|
||||
if tipo_cuota != "1° Cuota":
|
||||
fch_raw = row.get(col_venc)
|
||||
if fch_raw:
|
||||
try:
|
||||
if isinstance(fch_raw, str): f_obj = datetime.strptime(fch_raw[:10], '%Y-%m-%d')
|
||||
else: f_obj = fch_raw
|
||||
if f_obj.year > ano_actual: continue
|
||||
elif f_obj.year == ano_actual and f_obj.month > mes_actual: continue
|
||||
except: pass
|
||||
|
||||
# Saldo Positivo
|
||||
try: val = float(row.get(col_saldo, 0))
|
||||
except: val = 0.0
|
||||
if val <= 0.1: continue
|
||||
|
||||
# --- 4. Construcción de Fila ---
|
||||
def fmt(v):
|
||||
if not v: return ""
|
||||
if isinstance(v, str): return v[:10]
|
||||
if isinstance(v, datetime): return v.strftime('%d-%m-%Y')
|
||||
return str(v)
|
||||
|
||||
fila = {
|
||||
'MATRICULA': row.get('num_matricula'),
|
||||
'VENDEDOR': row.get('dsc_vendedor'),
|
||||
'ALUMNO': row.get('dsc_alumno'),
|
||||
'PROGRAMA': row.get('dsc_promocion'),
|
||||
'FECHA INICIO': fmt(row.get('fch_inicio')),
|
||||
'FECHA MATR.': fmt(row.get('fch_matricula')),
|
||||
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||
f'SALDO {tipo_cuota.upper()}': val,
|
||||
'VENCIMIENTO': fmt(row.get(col_venc)),
|
||||
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||
'DNI': row.get('dsc_documento', ''),
|
||||
'CELULAR': row.get('dsc_telefono_1', '')
|
||||
}
|
||||
datos_limpios.append(fila)
|
||||
|
||||
return datos_limpios
|
||||
|
||||
# Métodos legacy
|
||||
def obtener_saldos(self, ano, mes, tipo_cuota): return self.processor.obtener_datos_procesados(ano, mes, tipo_cuota)
|
||||
def get_current_year(self): return self.data_manager.get_current_year()
|
||||
def get_current_month(self): return self.data_manager.get_current_month()
|
||||
162
backend/modules/saldo_pendiente/processor.py
Normal file
162
backend/modules/saldo_pendiente/processor.py
Normal file
@@ -0,0 +1,162 @@
|
||||
# modules/saldo_pendiente/processor.py
|
||||
from datetime import datetime
|
||||
|
||||
class SaldoProcessor:
|
||||
"""
|
||||
Procesador de lógica de negocio para Saldos Pendientes.
|
||||
Filtra estrictamente por estado ALU/PRE, saldos positivos,
|
||||
vendedores permitidos y FECHA DE VENCIMIENTO (No mostrar futuro).
|
||||
"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
|
||||
def obtener_datos_procesados(self, ano, mes, tipo_cuota):
|
||||
"""
|
||||
Recupera datos crudos y aplica los filtros de negocio.
|
||||
"""
|
||||
# 1. Traer la data cruda desde DataManager
|
||||
raw_data = self.data_manager.ejecutar_consulta_saldos_pendientes(ano, mes)
|
||||
|
||||
datos_limpios = []
|
||||
|
||||
# 2. Obtener fecha actual para saber qué es "futuro"
|
||||
ahora = datetime.now()
|
||||
ano_actual = ahora.year
|
||||
mes_actual = ahora.month
|
||||
|
||||
# 3. Cargar la "Lista Blanca" de vendedores desde el JSON
|
||||
config = self.data_manager.config_data
|
||||
lista_raw = config.get("lista_pendientes", [])
|
||||
|
||||
# RESPALDO DE EMERGENCIA
|
||||
if not lista_raw:
|
||||
lista_raw = [
|
||||
"AGUILAR U. JUAN CARLOS",
|
||||
"CHAVEZ P. DIANA",
|
||||
"HUAMAN C. ALONSO AGUSTIN",
|
||||
"LA ROSA C. VERONICA ASTRID",
|
||||
"LAZARO Q. DIEGO ARTURO",
|
||||
"MONTOYA D. CARMEN ISABEL",
|
||||
"PERALTA C. ALMENDRA LUCIA"
|
||||
]
|
||||
|
||||
# Normalizamos la lista
|
||||
lista_permitidos = set([str(v).strip().upper() for v in lista_raw])
|
||||
|
||||
# 4. Mapeo de columnas según la selección
|
||||
mapa_columnas = {
|
||||
"1° Cuota": ("imp_saldo_cuota1", "fch_venc_cuota1"),
|
||||
"2° Cuota": ("imp_saldo_cuota2", "fch_venc_cuota2"),
|
||||
"3° Cuota": ("imp_saldo_cuota3", "fch_venc_cuota3"),
|
||||
}
|
||||
|
||||
col_saldo_target, col_venc_target = mapa_columnas.get(tipo_cuota, (None, None))
|
||||
|
||||
if not col_saldo_target:
|
||||
return []
|
||||
|
||||
for row in raw_data:
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 1: VENDEDOR PERMITIDO
|
||||
# =================================================================
|
||||
vendedor_actual = str(row.get('dsc_vendedor', '')).strip().upper()
|
||||
|
||||
if lista_permitidos:
|
||||
if vendedor_actual not in lista_permitidos:
|
||||
# Búsqueda parcial por si hay errores de espacios
|
||||
encontrado = False
|
||||
for permitido in lista_permitidos:
|
||||
v_norm = vendedor_actual.replace(" ", "")
|
||||
p_norm = permitido.replace(" ", "")
|
||||
if p_norm in v_norm:
|
||||
encontrado = True
|
||||
break
|
||||
if not encontrado:
|
||||
continue
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 2: ESTADO (ALU/PRE)
|
||||
# =================================================================
|
||||
estado = str(row.get('estado_matricula', '')).strip().upper()
|
||||
if estado not in ('ALU', 'PRE'):
|
||||
continue
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 3: FECHA DE VENCIMIENTO (NO MOSTRAR FUTURO)
|
||||
# =================================================================
|
||||
# Este filtro aplica PRINCIPALMENTE para 2° Cuota en adelante.
|
||||
# (Aunque la lógica es válida para todas, la 1° suele ser inmediata).
|
||||
|
||||
if tipo_cuota != "1° Cuota":
|
||||
fch_venc_raw = row.get(col_venc_target)
|
||||
|
||||
es_futuro = False
|
||||
if fch_venc_raw:
|
||||
try:
|
||||
# Convertir a objeto fecha si es texto
|
||||
if isinstance(fch_venc_raw, str):
|
||||
# Asumimos formato SQL YYYY-MM-DD
|
||||
f_obj = datetime.strptime(fch_venc_raw[:10], '%Y-%m-%d')
|
||||
else:
|
||||
f_obj = fch_venc_raw # Ya es datetime
|
||||
|
||||
venc_ano = f_obj.year
|
||||
venc_mes = f_obj.month
|
||||
|
||||
# LÓGICA DE TIEMPO:
|
||||
# Si el año de vencimiento es mayor al actual -> ES FUTURO
|
||||
if venc_ano > ano_actual:
|
||||
es_futuro = True
|
||||
# Si es el mismo año, pero el mes es mayor al actual -> ES FUTURO
|
||||
elif venc_ano == ano_actual and venc_mes > mes_actual:
|
||||
es_futuro = True
|
||||
|
||||
except Exception as e:
|
||||
# Si falla la fecha, asumimos que no es futuro para no ocultar por error
|
||||
pass
|
||||
|
||||
# Si la cuota vence en el futuro (ej: Marzo cuando estamos en Febrero), LA OCULTAMOS.
|
||||
if es_futuro:
|
||||
continue
|
||||
|
||||
# =================================================================
|
||||
# 🛑 FILTRO 4: SALDO > 0
|
||||
# =================================================================
|
||||
try:
|
||||
val = row.get(col_saldo_target, 0)
|
||||
saldo_a_mostrar = float(val) if val is not None else 0.0
|
||||
except:
|
||||
saldo_a_mostrar = 0.0
|
||||
|
||||
if saldo_a_mostrar <= 0.1:
|
||||
continue
|
||||
|
||||
# --- FORMATEO PARA VISUALIZACIÓN ---
|
||||
def format_date(val):
|
||||
if not val: return ""
|
||||
if isinstance(val, str): return val[:10]
|
||||
if isinstance(val, datetime): return val.strftime('%d-%m-%Y')
|
||||
return str(val)
|
||||
|
||||
f_inicio = format_date(row.get('fch_inicio'))
|
||||
f_matr = format_date(row.get('fch_matricula'))
|
||||
f_vencimiento = format_date(row.get(col_venc_target))
|
||||
|
||||
# --- CONSTRUCCIÓN DE LA FILA FINAL ---
|
||||
fila = {
|
||||
'MATRICULA': row.get('num_matricula'),
|
||||
'VENDEDOR': row.get('dsc_vendedor'),
|
||||
'ALUMNO': row.get('dsc_alumno'),
|
||||
'PROGRAMA': row.get('dsc_promocion'),
|
||||
'FECHA INICIO': f_inicio,
|
||||
'FECHA MATR.': f_matr,
|
||||
'SALDO MAT.': float(row.get('imp_saldo_matricula', 0)),
|
||||
f'SALDO {tipo_cuota.upper()}': saldo_a_mostrar,
|
||||
'VENCIMIENTO': f_vencimiento, # Aquí se verá la fecha (ej: 14-02-2026)
|
||||
'INV. NETA': float(row.get('INV_NETA', 0)),
|
||||
}
|
||||
|
||||
datos_limpios.append(fila)
|
||||
|
||||
return datos_limpios
|
||||
0
backend/modules/ventas/__init__.py
Normal file
0
backend/modules/ventas/__init__.py
Normal file
736
backend/modules/ventas/logic.py
Normal file
736
backend/modules/ventas/logic.py
Normal file
@@ -0,0 +1,736 @@
|
||||
# modules/ventas/logic.py
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import re
|
||||
|
||||
class VentasLogic:
|
||||
"""Clase que maneja la lógica de negocio y procesamiento de datos para Ventas"""
|
||||
|
||||
def __init__(self, data_manager):
|
||||
self.data_manager = data_manager
|
||||
self._config_sedes = None
|
||||
|
||||
def _clasificar_sede_comisiones(self, dsc_programa):
|
||||
"""Clasifica la sede usando EXACTAMENTE la misma lógica de Cobranza (sede.json)."""
|
||||
try:
|
||||
if not hasattr(self, '_cob_processor') or self._cob_processor is None:
|
||||
from modules.cobranza.processor import CobranzaProcessor
|
||||
self._cob_processor = CobranzaProcessor(self.data_manager)
|
||||
return self._cob_processor.clasificar_sede(dsc_programa)
|
||||
except Exception:
|
||||
prog_up = str(dsc_programa or "").upper()
|
||||
for s in ("AREQUIPA", "PIURA", "TRUJILLO"):
|
||||
if s in prog_up:
|
||||
return s
|
||||
return "LIMA"
|
||||
|
||||
def buscar_todos_matriculados(self):
|
||||
"""Devuelve TODOS los matriculados (todos los años/meses) para el buscador.
|
||||
Ejecuta la query de matrículas quitando el filtro de YEAR/MONTH de fch_matricula.
|
||||
Retorna filas con el mismo formato que el detalle (para reusar columnas)."""
|
||||
import re as _re
|
||||
sql_base = self.data_manager.query_matriculas_sql
|
||||
if not sql_base:
|
||||
return []
|
||||
# Quitar las condiciones de año y mes de fch_matricula
|
||||
sql = _re.sub(r"AND\s+YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql_base, flags=_re.IGNORECASE)
|
||||
sql = _re.sub(r"AND\s+MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "", sql, flags=_re.IGNORECASE)
|
||||
# Por si están sin AND (primera condición)
|
||||
sql = _re.sub(r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||
sql = _re.sub(r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?", "1=1", sql, flags=_re.IGNORECASE)
|
||||
# El buscador debe traer TODOS los matriculados excepto ANU (incluye SUS, no pagados, etc.)
|
||||
# El buscador muestra TODOS los matriculados, incluso ANU
|
||||
sql = _re.sub(r"AND\s+sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\([^)]*\)", "", sql, flags=_re.IGNORECASE)
|
||||
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||
sql = _re.sub(r"NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*,\s*'RET'\s*\)", "NOT IN ('ZZZ')", sql, flags=_re.IGNORECASE)
|
||||
|
||||
conn = self.data_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql) # sin parámetros de fecha
|
||||
columns = [c[0] for c in cursor.description]
|
||||
datos = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
|
||||
# Traer fecha de cancelación de la cuota 1 de TODAS las matrículas (cualquier refinanciamiento)
|
||||
fechas_canc = {}
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT num_matricula, MAX(fch_cancelacion)
|
||||
FROM sgede_cronograma_matricula
|
||||
WHERE num_cuota = 1 AND fch_cancelacion IS NOT NULL
|
||||
GROUP BY num_matricula
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
k = str(r[0]).strip()
|
||||
if k.endswith('.0'): k = k[:-2]
|
||||
fechas_canc[k] = r[1]
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
filas = []
|
||||
for d in datos:
|
||||
raw_mat = d.get('num_matricula')
|
||||
mat_id = ""
|
||||
if raw_mat is not None:
|
||||
v = str(raw_mat).strip()
|
||||
if v.endswith('.0'): v = v[:-2]
|
||||
mat_id = v
|
||||
alumno = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||
# MOSTRAR: detallado (dsc_promocion = sgede_RP_programa.dsc_det_programa)
|
||||
programa = (d.get('dsc_promocion') or d.get('dsc_det_programa') or d.get('dsc_programa') or '')
|
||||
# CLASIFICAR sede: prioriza dsc_programa
|
||||
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa') or d.get('dsc_promocion') or '')
|
||||
vendedor = d.get('dsc_vendedor', 'SIN VENDEDOR') or 'SIN VENDEDOR'
|
||||
inv = d.get('INV_NETA', 0) or 0
|
||||
saldo_mat = d.get('imp_saldo_matricula', 0) or 0
|
||||
saldo_c1 = d.get('imp_saldo_cuota1', 0) or 0
|
||||
def _fdate(v):
|
||||
if not v or str(v).strip() in ("","None"): return ""
|
||||
if hasattr(v,'strftime'): return v.strftime('%d/%m/%Y')
|
||||
s = str(v)[:10]
|
||||
if len(s)==10 and s[4]=='-': p=s.split('-'); return f"{p[2]}/{p[1]}/{p[0]}"
|
||||
return s
|
||||
f_mat = _fdate(d.get('fch_matricula'))
|
||||
f_ini = _fdate(d.get('fch_inicio'))
|
||||
f_canc = _fdate(fechas_canc.get(mat_id))
|
||||
sede = self._clasificar_sede_comisiones(prog_clasificacion)
|
||||
# mismo layout que el detalle: 16 columnas
|
||||
fila = [
|
||||
vendedor, alumno, f_mat, f_canc, "", "", "",
|
||||
f"S/ {float(inv):,.0f}", programa, f_ini,
|
||||
f"S/ {float(saldo_mat):,.0f}", f"S/ {float(saldo_c1):,.0f}",
|
||||
"", "-", sede, mat_id,
|
||||
]
|
||||
filas.append(fila)
|
||||
return filas
|
||||
|
||||
def obtener_datos_brutos(self, ano, mes_numero):
|
||||
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero))
|
||||
return datos or []
|
||||
|
||||
def obtener_datos_brutos_filtrado(self, ano, mes_numero, sede="TODOS", programa="TODOS"):
|
||||
datos = self.data_manager.ejecutar_consulta_ventas(str(ano), str(mes_numero), sede, programa)
|
||||
return datos or []
|
||||
|
||||
# =========================================================================
|
||||
# FUNCIONES AUXILIARES
|
||||
# =========================================================================
|
||||
def clasificar_programa(self, dsc_programa):
|
||||
if not dsc_programa: return "OTROS"
|
||||
dsc_prog_upper = str(dsc_programa).upper()
|
||||
meta_data = self.data_manager.meta_data
|
||||
clasificaciones = meta_data.get("clasificacion_programas", {})
|
||||
|
||||
for clase, data in clasificaciones.items():
|
||||
for patron in data.get("patrones", []):
|
||||
if patron.upper() in dsc_prog_upper:
|
||||
return clase
|
||||
return meta_data.get("clasificacion_default", {}).get("categoria", "SEMINARIOS")
|
||||
|
||||
def identificar_linea_carrera(self, nombre_programa):
|
||||
categoria = self.clasificar_programa(nombre_programa)
|
||||
if categoria in ["AREQUIPA", "TRUJILLO", "PIURA", "TEAC", "TERC", "CARRERA"]:
|
||||
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
|
||||
|
||||
# =========================================================================
|
||||
# OBTENCIÓN DE DETALLE POR VENDEDOR (LÓGICA PURA)
|
||||
# =========================================================================
|
||||
def obtener_detalle_vendedor(self, vendedor, ano, mes_numero):
|
||||
try:
|
||||
key_mes = f"{int(mes_numero):02d}-{ano}"
|
||||
lista_raw = self.data_manager.historico_pendientes.get(key_mes, [])
|
||||
set_vip_actual = set()
|
||||
lista_vip_str = []
|
||||
|
||||
for item in lista_raw:
|
||||
try:
|
||||
val = int(item)
|
||||
set_vip_actual.add(val)
|
||||
lista_vip_str.append(str(val))
|
||||
except: pass
|
||||
|
||||
# Incluir matrículas con override de fecha (igual que ejecutar_consulta_ventas)
|
||||
_fov_vip = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||
for mk in _fov_vip.keys():
|
||||
try:
|
||||
val = int(float(str(mk)))
|
||||
set_vip_actual.add(val)
|
||||
if str(val) not in lista_vip_str:
|
||||
lista_vip_str.append(str(val))
|
||||
except: pass
|
||||
|
||||
correcciones_mat = self.data_manager.config_data.get("correcciones_matriculas", {})
|
||||
correcciones_desc = self.data_manager.config_data.get('correcciones_descuento', {})
|
||||
correcciones_cont = self.data_manager.config_data.get('correcciones_continuidad', {})
|
||||
|
||||
sql_base = self.data_manager.query_matriculas_sql
|
||||
if not sql_base:
|
||||
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||
|
||||
sql_in_clause = "(-1)"
|
||||
if lista_vip_str:
|
||||
sql_in_clause = "(" + ",".join(lista_vip_str) + ")"
|
||||
|
||||
sql_modificado = re.sub(
|
||||
r"YEAR\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||
r"( (YEAR(sgeca_matricula.fch_matricula) = ?",
|
||||
sql_base, flags=re.IGNORECASE
|
||||
)
|
||||
sql_modificado = re.sub(
|
||||
r"MONTH\(\s*sgeca_matricula\.fch_matricula\s*\)\s*=\s*\?",
|
||||
rf"""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) = ?) )""",
|
||||
sql_modificado, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# Permitir matrículas VIP/override aunque estén ANU/SUS (igual que la tabla principal)
|
||||
if lista_vip_str:
|
||||
sql_modificado = re.sub(
|
||||
r"sgeca_matricula\.cod_estado\s+NOT\s+IN\s*\(\s*'ANU'\s*,\s*'SUS'\s*\)",
|
||||
rf"(sgeca_matricula.cod_estado NOT IN ('ANU', 'SUS') OR sgeca_matricula.num_matricula IN {sql_in_clause})",
|
||||
sql_modificado, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
conn = self.data_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(sql_modificado, ano, mes_numero, ano, mes_numero)
|
||||
columns = [column[0] for column in cursor.description]
|
||||
datos_raw = [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
|
||||
# 🔥 DESCARGA DEL CRONOGRAMA COMPLETO (SIN IMPORTAR EL MES) 🔥
|
||||
lista_mats = list(set([str(d.get('num_matricula')).replace('.0','').strip() for d in datos_raw if d.get('num_matricula')]))
|
||||
cuotas_raw = []
|
||||
|
||||
if lista_mats:
|
||||
mats_str_q = ",".join([f"'{m}'" for m in lista_mats])
|
||||
try:
|
||||
res_cuo = requests.get(self.data_manager.github_cuota_url)
|
||||
sql_cuotas_base = res_cuo.text
|
||||
|
||||
# Convertimos las validaciones de Año y Mes en "1=1" para que no esconda los cronogramas de otros meses
|
||||
sql_cuotas_base = re.sub(r"YEAR\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||
sql_cuotas_base = re.sub(r"MONTH\s*\([^)]+\)\s*=\s*\?", "1=1", sql_cuotas_base, flags=re.IGNORECASE)
|
||||
sql_cuotas_base = re.sub(r"ORDER\s+BY\s+.*$", "", sql_cuotas_base, flags=re.IGNORECASE | re.DOTALL)
|
||||
|
||||
sql_final_cuotas = f"SELECT * FROM ({sql_cuotas_base}) AS sub_cuotas WHERE num_matricula IN ({mats_str_q})"
|
||||
|
||||
cursor.execute(sql_final_cuotas)
|
||||
cols_cuo = [c[0] for c in cursor.description]
|
||||
cuotas_raw = [dict(zip(cols_cuo, row)) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
print(f"Error DAX Cuotas: {e}")
|
||||
|
||||
try: cursos_raw = self.data_manager.ejecutar_consulta_cursos(ano, mes_numero)
|
||||
except: cursos_raw = []
|
||||
|
||||
conn.close()
|
||||
|
||||
# Agrupar Cuotas
|
||||
cuotas_por_mat = {}
|
||||
for c in cuotas_raw:
|
||||
m_id = str(c.get('num_matricula', '')).strip()
|
||||
if m_id.endswith('.0'): m_id = m_id[:-2]
|
||||
if m_id not in cuotas_por_mat: cuotas_por_mat[m_id] = []
|
||||
cuotas_por_mat[m_id].append(c)
|
||||
|
||||
fecha_inicio_prog = {}
|
||||
for c in cursos_raw:
|
||||
prog = str(c.get('dsc_programa', '')).strip().upper()
|
||||
if prog not in fecha_inicio_prog:
|
||||
fecha_inicio_prog[prog] = self.parse_fecha(c.get('fch_inicio'))
|
||||
|
||||
lista_dnis = [str(d.get('dsc_documento', '')).strip() for d in datos_raw if d.get('dsc_documento')]
|
||||
try: datos_historial = self.data_manager.consultar_historial_continuidad(lista_dnis)
|
||||
except: datos_historial = {}
|
||||
|
||||
listas_datos = {
|
||||
"Venta Inscritos": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||
"Venta P.C": {"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0},
|
||||
"Venta Pendientes":{"filas": [], "s_cuota": 0.0, "c_cuota": 0, "s_desc": 0.0, "c_desc": 0, "s_neta": 0.0}
|
||||
}
|
||||
|
||||
vendedor_target = str(vendedor).upper().replace(".", "").replace(",", "")
|
||||
vendedor_target = " ".join(vendedor_target.split())
|
||||
traer_todos = (vendedor_target == "__TODOS__")
|
||||
|
||||
for d in datos_raw:
|
||||
vend_name = str(d.get('dsc_vendedor', 'SIN VENDEDOR')).upper().replace(".", "").replace(",", "")
|
||||
vend_name_clean = " ".join(vend_name.split())
|
||||
|
||||
if not traer_todos and vendedor_target not in vend_name_clean and vend_name_clean not in vendedor_target:
|
||||
continue
|
||||
|
||||
estado = str(d.get('estado_matricula', '')).strip().upper()
|
||||
# Permitir ANU/otros si tiene override de fecha; si no, solo ALU/PRE/RET
|
||||
_rm = d.get('num_matricula')
|
||||
_mk = ""
|
||||
if _rm is not None:
|
||||
_mk = str(_rm).strip()
|
||||
if _mk.endswith('.0'): _mk = _mk[:-2]
|
||||
_fov_chk = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||
if estado not in ['ALU', 'PRE', 'RET'] and _mk not in _fov_chk: continue
|
||||
|
||||
raw_mat = d.get('num_matricula')
|
||||
mat_id = ""
|
||||
matricula_int = -1
|
||||
if raw_mat is not None:
|
||||
val_str = str(raw_mat).strip()
|
||||
if val_str.endswith('.0'): val_str = val_str[:-2]
|
||||
mat_id = val_str
|
||||
try: matricula_int = int(float(val_str))
|
||||
except: pass
|
||||
|
||||
dni = str(d.get('dsc_documento', '')).strip()
|
||||
alumno_nombre = d.get('dsc_alumno', 'SIN NOMBRE')
|
||||
nombre_prog_crudo = str(d.get('dsc_programa', '')).strip().upper()
|
||||
|
||||
inv_neta_raw = float(d.get('INV_NETA', 0.0))
|
||||
cod_moneda = str(d.get('cod_moneda', 'SOL')).strip().upper()
|
||||
|
||||
# Buscar imp_tc desde el CRONOGRAMA (no del comprobante)
|
||||
imp_tc = 0
|
||||
cuotas_alumno = cuotas_por_mat.get(mat_id, [])
|
||||
for c_aux in cuotas_alumno:
|
||||
try:
|
||||
tc_aux = float(c_aux.get('imp_tc', 0) or 0)
|
||||
if tc_aux >= 2: # Tomar el primer TC válido del cronograma
|
||||
imp_tc = tc_aux
|
||||
break
|
||||
except: continue
|
||||
if imp_tc < 2: imp_tc = 3.45 # TC por defecto si no hay cronograma o es inválido
|
||||
|
||||
saldo_mat = float(d.get('imp_saldo_matricula', 0.0))
|
||||
saldo_c1 = float(d.get('imp_saldo_cuota1', 0.0))
|
||||
fch_canc_raw = d.get('fch_cancelacion_cuota1')
|
||||
fch_mat = d.get('fch_matricula')
|
||||
|
||||
# Override de fecha cancelación 1 (Supabase) → prioridad para clasificación + cuenta como pagado
|
||||
_fov = getattr(self.data_manager, '_fecha_canc_overrides', None) or {}
|
||||
_mat_key = str(mat_id).strip()
|
||||
if _mat_key.endswith('.0'): _mat_key = _mat_key[:-2]
|
||||
tiene_override_fecha = _mat_key in _fov
|
||||
fecha_vaciada_override = False # override "borrar" → tiene prioridad sobre todo
|
||||
if tiene_override_fecha:
|
||||
s = str(_fov[_mat_key]).strip()
|
||||
if s == "__VACIO__":
|
||||
# Fecha borrada a propósito → anula la del SQL (no cuenta como pagado)
|
||||
fch_canc_raw = None
|
||||
tiene_override_fecha = False
|
||||
fecha_vaciada_override = 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_canc_raw = s
|
||||
|
||||
if mat_id in correcciones_mat:
|
||||
datos_corregidos = correcciones_mat[mat_id]
|
||||
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"])
|
||||
# Supabase (vaciado) tiene prioridad: la corrección manual NO restaura la fecha
|
||||
if "fch_cancelacion_cuota1" in datos_corregidos and not fecha_vaciada_override:
|
||||
fch_canc_raw = datos_corregidos["fch_cancelacion_cuota1"]
|
||||
|
||||
# Prioridad TC: Supabase (mes) > corrección > cronograma
|
||||
_tc_ov = getattr(self.data_manager, '_tc_override_mes', None)
|
||||
if _tc_ov: imp_tc = float(_tc_ov)
|
||||
|
||||
if cod_moneda == 'DOL': inv_neta_soles = round(inv_neta_raw * imp_tc, 2)
|
||||
else: inv_neta_soles = round(inv_neta_raw, 2)
|
||||
|
||||
row_year, row_month = -1, -1
|
||||
fecha_mat_limpia = ""
|
||||
if fch_mat:
|
||||
try:
|
||||
if hasattr(fch_mat, 'strftime'):
|
||||
row_year, row_month = fch_mat.year, fch_mat.month
|
||||
fecha_mat_limpia = fch_mat.strftime('%d/%m/%Y')
|
||||
else:
|
||||
f_obj = datetime.strptime(str(fch_mat)[:10], '%Y-%m-%d')
|
||||
row_year, row_month = f_obj.year, f_obj.month
|
||||
fecha_mat_limpia = f_obj.strftime('%d/%m/%Y')
|
||||
except: fecha_mat_limpia = str(fch_mat)[:10]
|
||||
|
||||
fch_canc_limpia = ""
|
||||
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||
try:
|
||||
if hasattr(fch_canc_raw, 'strftime'): fch_canc_limpia = fch_canc_raw.strftime('%d/%m/%Y')
|
||||
else:
|
||||
tmp = str(fch_canc_raw)[:10]
|
||||
if len(tmp) == 10 and tmp[4] == '-': p = tmp.split('-'); fch_canc_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||
else: fch_canc_limpia = tmp
|
||||
except: fch_canc_limpia = str(fch_canc_raw)[:10]
|
||||
|
||||
clase_alumno = self.clasificar_programa(nombre_prog_crudo)
|
||||
linea_actual = self.identificar_linea_carrera(nombre_prog_crudo)
|
||||
fecha_inicio_actual = fecha_inicio_prog.get(nombre_prog_crudo)
|
||||
|
||||
es_refriperu = False
|
||||
if mat_id in correcciones_desc:
|
||||
if str(correcciones_desc[mat_id]).upper() == "SI": es_refriperu = True
|
||||
elif str(correcciones_desc[mat_id]).upper() == "NO": es_refriperu = False
|
||||
else:
|
||||
if clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"] and 1200 <= inv_neta_soles <= 1700: es_refriperu = True
|
||||
elif clase_alumno in ["TEAC", "TERC", "CARRERA"] and 1400 <= inv_neta_soles <= 1900: es_refriperu = True
|
||||
|
||||
tipo_final = "NUEVO"
|
||||
if es_refriperu: tipo_final = "REFRIPERU"
|
||||
else:
|
||||
es_cont = False
|
||||
if mat_id in correcciones_cont:
|
||||
if str(correcciones_cont[mat_id]).upper() == "SI": es_cont = True
|
||||
elif str(correcciones_cont[mat_id]).upper() == "NO": es_cont = False
|
||||
else:
|
||||
if fecha_inicio_actual and linea_actual and (dni in datos_historial):
|
||||
for antecedente in datos_historial[dni]:
|
||||
nombre_pasado = str(antecedente.get('programa', ''))
|
||||
fecha_pasada = antecedente.get('fecha')
|
||||
if not fecha_pasada: continue
|
||||
if not (fecha_pasada < fecha_inicio_actual): continue
|
||||
dias_diff = (fecha_inicio_actual - fecha_pasada).days
|
||||
if dias_diff < 60: continue
|
||||
linea_pasada = self.identificar_linea_carrera(nombre_pasado)
|
||||
if linea_pasada == linea_actual:
|
||||
es_cont = True
|
||||
break
|
||||
if es_cont: tipo_final = "CONTINUIDAD"
|
||||
|
||||
# ===============================================================
|
||||
# 🔥 MATEMÁTICA PURA EXACTAMENTE COMO LA PEDISTE 🔥
|
||||
# ===============================================================
|
||||
suma_cuotas = 0.0
|
||||
mis_cuotas = cuotas_por_mat.get(mat_id, [])
|
||||
|
||||
for c_dict in mis_cuotas:
|
||||
try: num_c = int(c_dict.get('num_cuota', 0))
|
||||
except: num_c = 0
|
||||
|
||||
# REGLA: Si el número de cuota es mayor a 0, sumar (imp_total - imp_dscto)
|
||||
if num_c > 0:
|
||||
t_val = c_dict.get('imp_total', 0)
|
||||
d_val = c_dict.get('imp_dscto', 0)
|
||||
|
||||
try: t_monto = float(t_val) if t_val is not None else 0.0
|
||||
except: t_monto = 0.0
|
||||
try: d_monto = float(d_val) if d_val is not None else 0.0
|
||||
except: d_monto = 0.0
|
||||
|
||||
imp_c = t_monto - d_monto
|
||||
|
||||
# Conversión si es Dólares
|
||||
tc_over = None
|
||||
if mat_id in correcciones_mat and "imp_tc" in correcciones_mat[mat_id]:
|
||||
tc_over = float(correcciones_mat[mat_id]["imp_tc"])
|
||||
|
||||
cod_m_c = str(c_dict.get('cod_moneda', 'SOL')).strip().upper()
|
||||
if cod_m_c == "DOL":
|
||||
if tc_over is not None: tc_c = tc_over
|
||||
else:
|
||||
try: tc_c = float(c_dict.get('imp_tc', 0) or 0)
|
||||
except: tc_c = 0
|
||||
if tc_c < 2: tc_c = 3.45 # TC por defecto si es inválido
|
||||
imp_c *= tc_c
|
||||
|
||||
suma_cuotas += imp_c
|
||||
|
||||
# ===============================================================
|
||||
# DIVISIÓN SEGÚN CLASIFICACIÓN
|
||||
# ===============================================================
|
||||
if suma_cuotas > 0:
|
||||
if clase_alumno in ["TEAC", "TERC", "CARRERA"]:
|
||||
valor_cuota_final = suma_cuotas / 5.0
|
||||
elif clase_alumno in ["AREQUIPA", "TRUJILLO", "PIURA"]:
|
||||
valor_cuota_final = suma_cuotas / 6.0
|
||||
else:
|
||||
valor_cuota_final = suma_cuotas
|
||||
|
||||
str_cuota_val = f"S/ {valor_cuota_final:,.0f}"
|
||||
else:
|
||||
valor_cuota_final = 0.0
|
||||
# Si no tiene cuotas mayores a 0, se queda en blanco limpio
|
||||
str_cuota_val = ""
|
||||
|
||||
# ===============================================================
|
||||
# LÓGICA DESC. ESPECIAL (Sobre la suma pura)
|
||||
# ===============================================================
|
||||
desc_e_raw = 0.0
|
||||
if suma_cuotas >= 1900:
|
||||
if clase_alumno in ["TEAC", "TERC", "CARRERA"]: d_calc = 3400 - suma_cuotas
|
||||
elif clase_alumno in ["AREQUIPA", "PIURA", "TRUJILLO"]: d_calc = 2740 - suma_cuotas
|
||||
else: d_calc = 0
|
||||
|
||||
if d_calc > 0:
|
||||
desc_e_raw = min(max(d_calc - 200, 0.0), 200.0)
|
||||
|
||||
str_desc_e_val = f"S/ {desc_e_raw:,.0f}" if desc_e_raw > 0 else ""
|
||||
|
||||
# ==========================================
|
||||
# DISTRIBUCIÓN EN LAS 3 LISTAS
|
||||
# ==========================================
|
||||
vendedor_real = d.get('dsc_vendedor', 'SIN VENDEDOR')
|
||||
if not vendedor_real: vendedor_real = 'SIN VENDEDOR'
|
||||
|
||||
# Campos extra para el popup de Comisiones (mismo alumno / num_indice)
|
||||
# NOTA: la query trae el programa DETALLADO con alias 'dsc_promocion'
|
||||
# (sgede_RP_programa.dsc_det_programa AS dsc_promocion).
|
||||
# MOSTRAR: prioriza el detallado (dsc_promocion)
|
||||
det_programa = (d.get('dsc_promocion') or d.get('dsc_det_programa')
|
||||
or d.get('dsc_programa') or '')
|
||||
# CLASIFICAR (sede/filtro): prioriza dsc_programa
|
||||
prog_clasificacion = (d.get('dsc_programa') or d.get('dsc_det_programa')
|
||||
or d.get('dsc_promocion') or '')
|
||||
fch_ini_raw = d.get('fch_inicio', '')
|
||||
fch_ini_limpia = ""
|
||||
if fch_ini_raw and str(fch_ini_raw).strip() != "None":
|
||||
try:
|
||||
if hasattr(fch_ini_raw, 'strftime'): fch_ini_limpia = fch_ini_raw.strftime('%d/%m/%Y')
|
||||
else:
|
||||
t = str(fch_ini_raw)[:10]
|
||||
if len(t) == 10 and t[4] == '-':
|
||||
p = t.split('-'); fch_ini_limpia = f"{p[2]}/{p[1]}/{p[0]}"
|
||||
else: fch_ini_limpia = t
|
||||
except: fch_ini_limpia = str(fch_ini_raw)[:10]
|
||||
str_saldo_mat = f"S/ {saldo_mat:,.0f}" if saldo_mat else "S/ 0"
|
||||
str_saldo_c1 = f"S/ {saldo_c1:,.0f}" if saldo_c1 else "S/ 0"
|
||||
|
||||
# ── Cálculos extra para Comisiones ──────────────────────────
|
||||
# DÍAS ANTICIPACIÓN = Fecha Inicio - Fecha Cancelación 1 (en días)
|
||||
dias_anticipacion = "-"
|
||||
try:
|
||||
def _to_date(v):
|
||||
if not v or str(v).strip() in ("", "None"): return None
|
||||
if hasattr(v, 'year'): return datetime(v.year, v.month, v.day)
|
||||
s = str(v)[:10]
|
||||
if len(s) == 10 and s[4] == '-': return datetime.strptime(s, '%Y-%m-%d')
|
||||
if len(s) == 10 and s[2] == '/':
|
||||
p = s.split('/'); return datetime(int(p[2]), int(p[1]), int(p[0]))
|
||||
return None
|
||||
di = _to_date(fch_ini_raw)
|
||||
dc = _to_date(fch_canc_raw)
|
||||
if di and dc:
|
||||
dias_anticipacion = str((di - dc).days)
|
||||
except Exception:
|
||||
dias_anticipacion = "-"
|
||||
|
||||
# TIPO PROGRAMA y sede (clasificación tipo Cobranza vía sede.json)
|
||||
sede_alumno = self._clasificar_sede_comisiones(prog_clasificacion or nombre_prog_crudo)
|
||||
tipo_programa = sede_alumno # según sede.json (LIMA/AREQUIPA/PIURA/TRUJILLO)
|
||||
|
||||
# VALOR CUOTA ADICIONAL = Promedio cuota - 640 (solo si sede = LIMA, mínimo 0)
|
||||
if sede_alumno == "LIMA" and valor_cuota_final > 0:
|
||||
valor_adicional = valor_cuota_final - 640
|
||||
if valor_adicional < 0:
|
||||
valor_adicional = 0
|
||||
str_valor_adicional = f"S/ {valor_adicional:,.0f}"
|
||||
else:
|
||||
str_valor_adicional = "-"
|
||||
|
||||
fila_final = [
|
||||
vendedor_real, alumno_nombre, fecha_mat_limpia, fch_canc_limpia,
|
||||
tipo_final, str_cuota_val, str_desc_e_val, f"S/ {inv_neta_soles:,.0f}",
|
||||
# Extra para Comisiones (índices 8+): programa, fch inicio, saldos
|
||||
det_programa, fch_ini_limpia, str_saldo_mat, str_saldo_c1,
|
||||
# Nuevos (índices 12+): días anticipación, valor cuota adicional, tipo programa
|
||||
dias_anticipacion, str_valor_adicional, tipo_programa,
|
||||
# Índice [15]: num_matricula (clave para overrides de Comisiones)
|
||||
mat_id,
|
||||
# Índice [16]: nombre para CLASIFICAR (prioriza dsc_programa). Oculto en UI.
|
||||
prog_clasificacion
|
||||
]
|
||||
|
||||
es_venta_del_mes = (str(row_year) == str(ano)) and (str(row_month) == str(mes_numero))
|
||||
saldos_ok = (saldo_mat == 0 and saldo_c1 == 0)
|
||||
if tiene_override_fecha:
|
||||
saldos_ok = True
|
||||
|
||||
def agregar_a_lista(nombre_lista):
|
||||
listas_datos[nombre_lista]["filas"].append(fila_final)
|
||||
listas_datos[nombre_lista]["s_neta"] += inv_neta_soles
|
||||
|
||||
if valor_cuota_final > 0:
|
||||
listas_datos[nombre_lista]["s_cuota"] += valor_cuota_final
|
||||
listas_datos[nombre_lista]["c_cuota"] += 1
|
||||
|
||||
if desc_e_raw > 0:
|
||||
listas_datos[nombre_lista]["s_desc"] += desc_e_raw
|
||||
listas_datos[nombre_lista]["c_desc"] += 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||
# Sacamos la validación de fecha de cancelación AFUERA para que sirva a ambas listas
|
||||
pago_en_fecha = False
|
||||
if fch_canc_raw and str(fch_canc_raw).strip() != "None":
|
||||
try:
|
||||
if isinstance(fch_canc_raw, str):
|
||||
f_obj = datetime.strptime(fch_canc_raw[:10], '%Y-%m-%d')
|
||||
f_ano, f_mes = f_obj.year, f_obj.month
|
||||
else:
|
||||
f_ano, f_mes = fch_canc_raw.year, fch_canc_raw.month
|
||||
|
||||
if str(f_ano) == str(ano) and str(f_mes) == str(mes_numero):
|
||||
pago_en_fecha = True
|
||||
except: pass
|
||||
|
||||
# ¿Matrícula de un mes ANTERIOR al filtro?
|
||||
matricula_mes_pasado = False
|
||||
try:
|
||||
ym_mat = int(row_year) * 100 + int(row_month)
|
||||
ym_filtro = int(ano) * 100 + int(mes_numero)
|
||||
matricula_mes_pasado = ym_mat < ym_filtro
|
||||
except: pass
|
||||
|
||||
if es_venta_del_mes:
|
||||
agregar_a_lista("Venta Inscritos")
|
||||
if saldos_ok and pago_en_fecha:
|
||||
agregar_a_lista("Venta P.C")
|
||||
|
||||
# MES PASADO: matrícula anterior + pagó 1° cuota en el mes del filtro
|
||||
if matricula_mes_pasado and saldos_ok and pago_en_fecha:
|
||||
agregar_a_lista("Venta Pendientes")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================
|
||||
# ORDENAR Y ARMAR TOTAL GENERAL
|
||||
# ==========================================
|
||||
resultado_final = {}
|
||||
for k, dict_data in listas_datos.items():
|
||||
dict_data["filas"].sort(key=lambda x: x[1])
|
||||
|
||||
p_cuota = (dict_data["s_cuota"] / dict_data["c_cuota"]) if dict_data["c_cuota"] > 0 else 0.0
|
||||
p_desc = (dict_data["s_desc"] / dict_data["c_desc"]) if dict_data["c_desc"] > 0 else 0.0
|
||||
|
||||
str_gral_cuota = f"S/ {p_cuota:,.0f}" if dict_data["c_cuota"] > 0 else ""
|
||||
str_gral_desc = f"S/ {p_desc:,.0f}" if dict_data["c_desc"] > 0 else ""
|
||||
|
||||
dict_data["filas"].append([
|
||||
"TOTAL GENERAL", "", "", "", "",
|
||||
str_gral_cuota, str_gral_desc, f"S/ {dict_data['s_neta']:,.0f}"
|
||||
])
|
||||
resultado_final[k] = dict_data["filas"]
|
||||
|
||||
return resultado_final
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error procesando alumnos para modal Ventas: {e}")
|
||||
return {"Venta Inscritos": [], "Venta P.C": [], "Venta Pendientes": []}
|
||||
|
||||
def formatear_datos_para_tabla(self, datos):
|
||||
if not datos:
|
||||
return []
|
||||
|
||||
sheet_data = []
|
||||
total_monto = total_pc = total_pendientes = total_avance_pc_total = 0.0
|
||||
total_cantidad = total_cantidad_pc = total_cantidad_pendientes = 0
|
||||
|
||||
for registro in datos:
|
||||
vendedor = registro.get('VENDEDOR', 'SIN VENDEDOR')
|
||||
monto = float(registro.get('MONTO', 0.0))
|
||||
cantidad = int(registro.get('CANTIDAD', 0))
|
||||
ventas_pc = float(registro.get('VENTAS_PC', 0.0))
|
||||
cantidad_pc = int(registro.get('INSCRITOS_PC', 0))
|
||||
pendientes = float(registro.get('PENDIENTES', 0.0))
|
||||
cant_pendientes = int(registro.get('INSCRITOS_PENDIENTES', 0))
|
||||
|
||||
avance_pc_total = ventas_pc + pendientes
|
||||
|
||||
total_monto += monto; total_cantidad += cantidad; total_pc += ventas_pc
|
||||
total_cantidad_pc += cantidad_pc; total_pendientes += pendientes
|
||||
total_cantidad_pendientes += cant_pendientes; total_avance_pc_total += avance_pc_total
|
||||
|
||||
sheet_data.append([
|
||||
vendedor, cantidad, f"S/ {monto:,.0f}", cantidad_pc, f"S/ {ventas_pc:,.0f}",
|
||||
cant_pendientes, f"S/ {pendientes:,.0f}", f"S/ {avance_pc_total:,.0f}",
|
||||
" ≡ ▼ "
|
||||
])
|
||||
|
||||
sheet_data.append([
|
||||
"TOTAL GENERAL", total_cantidad, f"S/ {total_monto:,.0f}",
|
||||
total_cantidad_pc, f"S/ {total_pc:,.0f}", total_cantidad_pendientes,
|
||||
f"S/ {total_pendientes:,.0f}", f"S/ {total_avance_pc_total:,.0f}",
|
||||
""
|
||||
])
|
||||
|
||||
return sheet_data
|
||||
|
||||
def exportar_detalle_alumnos_excel(self, vendedor, lista_nombre, headers, datos):
|
||||
if not datos: raise ValueError("No hay datos para exportar")
|
||||
vend_limpio = "".join([c if c.isalnum() else "_" for c in str(vendedor)])[:30]
|
||||
lista_limpia = "".join([c if c.isalnum() else "_" for c in str(lista_nombre)])
|
||||
archivo = f"Ventas_{vend_limpio}_{lista_limpia}.xlsx"
|
||||
|
||||
df = pd.DataFrame(datos, columns=headers)
|
||||
try:
|
||||
with pd.ExcelWriter(archivo, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='Detalle_Ventas')
|
||||
worksheet = writer.sheets['Detalle_Ventas']
|
||||
try:
|
||||
from openpyxl.styles import PatternFill, Font, Border, Side, Alignment
|
||||
fill_header = PatternFill(start_color="12345F", end_color="12345F", fill_type="solid")
|
||||
font_header = Font(color="FFFFFF", bold=True)
|
||||
thin_border = Border(left=Side(style='thin', color="DDDDDD"), right=Side(style='thin', color="DDDDDD"),
|
||||
top=Side(style='thin', color="DDDDDD"), bottom=Side(style='thin', color="DDDDDD"))
|
||||
|
||||
for cell in worksheet[1]:
|
||||
cell.fill = fill_header
|
||||
cell.font = font_header
|
||||
cell.border = thin_border
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
for row in worksheet.iter_rows(min_row=2):
|
||||
for cell in row:
|
||||
cell.border = thin_border
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
|
||||
for col in worksheet.columns:
|
||||
max_len = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
if cell.value: max_len = max(max_len, len(str(cell.value)))
|
||||
worksheet.column_dimensions[col_letter].width = min(max_len + 3, 50)
|
||||
except: pass
|
||||
except: df.to_excel(archivo, index=False)
|
||||
return archivo
|
||||
|
||||
def exportar_a_excel(self, datos, ano, nombre_mes):
|
||||
if not datos: raise ValueError("No hay datos para exportar")
|
||||
datos_export = []
|
||||
for d in datos:
|
||||
nuevo_d = dict(d)
|
||||
nuevo_d['AVANCE_PC_TOTAL'] = float(d.get('VENTAS_PC', 0)) + float(d.get('PENDIENTES', 0))
|
||||
datos_export.append(nuevo_d)
|
||||
|
||||
df = pd.DataFrame(datos_export)
|
||||
archivo = f"ventas_{ano}_{nombre_mes}.xlsx"
|
||||
df.to_excel(archivo, index=False)
|
||||
return archivo
|
||||
Reference in New Issue
Block a user