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.
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ía | Descripción |
|---|---|
| Horas lectivas HA / HC | Tiempo frente a alumnos. HA = horas pedagógicas (45 min), HC = horas cronológicas (60 min) |
| Horas no lectivas | Preparación, corrección, reuniones técnicas |
| Recreo | Supervisión de recreos |
| Horas institucionales | Actividades 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
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.
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
syncPersonSchoolNthTableVulnerabilityconoldValue = 0, como si el porcentaje de alumnos prioritarios pasara de cero al valor configurado.
El árbol de decisión del Escenario A:
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:
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:
forceUpdateSpecificTeachers | isModified del docente | ¿Se actualiza? |
|---|---|---|
true | cualquiera | Sí |
false | false | Sí |
false | true | No — 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%:
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:
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
| Evento | Listener | Acción |
|---|---|---|
school-period.recalculate | handleRecalculate | Ejecuta calculateFunctionaryHours(schoolPeriodId) |
school-period.recalculate-person-school | handleRecalculatePersonSchool | Ejecuta 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:
El debounceMap: Map<schoolPeriodId, TimeoutHandle> asegura que cada periodo tenga su propio timer independiente.
7. Recálculo consolidado
calculateCompleteFuncionary — nivel docente
Las horas también se desglosan por subsidio para trazabilidad financiera:
| Subsidio | Descripción |
|---|---|
| SG | Subvención General |
| PIE | Programa de Integración Escolar |
| SEP | Subvenció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:
Esto permite trazar el cambio tanto desde el perfil del docente como desde la vista del colegio.
9. Estrategias de rendimiento
| Estrategia | Beneficio |
|---|---|
Promise.all en batch de updates | Paralelismo controlado |
runWithConcurrency con tamaño de lote | Evita 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 intervalos | Operaciones matemáticas simples, compatibilidad PostgreSQL |
Agrupación previa con Map antes de iterar | Reduce 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.