Referencia

Actualización de Configuración de Colegio: Lógica de Recálculo Horario

Cómo funciona la cadena de operaciones que redistribuye horas docentes, sincroniza tramos de vulnerabilidad y registra auditoría al actualizar un SchoolPeriod.

Ariel Mamani ·
carga-horaria vulnerabilidad nestjs typeorm graphql

Al actualizar la configuración de un colegio, el sistema redistribuye automáticamente los tramos horarios de todos los docentes según las nuevas tablas NTH y el porcentaje de alumnos prioritarios, manteniendo coherencia entre contratos, actividades y registros de auditoría.

Descarga: Artículo técnico (PDF) — versión 1.0, mayo 2026. Documento original generado por el DTI; esta página es la versión navegable equivalente.

Contexto

En el sistema de gestión académica, cada establecimiento educacional tiene asociado un SchoolPeriod que concentra la configuración operativa del año escolar: qué tabla de horas rige para los docentes, qué tabla aplica para docentes con alumnos en condición de vulnerabilidad, y qué porcentaje del establecimiento corresponde a alumnos prioritarios.

Cuando un director o secretario modifica cualquiera de esos valores, no basta con guardar el registro en la base de datos. El cambio debe propagarse hacia los PersonSchoolNthTable de cada docente (los registros que definen cuántas horas lectivas, no lectivas y de recreo tiene asignadas), hacia las Activity derivadas (planificación y recreo), y hacia los cálculos consolidados del periodo.

Este artículo documenta el flujo completo: desde la mutación GraphQL hasta el recálculo final persistido, pasando por cada servicio de redistribución horaria.

Stack técnico

  • NestJS como framework backend
  • TypeORM para acceso a datos
  • PostgreSQL como motor de base de datos (los tiempos se almacenan como intervalos PostgreSQL)
  • EventEmitter2 para arquitectura basada en eventos asincrónicos con debounce
  • GraphQL como capa de API

¿Qué son las tablas de horas (NthTable)?

Cada docente tiene un contrato que define sus horas semanales totales. Esas horas se distribuyen en categorías según la normativa chilena:

CategoríaDescripción
Horas lectivas HA / HCTiempo frente a alumnos. HA = horas pedagógicas (45 min), HC = horas cronológicas (60 min)
Horas no lectivasPreparación, corrección, reuniones técnicas
RecreoSupervisión de recreos
Horas institucionalesActividades fuera del aula

Las NthTable son catálogos que, dado un cierto weeklySchedule (horas semanales), definen cuántas corresponden a cada categoría. La NthConfig es cada fila de ese catálogo. Existe una tabla normal y una tabla de vulnerabilidad, esta última aplicable a docentes que atienden cursos de básica con alta proporción de alumnos prioritarios.

Un PersonSchoolNthTable vincula al docente con su tramo activo. Un docente puede tener uno o dos registros: uno para el tramo normal y, si el establecimiento supera el 79% de alumnos prioritarios, un segundo para el tramo de vulnerabilidad.

Diagrama de entidades clave

diagrama mermaid

Desarrollo

1. Punto de entrada: mutación updateSchool

El proceso comienza en el resolver GraphQL updateSchool, restringido a los roles SchoolDirector y SchoolSecretary. Antes de aplicar cualquier cambio, el resolver captura el estado actual del colegio y su periodo escolar. Esto es crítico: si se capturara el estado después de guardar, no habría forma de construir el diff de auditoría.

// Estado previo — capturado ANTES de cualquier escritura
const current = await this.schoolRepository.findOne({
  where: { id: updateSchoolInput.id },
  relations: ['typeFinancing', 'typeTeaching', 'modality'],
});

const oldSchoolPeriod = await this.schoolPeriodRepository.findOne({
  where: {
    schoolId: updateSchoolInput.id,
    academicPeriodId: updateSchoolInput.schoolPeriod.academicPeriodId,
  },
});

Una vez que SchoolService.update completa, el resolver vuelve a consultar las mismas entidades para obtener el estado nuevo y comparar campo a campo.

diagrama mermaid

2. Persistencia: SchoolService.update

El servicio descompone el input en datos del colegio (rest) y del periodo escolar (schoolPeriod) y los persiste de forma independiente:

const { schoolPeriod, ...rest } = updateSchoolInput;
Object.assign(school, rest);
await this.schoolRepository.save(school);

Luego busca si ya existe un SchoolPeriod para la combinación schoolId + academicPeriodId:

  • Escenario A — periodo existente: se actualiza y se evalúa qué recálculos corresponde ejecutar.
  • Escenario B — periodo nuevo: se crea y se llama a syncPersonSchoolNthTableVulnerability con oldValue = 0, como si el porcentaje de alumnos prioritarios pasara de cero al valor configurado.

El árbol de decisión del Escenario A:

diagrama mermaid

3. Conceptos base del cálculo de horas

Antes de los algoritmos de redistribución conviene entender cómo se modela el contrato de un docente:

contractHours (ej: 44h)
├── nonClassroomHours       → horas institucionales (fuera del aula)
└── Horas de jornada semanal
    ├── teachingHoursHc     → horas lectivas cronológicas
    ├── nonTeachingHours    → horas no lectivas (prep., corrección, reuniones)
    └── recreation          → recreos supervisados

La NthConfig para weeklySchedule = 44 define exactamente cuánto corresponde a cada categoría. La conversión entre HA y HC es fija: 1 hora pedagógica (HA) equivale a 45 minutos:

totalUsedTeachingHoursHc = totalUsedTeachingHoursHa × 45 × 60   (en segundos)

Todos los tiempos se trabajan internamente en segundos y se persisten como intervalos PostgreSQL:

intervalToSeconds(interval)        // convierte intervalo PostgreSQL a segundos
secondsToIntervalString(seconds)   // convierte segundos a string "HH:MM:SS"

Distribución cuando aplica vulnerabilidad

availableHours = max(contractHours − nonClassroomHours, 0)
vulnerabilityWeeklySchedule = min(nthConfig_vuln.weeklySchedule, availableHours)
normalWeeklySchedule        = min(max(availableHours − vulnerabilityWeeklySchedule, 0),
                                  nthConfigNormal.weeklySchedule)

4. Redistribución de tramos horarios

4.1 Cambio de tabla de vulnerabilidad

changePersonSchoolHourTableNameVulnerability se invoca cuando hourTableNameVulnerabilityId cambia y el porcentaje de prioritarios supera el 79%. El parámetro wasUnified indica si antes la tabla normal y la de vulnerabilidad eran la misma.

Caso wasUnified = true — dividir un registro en dos:

diagrama mermaid

El tamaño del tramo de vulnerabilidad se determina buscando en la nueva tabla la NthConfig cuyas teachingHoursHa coincidan exactamente con las horas que el docente imparte en cursos prioritarios. Si no hay coincidencia, se usa el valor máximo disponible.

Caso wasUnified = false, ahora se unifica (isNowUnified = true):

Los dos registros se fusionan sumando sus horas, con cap en el máximo de la nueva tabla:

fusedHours = min(vulnerability.weeklySchedule + normal.weeklySchedule,
                 nthConfigNormal.weeklySchedule)

Se actualiza el registro normal con fusedHours y se elimina el de vulnerabilidad.

Caso wasUnified = false, sigue separado:

El registro de vulnerabilidad se redirige a la nueva tabla. Si el docente solo tenía el registro de vulnerabilidad, se crea el registro normal como residuo del contrato.


4.2 Cambio de tabla normal

changePersonSchoolNthTable recorre todos los docentes del periodo y actualiza su registro normal. Respeta el flag forceUpdateSpecificTeachers:

forceUpdateSpecificTeachersisModified del docente¿Se actualiza?
truecualquiera
falsefalse
falsetrueNo — se preserva la configuración manual

La regla de cap: el weeklySchedule resultante nunca puede exceder el máximo de la nueva tabla:

weeklyHoursCalculated = min(normal.weeklySchedule, nthConfigNormal.weeklySchedule)

Si la nueva tabla es la misma que la tabla de vulnerabilidad y el docente tenía dos registros, se fusionan:

weeklyHoursCalculated = min(toKeep.weeklySchedule + toDelete.weeklySchedule,
                             nthConfigNormal.weeklySchedule)

Las actualizaciones se ejecutan en paralelo con Promise.all.


4.3 Cambio en porcentaje de alumnos prioritarios

syncPersonSchoolNthTableVulnerability solo actúa cuando el porcentaje cruza el umbral del 79%:

diagrama mermaid

Activar (prev ≤ 79 → curr > 79):

Solo afecta a docentes con clases en cursos de básica prioritaria, navegando GradeSubjectTeacher → Grade → Level.

configsForHours             = NthConfig WHERE nthTableId = vulnTable
                                          AND teachingHoursHa = totalTeachingHours
vulnerabilityWeeklySchedule = min(config.weeklySchedule, availableHours)

// Si horas estaban fusionadas (isMerged):
hourNormal = max(availableHours, 0)
// Si ya tenía registro normal separado:
hourNormal = max(availableHours − vulnerabilityWeeklySchedule, 0)

weeklyHoursCalculated = min(hourNormal, nthConfigNormal.weeklySchedule)

Desactivar (prev > 79 → curr ≤ 79):

weeklyHoursCalculated = min(max(contractHours − nonClassroomHours, 0),
                             nthConfigNormal.weeklySchedule)

Si existe registro normal → se actualiza y se borra el de vulnerabilidad. Si solo existe el de vulnerabilidad → ese mismo registro se reasigna a la tabla normal. Al terminar, se emite el evento school-period.recalculate.


5. Sincronización de actividades derivadas

Cada vez que un PersonSchoolNthTable es modificado, syncNonClassroomHoursWithoutUpdate actualiza las actividades de Recreo y Planificación:

diagrama mermaid

La razón de sumar sobre todos los registros del docente es que si tiene dos tramos activos (normal + vulnerabilidad), sus horas están distribuidas entre ambos PersonSchoolNthTable. La actividad debe reflejar el total combinado.


6. Arquitectura asincrónica: eventos y debounce

El sistema utiliza EventEmitter2 con listeners @OnEvent() para desacoplar los recálculos del flujo principal.

Eventos soportados

EventoListenerAcción
school-period.recalculatehandleRecalculateEjecuta calculateFunctionaryHours(schoolPeriodId)
school-period.recalculate-person-schoolhandleRecalculatePersonSchoolEjecuta calculateCompleteFuncionary(personSchoolId)

Debounce por schoolPeriodId

Cuando se redistribuyen horas de N docentes, el evento school-period.recalculate se emite una vez por docente. Sin debounce, el recálculo global se ejecutaría N veces seguidas:

diagrama mermaid

El debounceMap: Map<schoolPeriodId, TimeoutHandle> asegura que cada periodo tenga su propio timer independiente.


7. Recálculo consolidado

calculateCompleteFuncionary — nivel docente

diagrama mermaid

Las horas también se desglosan por subsidio para trazabilidad financiera:

SubsidioDescripción
SGSubvención General
PIEPrograma de Integración Escolar
SEPSubvención Escolar Preferencial
totalHoursSG  = Σ GST.assignedHours WHERE subsidyId == SG
totalHoursPIE = Σ GST.assignedHours WHERE subsidyId == PIE
totalHoursSEP = Σ GST.assignedHours WHERE subsidyId == SEP
exceeded      = max(used − total, 0)

calculateFunctionaryHours — nivel periodo

progressTeachers   = docentes con percentageTotal == 100 / totalDocentes × 100
progressAssistants = ídem para asistentes
progressGrades     = cursos isComplete / totalCursos × 100
totalProgress      = (progressTeachers + progressAssistants + progressGrades) / 3

calculateFunctionaryAll — recálculo masivo

Recalcula todos los periodos de un año académico usando runWithConcurrency. Invocable desde CLI:

npm run calculate-all-values <academicPeriodId>
async function bootstrap() {
  const app = await NestFactory.createApplicationContext(AppModule);
  const taskService = app.get(CalculateSchoolPeriodTaskService);
  const academicPeriodId = parseInt(process.argv[2], 10);
  if (!academicPeriodId) process.exit(1);
  await taskService.calculateFunctionaryAll(academicPeriodId);
}

8. Auditoría de cambios

schoolChanges: diferencias en nombre, RBD, dirección, tipo de financiamiento, etc. Para campos de relación se comparan los nombres (no los IDs):

// En lugar de: { field: 'typeFinancingId', oldValue: 3, newValue: 5 }
// Se registra: { field: 'Tipo de Financiamiento', oldValue: 'Municipal', newValue: 'Particular Subvencionado' }

periodChanges: diferencias en las cuatro configuraciones de horas del periodo. Si hay cambios, se crean N+1 logs:

diagrama mermaid

Esto permite trazar el cambio tanto desde el perfil del docente como desde la vista del colegio.


9. Estrategias de rendimiento

EstrategiaBeneficio
Promise.all en batch de updatesParalelismo controlado
runWithConcurrency con tamaño de loteEvita saturar conexiones de BD
Debounce de eventos con Map<id, Timeout>Reduce recálculos redundantes
Consolidación persistida en tablas Calculate*Evita recalcular desde cero en cada consulta
Trabajo en segundos, persistencia en intervalosOperaciones matemáticas simples, compatibilidad PostgreSQL
Agrupación previa con Map antes de iterarReduce consultas N+1

10. Consideraciones y edge cases

Orden cuando cambian múltiples campos simultáneamente: El cambio de tabla de vulnerabilidad (sección 4.1) se procesa antes que el cambio de tabla normal (4.2). El batch final de syncNonClassroomHoursWithoutUpdate actúa como capa de corrección sobre el estado final, independientemente de los estados intermedios.

Docentes sin PersonSchoolNthTable: calculateCompleteFuncionary los inicializa automáticamente. Si contractHours supera el máximo de la tabla, el exceso se acumula en nonClassroomHours. Si contractHours es 0, se crean registros con valores vacíos (00:00:00).

Fire-and-forget en el batch final: El batch de syncNonClassroomHoursWithoutUpdate al final de SchoolService.update no tiene await explícito antes del return, ejecutándose de forma asincrónica sin bloquear la respuesta. Las actualizaciones individuales dentro de changePersonSchoolHourTableNameVulnerability sí son secuenciales por docente para evitar condiciones de carrera.


Conclusión

La actualización de configuración de un colegio desencadena una cadena de redistribución horaria completamente automatizada. El diseño basado en eventos con debounce garantiza que el recálculo consolidado se ejecute una única vez por operación, independientemente de cuántos docentes se vean afectados. La separación en servicios especializados (changePersonSchoolNthTable, changePersonSchoolHourTableNameVulnerability, syncPersonSchoolNthTableVulnerability) permite mantener cada responsabilidad acotada y testeable de forma independiente.

Los siguientes pasos naturales son la cobertura con tests de integración para cada combinación del árbol de decisión de SchoolService.update, y el monitoreo del tiempo de ejecución del batch final en colegios con gran número de docentes.