Adzacam commited on
Commit
04b8e02
·
1 Parent(s): 59ad42d

feat: expandir endpoints de dashboard con 6 KPIs ejecutivos y analíticas de riesgo académico detalladas

Browse files
Files changed (1) hide show
  1. app.py +217 -63
app.py CHANGED
@@ -823,50 +823,128 @@ def get_dashboard_scorecard(
823
  ):
824
  """
825
  Perspectiva 1: Scorecard Ejecutivo.
826
- Riesgo Multidimensional (Académico + Financiero cruzado).
827
- Rentabilidad Presupuestaria (Ejecutado vs Meta).
828
  """
829
  try:
830
  from sqlalchemy import func
831
-
832
- # 1. Riesgo Multidimensional
833
- cruzados = db.query(
834
- DimEstudiante, FactRendimientoAcademico, FactSituacionFinanciera
835
- ).join(
836
- FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante
837
- ).join(
838
- FactSituacionFinanciera, DimEstudiante.id_estudiante == FactSituacionFinanciera.id_estudiante
839
- ).filter(
840
- FactRendimientoAcademico.nota_final <= umbral_nota,
841
- FactSituacionFinanciera.cuotas_impagas >= min_cuotas
842
- ).all()
843
-
844
- total_activos = db.query(DimEstudiante).count()
845
- alumnos_riesgo = len(cruzados)
846
- indice_riesgo = (alumnos_riesgo / total_activos) if total_activos > 0 else 0
847
-
848
- # 2. Salud de Cartera (Mora vs Vigente)
849
- cartera_query = db.query(
850
- FactSituacionFinanciera.estado_cartera,
851
- func.sum(FactSituacionFinanciera.monto_deuda).label("total")
852
- ).group_by(FactSituacionFinanciera.estado_cartera).all()
853
-
854
- cartera = {row.estado_cartera: float(row.total or 0) for row in cartera_query}
855
 
856
- # 3. Ejecución Presupuestaria (Mocked for now since tables may be empty)
857
- # In a real app this reads FactRentabilidadPresupuesto
858
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  return {
860
  "status": "success",
861
- "riesgo_multidimensional": {
862
- "alumnos_riesgo_critico": alumnos_riesgo,
863
- "indice_riesgo_pct": round(indice_riesgo * 100, 2),
864
- "umbral_aplicado": umbral_nota,
865
- "cuotas_aplicadas": min_cuotas
 
 
 
 
866
  },
867
- "cartera": cartera
 
868
  }
869
  except Exception as e:
 
 
870
  raise HTTPException(status_code=500, detail=str(e))
871
 
872
  @app.get("/api/v1/dashboard/academica")
@@ -876,54 +954,130 @@ def get_dashboard_academica(
876
  ):
877
  """
878
  Perspectiva 2: Gestión Académica.
879
- Dispersión de Notas vs Asistencia y NPS Docente.
880
  """
881
  try:
882
  from sqlalchemy import func
 
883
  # 1. Aprobación vs Reprobación
884
- reprobados = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.nota_final <= umbral_nota).count()
885
  totales = db.query(FactRendimientoAcademico).count()
 
886
  aprobados = totales - reprobados
887
 
888
- # 2. Dispersión Notas vs Asistencia (solo una muestra o promedios por módulo)
889
- dispersion_raw = db.query(
890
- DimModulo.nombre_modulo,
891
- func.avg(FactRendimientoAcademico.nota_final).label("nota_promedio"),
892
- func.avg(FactRendimientoAcademico.asistencia_pct).label("asistencia_promedio"),
893
- func.avg(FactRendimientoAcademico.incumplimiento_actividades_pct).label("incumplimiento_promedio")
894
- ).join(
895
- DimModulo, FactRendimientoAcademico.id_modulo == DimModulo.id_modulo
896
- ).group_by(DimModulo.nombre_modulo).all()
897
 
898
- dispersion = [
899
- {
900
- "modulo": r.nombre_modulo,
901
- "nota": float(r.nota_promedio or 0),
902
- "asistencia": float(r.asistencia_promedio or 0),
903
- "incumplimiento": float(r.incumplimiento_promedio or 0)
904
- } for r in dispersion_raw
905
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
 
907
- # 3. Desempeño NPS Docente (Mocked if table is empty or missing)
 
 
908
  try:
909
  docentes_nps = db.query(
910
  DimDocente.nombre_completo,
911
- func.avg(FactEvaluacionDocente.puntuacion).label("nps_promedio")
 
 
912
  ).outerjoin(
913
  FactEvaluacionDocente, DimDocente.id_docente == FactEvaluacionDocente.id_docente
914
- ).group_by(DimDocente.nombre_completo).all()
 
 
 
 
 
 
 
 
 
 
 
 
 
915
 
916
- nps_data = [{"docente": d.nombre_completo, "nps": float(d.nps_promedio) if d.nps_promedio else 4.0} for d in docentes_nps]
917
- except Exception as e:
918
- # Table might not exist yet in Supabase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  db.rollback()
920
- nps_data = []
921
 
922
  return {
923
  "status": "success",
924
- "aprobacion": {"aprobados": aprobados, "reprobados": reprobados},
 
 
 
 
 
 
925
  "dispersion": dispersion,
926
- "nps_docentes": nps_data
 
927
  }
928
  except Exception as e:
929
  import traceback
 
823
  ):
824
  """
825
  Perspectiva 1: Scorecard Ejecutivo.
826
+ Retorna los 6 KPIs ejecutivos del CMI.
 
827
  """
828
  try:
829
  from sqlalchemy import func
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
 
831
+ total_estudiantes = db.query(DimEstudiante).count()
832
+
833
+ # 1. Riesgo Multidimensional (Académico + Financiero cruzado)
834
+ alumnos_riesgo = 0
835
+ try:
836
+ cruzados = db.query(DimEstudiante.id_estudiante).join(
837
+ FactRendimientoAcademico, DimEstudiante.id_estudiante == FactRendimientoAcademico.id_estudiante
838
+ ).join(
839
+ FactSituacionFinanciera, DimEstudiante.id_estudiante == FactSituacionFinanciera.id_estudiante
840
+ ).filter(
841
+ FactRendimientoAcademico.nota_final <= umbral_nota,
842
+ FactSituacionFinanciera.cuotas_impagas >= min_cuotas
843
+ ).distinct().count()
844
+ alumnos_riesgo = cruzados
845
+ except Exception:
846
+ db.rollback()
847
+
848
+ indice_riesgo = (alumnos_riesgo / total_estudiantes * 100) if total_estudiantes > 0 else 0
849
+
850
+ # 2. Cartera financiera
851
+ cartera = {}
852
+ deuda_total = 0
853
+ try:
854
+ cartera_q = db.query(
855
+ FactSituacionFinanciera.estado_cartera,
856
+ func.count(FactSituacionFinanciera.id_hecho_fin).label("cantidad"),
857
+ func.sum(FactSituacionFinanciera.monto_deuda).label("total")
858
+ ).group_by(FactSituacionFinanciera.estado_cartera).all()
859
+ cartera = {r.estado_cartera: {"cantidad": int(r.cantidad), "monto": float(r.total or 0)} for r in cartera_q}
860
+ deuda_total = sum(v["monto"] for v in cartera.values())
861
+ except Exception:
862
+ db.rollback()
863
+
864
+ # 3. EBITDA / Rentabilidad (try real table, fallback to computed from cartera)
865
+ ebitda_data = {"ingresos_ejecutados": 0, "costos_asociados": 0, "ebitda": 0, "meta_ingresos": 0}
866
+ margen_por_programa = []
867
+ try:
868
+ rent_data = db.query(
869
+ DimModulo.programa,
870
+ func.sum(FactRentabilidadPresupuesto.monto_ejecutado).label("ejecutado"),
871
+ func.sum(FactRentabilidadPresupuesto.monto_meta).label("meta")
872
+ ).join(DimModulo, FactRentabilidadPresupuesto.id_modulo == DimModulo.id_modulo
873
+ ).group_by(DimModulo.programa).all()
874
+
875
+ total_ejecutado = sum(float(r.ejecutado or 0) for r in rent_data)
876
+ total_meta = sum(float(r.meta or 0) for r in rent_data)
877
+ ebitda_data = {
878
+ "ingresos_ejecutados": total_ejecutado,
879
+ "costos_asociados": total_ejecutado * 0.65,
880
+ "ebitda": total_ejecutado * 0.35,
881
+ "meta_ingresos": total_meta,
882
+ "cumplimiento_pct": round((total_ejecutado / total_meta * 100), 1) if total_meta > 0 else 0
883
+ }
884
+ margen_por_programa = [
885
+ {"programa": r.programa or "General", "ejecutado": float(r.ejecutado or 0), "meta": float(r.meta or 0),
886
+ "margen_pct": round(float(r.ejecutado or 0) / float(r.meta or 1) * 100, 1)}
887
+ for r in rent_data
888
+ ]
889
+ except Exception:
890
+ db.rollback()
891
+
892
+ # 4. Tasa de Retención Estudiantil
893
+ total_hechos_aca = 0
894
+ estudiantes_activos = 0
895
+ retencion_pct = 0
896
+ try:
897
+ total_hechos_aca = db.query(FactRendimientoAcademico).count()
898
+ estudiantes_activos = db.query(FactRendimientoAcademico.id_estudiante).distinct().count()
899
+ # Students with nota > umbral are "retained"
900
+ retenidos = db.query(FactRendimientoAcademico.id_estudiante).filter(
901
+ FactRendimientoAcademico.nota_final > umbral_nota
902
+ ).distinct().count()
903
+ retencion_pct = round((retenidos / estudiantes_activos * 100), 1) if estudiantes_activos > 0 else 0
904
+ except Exception:
905
+ db.rollback()
906
+
907
+ # 5. Satisfacción Global (NPS Docente)
908
+ satisfaccion = 0
909
+ try:
910
+ avg_nps = db.query(func.avg(FactEvaluacionDocente.puntuacion)).scalar()
911
+ satisfaccion = round(float(avg_nps or 0), 1)
912
+ except Exception:
913
+ db.rollback()
914
+
915
+ # 6. Integridad MLOps
916
+ mlops_integridad = {"validados_pct": 0, "en_auditoria_pct": 0, "total_registros": 0}
917
+ try:
918
+ total_reg = db.query(FactRendimientoAcademico).count()
919
+ en_revision = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.requiere_revision == True).count()
920
+ avg_conf = db.query(func.avg(FactRendimientoAcademico.nivel_confianza_ia)).scalar()
921
+ mlops_integridad = {
922
+ "validados_pct": round(((total_reg - en_revision) / total_reg * 100), 1) if total_reg > 0 else 0,
923
+ "en_auditoria_pct": round((en_revision / total_reg * 100), 1) if total_reg > 0 else 0,
924
+ "total_registros": total_reg,
925
+ "confianza_promedio": round(float(avg_conf or 0) * 100, 1)
926
+ }
927
+ except Exception:
928
+ db.rollback()
929
+
930
  return {
931
  "status": "success",
932
+ "kpis": {
933
+ "ebitda": ebitda_data,
934
+ "retencion_pct": retencion_pct,
935
+ "satisfaccion_global": satisfaccion,
936
+ "riesgo_desercion_pct": round(indice_riesgo, 1),
937
+ "alumnos_riesgo": alumnos_riesgo,
938
+ "total_estudiantes": total_estudiantes,
939
+ "deuda_total": deuda_total,
940
+ "mlops": mlops_integridad
941
  },
942
+ "cartera": cartera,
943
+ "margen_por_programa": margen_por_programa
944
  }
945
  except Exception as e:
946
+ import traceback
947
+ traceback.print_exc()
948
  raise HTTPException(status_code=500, detail=str(e))
949
 
950
  @app.get("/api/v1/dashboard/academica")
 
954
  ):
955
  """
956
  Perspectiva 2: Gestión Académica.
957
+ Riesgo académico, deserción, evaluaciones docentes, distribución por estado.
958
  """
959
  try:
960
  from sqlalchemy import func
961
+
962
  # 1. Aprobación vs Reprobación
 
963
  totales = db.query(FactRendimientoAcademico).count()
964
+ reprobados = db.query(FactRendimientoAcademico).filter(FactRendimientoAcademico.nota_final <= umbral_nota).count()
965
  aprobados = totales - reprobados
966
 
967
+ # 2. Riesgo Académico Crítico
968
+ nota_promedio = 0
969
+ try:
970
+ avg_nota = db.query(func.avg(FactRendimientoAcademico.nota_final)).scalar()
971
+ nota_promedio = round(float(avg_nota or 0), 1)
972
+ except Exception:
973
+ db.rollback()
 
 
974
 
975
+ # 3. Riesgo Deserción (Inasistencia > 30% o Incumplimiento > 30%)
976
+ riesgo_desercion = 0
977
+ try:
978
+ riesgo_desercion = db.query(FactRendimientoAcademico).filter(
979
+ (FactRendimientoAcademico.asistencia_pct < 70) |
980
+ (FactRendimientoAcademico.incumplimiento_actividades_pct > 30)
981
+ ).count()
982
+ except Exception:
983
+ db.rollback()
984
+
985
+ # 4. Dispersión Notas vs Asistencia por módulo
986
+ dispersion = []
987
+ try:
988
+ dispersion_raw = db.query(
989
+ DimModulo.nombre_modulo,
990
+ func.avg(FactRendimientoAcademico.nota_final).label("nota_promedio"),
991
+ func.avg(FactRendimientoAcademico.asistencia_pct).label("asistencia_promedio"),
992
+ func.avg(FactRendimientoAcademico.incumplimiento_actividades_pct).label("incumplimiento_promedio"),
993
+ func.count(FactRendimientoAcademico.id_hecho_aca).label("total_alumnos")
994
+ ).join(
995
+ DimModulo, FactRendimientoAcademico.id_modulo == DimModulo.id_modulo
996
+ ).group_by(DimModulo.nombre_modulo).all()
997
+
998
+ dispersion = [
999
+ {
1000
+ "modulo": r.nombre_modulo,
1001
+ "nota": round(float(r.nota_promedio or 0), 1),
1002
+ "asistencia": round(float(r.asistencia_promedio or 0), 1),
1003
+ "incumplimiento": round(float(r.incumplimiento_promedio or 0), 1),
1004
+ "alumnos": int(r.total_alumnos)
1005
+ } for r in dispersion_raw
1006
+ ]
1007
+ except Exception:
1008
+ db.rollback()
1009
 
1010
+ # 5. Evaluación Docente (NPS)
1011
+ nps_data = []
1012
+ nps_promedio_global = 0
1013
  try:
1014
  docentes_nps = db.query(
1015
  DimDocente.nombre_completo,
1016
+ DimDocente.area_especialidad,
1017
+ func.avg(FactEvaluacionDocente.puntuacion).label("nps_promedio"),
1018
+ func.count(FactEvaluacionDocente.id_hecho_eval).label("total_evaluaciones")
1019
  ).outerjoin(
1020
  FactEvaluacionDocente, DimDocente.id_docente == FactEvaluacionDocente.id_docente
1021
+ ).group_by(DimDocente.nombre_completo, DimDocente.area_especialidad).all()
1022
+
1023
+ nps_data = [
1024
+ {
1025
+ "docente": d.nombre_completo,
1026
+ "area": d.area_especialidad,
1027
+ "nps": round(float(d.nps_promedio), 1) if d.nps_promedio else 0,
1028
+ "evaluaciones": int(d.total_evaluaciones)
1029
+ } for d in docentes_nps
1030
+ ]
1031
+ if nps_data:
1032
+ nps_promedio_global = round(sum(d["nps"] for d in nps_data if d["nps"] > 0) / max(len([d for d in nps_data if d["nps"] > 0]), 1), 1)
1033
+ except Exception:
1034
+ db.rollback()
1035
 
1036
+ # 6. Distribución por estado ARCA (derivado de notas y asistencia)
1037
+ estado_distribucion = []
1038
+ try:
1039
+ # Aprobado-Titulado: nota > 70 y asistencia >= 70
1040
+ aprobado_titulado = db.query(FactRendimientoAcademico).filter(
1041
+ FactRendimientoAcademico.nota_final > umbral_nota,
1042
+ FactRendimientoAcademico.asistencia_pct >= 70
1043
+ ).count()
1044
+ # Reprobado-Insuficiencia: nota <= 70 y asistencia >= 70
1045
+ reprobado_insuf = db.query(FactRendimientoAcademico).filter(
1046
+ FactRendimientoAcademico.nota_final <= umbral_nota,
1047
+ FactRendimientoAcademico.asistencia_pct >= 70
1048
+ ).count()
1049
+ # Reprobado-Deserción: asistencia < 50
1050
+ reprobado_desercion = db.query(FactRendimientoAcademico).filter(
1051
+ FactRendimientoAcademico.asistencia_pct < 50
1052
+ ).count()
1053
+ # Reprobado-Congelamiento: nota <= 70 y 50 <= asistencia < 70
1054
+ reprobado_congelamiento = db.query(FactRendimientoAcademico).filter(
1055
+ FactRendimientoAcademico.nota_final <= umbral_nota,
1056
+ FactRendimientoAcademico.asistencia_pct >= 50,
1057
+ FactRendimientoAcademico.asistencia_pct < 70
1058
+ ).count()
1059
+
1060
+ estado_distribucion = [
1061
+ {"estado": "Aprobado - Titulado", "cantidad": aprobado_titulado, "color": "#10b981"},
1062
+ {"estado": "Reprobado - Insuficiencia", "cantidad": reprobado_insuf, "color": "#f59e0b"},
1063
+ {"estado": "Reprobado - Deserción", "cantidad": reprobado_desercion, "color": "#ef4444"},
1064
+ {"estado": "Reprobado - Congelamiento", "cantidad": reprobado_congelamiento, "color": "#8b5cf6"}
1065
+ ]
1066
+ except Exception:
1067
  db.rollback()
 
1068
 
1069
  return {
1070
  "status": "success",
1071
+ "aprobacion": {"aprobados": aprobados, "reprobados": reprobados, "total": totales},
1072
+ "kpis": {
1073
+ "nota_promedio": nota_promedio,
1074
+ "riesgo_desercion": riesgo_desercion,
1075
+ "nps_promedio_global": nps_promedio_global,
1076
+ "tasa_aprobacion_pct": round((aprobados / totales * 100), 1) if totales > 0 else 0
1077
+ },
1078
  "dispersion": dispersion,
1079
+ "nps_docentes": nps_data,
1080
+ "estado_distribucion": estado_distribucion
1081
  }
1082
  except Exception as e:
1083
  import traceback