Guía de bloques SISCargaLectiva

SISCargaLectivaGuía de exposiciónCódigo de AppScript/

Bloques 2, 3 y 4: qué explicar, qué mostrar y qué permite cada pantalla

Cada bloque tiene la misma estructura: una ficha, un guion minuto a minuto, los conceptos que hay que dominar, un recorrido por la aplicación con capturas, el catálogo completo de acciones, el código clave comentado, los errores y avisos, las preguntas que pueden hacer y ejercicios para practicar. Las capturas salen de la vista previa local del proyecto, con los Excel de ejemplo de docs/archivos/.

2

Persistencia y concurrencia

jhuamanih@ulasalle.edu.pe La hoja como base de datos, las versiones, el bloqueo de edición, la copia de recuperación y los avisos entre pestañas. Ir al bloque 2 →
3

Entrada de datos: Excel y cargas masivas

mfloresl@ulasalle.edu.pe Los .xlsx de Drive, el lector sin conversión, la interpretación que nunca rechaza, el pegado, la subida de archivos y la carga de asignaciones. Ir al bloque 3 →
4

Plan de ciclo (CU-001)

jortizc@ulasalle.edu.pe El tablero, la consulta, la grilla de edición, desdobles y fusiones, el registro, el historial y la derivación de cursos. Ir al bloque 4 →

Antes de empezar

Los tres bloques se explican sobre el mismo sistema: una aplicación web de Google Apps Script donde el coordinador registra el plan de ciclo de su carrera y el director asigna los docentes. El servidor son los archivos .js de AppScript/; el navegador recibe una sola página armada con los módulos .html. Los datos viven en una hoja de cálculo de Google («SISCargaLectiva-BD») y la oferta sale de los .xlsx de una carpeta de Drive.

Dos convenciones del código que conviene decir al empezar cualquier bloque:

  • Las funciones que terminan en _ son privadas: el navegador no puede llamarlas. Las demás (guardarPlan, abrirEdicion…) son los endpoints.
  • Todo endpoint responde {ok: true, datos} o {ok: false, codigo, mensaje, detalle}. El navegador decide qué hacer por el codigo (por ejemplo BLOQUEADO o CONFLICTO), nunca por el texto.

La vista previa local: la aplicación sin Google

Para practicar sin tocar la aplicación publicada, el repositorio trae un servidor local que ejecuta el mismo código del servidor con imitaciones en memoria de Drive, Sheets, la caché y el candado (AppScript/pruebas/falsos.mjs). Al arrancar ejecuta instalar(), crea las cuentas de prueba y sube los tres Excel de ejemplo. Desde la raíz del repositorio:

node AppScript/pruebas/servidor-local.mjs

Después abre en el navegador la dirección con la cuenta que quieras usar:

http://127.0.0.1:8765/?usuario=mfloresl@ulasalle.edu.pe
Tres cosas que hay que saber de la vista previa Los datos viven en memoria: al cerrar el proceso se pierde todo y al volver a arrancarlo todo empieza en v1. Para probar bloqueos abre dos pestañas con ?usuario= distinto. La subida de .xlsx necesita internet, porque SheetJS se descarga de su CDN.

Las capturas de esta guía se tomaron así, en Chrome a 1280 × 860. Las imágenes de las pestañas de la hoja del sistema son reconstrucciones de los datos que la vista previa tenía en memoria en ese momento: muestran las mismas filas y columnas que vería Google Sheets, pero no son una captura de Google Sheets.

Cuentas de prueba

En la vista previa, la pestaña Usuarios se llena con estas cuentas (USUARIOS_PRUEBA en falsos.mjs). En la aplicación publicada los roles los decide la pestaña Usuarios real.

CuentaRolQué puede hacerÚsala para
mfloresl@ulasalle.edu.peCoordinador de las 8 carrerasEditar, guardar y registrar cualquier plan; consultar la asignación docenteBloques 3 y 4; primera cuenta del bloque 2
fmaresg@ulasalle.edu.peCoordinador de las 8 carrerasLo mismo que mfloreslSegunda cuenta para ver bloqueos (bloque 2)
jhuamanih@ulasalle.edu.peDirector de DIMAAsignar docentes en ING, IND, ARQ e ICO; derivar los cursos que asigna DIMACarga masiva de asignaciones (bloque 3); derivar (bloque 4)
jortizc@ulasalle.edu.peDirector de DAHUCAsignar docentes en PSI y COMVer un curso derivado a DAHUC
afrontadof@ulasalle.edu.peDirector de DACJAsignar docentes en DER—
aarenasc@ulasalle.edu.peDirector de DACTEAsignar docentes en ADM—

Cómo encajan los tres bloques

Camino de un dato, del Excel a una versión guardada

Bloque 3Entra una tablaExcel de Drive, rango pegado o archivo subidointerpretar()
→
Bloque 4Se edita el planGrilla: grupos, desdobles, fusionesDominio.fusionar()
→
Bloque 4Se guardaBorrador, registro o cambiosguardarPlan()
→
Bloque 2Queda una versiónCon candado, bloqueo y conflicto resueltosregistrarVersion_()

El bloque 3 convierte tablas en asignaturas y grupos. El bloque 4 es la pantalla donde el coordinador trabaja con ellos. El bloque 2 está debajo de ambos: decide cómo se guarda cada cambio, quién puede escribir en cada momento y cómo no se pierde nada. Los tres usan Dominio.js, la lógica pura que se ejecuta igual en el servidor y en el navegador.

Bloque 2

Persistencia y concurrencia

Responsable: jhuamanih@ulasalle.edu.pe

2.1Ficha del bloque

Pregunta que responde
¿Cómo se guarda cada cambio sin perder nada y sin que dos personas se pisen, usando una hoja de cálculo como base de datos?
Archivos del servidor
BaseDatos.js 328Versionado.js 305Bloqueos.js 142Recuperacion.js 122Migraciones.js 90
Archivos del navegador
Edicion.html 186Pestanas.html 161
Lo usa
Planes.js y Fusiones.js (bloque 4), FuenteDrive.js (bloque 3), CargaDocente.js (bloque 5), GrillaEdicion.html y AsignacionEdicion.html, que crean su sesión de edición con crearSesionEdicion.
Depende de
Respuesta.js (fallo_ y los códigos), Config.js (minutosBloqueo), Dominio.REF y Dominio.isoPeru.
Pruebas
AppScript/pruebas/infraestructura.test.mjs (versionado común, bloqueos con referencia, escrituras y recuperación) y, en servidor.test.mjs, las pruebas del bloqueo por carrera, la caducidad y el latido, el conflicto, la restauración y el plan repartido en varias celdas.
Idea central
Nada se sobrescribe: cada guardado añade una versión. Dos mecanismos distintos evitan los choques: el candado del script (milisegundos) y el bloqueo de edición (minutos).

2.2Guion de exposición · unos 12 minutos

  1. 0:00 – 1:00

    El problema

    «Varias personas editan los mismos planes desde su navegador y no tenemos un servidor de base de datos: tenemos una hoja de cálculo de Google. Este bloque resuelve tres riesgos: que un guardado borre otro, que dos personas editen lo mismo a la vez y que se pierda lo que no se guardó si se cierra la pestaña.»

    Muestra: el tablero con la columna «Editando» (captura 2.6).

  2. 1:00 – 3:00

    La hoja como base de datos

    «BaseDatos.js define ocho pestañas en TABLAS_, cada una con sus columnas. Todo se guarda como texto plano, con formato @, porque Sheets convertiría “2026-1” en una fecha. Un texto que empieza por igual, más, menos o arroba se escribe con un apóstrofo para que nunca se ejecute como fórmula. Se lee con leerTabla_, que devuelve cada fila como un objeto y recuerda su número de fila, y se escribe en bloque con escribirFilas_, en una sola llamada.»

    Muestra: la pestaña Planes (captura 2.1) y BaseDatos.js:4 (TABLAS_).

  3. 3:00 – 5:30

    Versiones: índice más historial

    «Cada documento, sea un plan o una carga docente, usa dos pestañas. El índice tiene una fila por documento con su versión vigente y la fila exacta donde está guardada. La tabla de versiones tiene una fila por guardado con el contenido completo en JSON. registrarVersion_ añade la versión y actualiza el índice. Como el contenido de una versión nunca cambia, se guarda seis horas en la caché. Restaurar no borra nada: crea una versión nueva con el contenido viejo.»

    Muestra: el historial (captura 2.9), la v3 creada al restaurar (2.10) y la pestaña Versiones (2.11).

  4. 5:30 – 8:00

    El bloqueo de edición

    «Cuando alguien pulsa Editar, el servidor anota en la pestaña Bloqueos que ese plan es suyo hasta una hora de caducidad: treinta minutos después de la última actividad. Mientras haga clic o escriba, el navegador manda un latido, como mucho una vez por minuto, que mueve esa hora. Otra cuenta ve el plan bloqueado y solo puede consultarlo. Si nadie hace nada en treinta minutos, el navegador guarda lo pendiente como versión y suelta el plan.»

    Muestra: dos pestañas con cuentas distintas (capturas 2.4 a 2.8) y la expiración (2.21).

  5. 8:00 – 9:30

    Conflicto de versiones: advertir, no prohibir

    «El bloqueo no cubre todos los casos: la misma persona puede tener el plan abierto en dos pestañas, o el Excel puede actualizarse mientras se edita. Por eso cada guardado envía la versión sobre la que se trabajó. Si en el servidor ya hay una más nueva, responde CONFLICTO y el navegador pregunta. Si la persona insiste, se guarda con forzar, y la otra versión sigue en el historial.»

    Muestra: capturas 2.14 a 2.16.

  6. 9:30 – 10:45

    Copia de recuperación

    «Cada minuto, si hay cambios sin guardar, el navegador manda una copia a la pestaña Recuperacion. No es una versión: es una sola fila por persona y documento. Si la página se cae, al volver a editar se ofrece recuperarla. Se escribe con un candado breve de dos segundos: si el sistema está ocupado con un guardado de verdad, la copia se salta y se repite al minuto siguiente.»

    Muestra: la pestaña Recuperacion (2.17) y el diálogo (2.18).

  7. 10:45 – 11:30

    Avisos entre pestañas

    «Las pestañas del mismo navegador se avisan con BroadcastChannel cuando alguien toma o suelta un plan, guarda una versión o relee los Excel. Así el tablero se actualiza sin pedirlo al servidor. Es solo una comodidad: la fuente de verdad sigue siendo el servidor, y sin el canal todo funciona igual.»

    Muestra: captura 2.13 o 2.15.

  8. 11:30 – 12:00

    Migraciones y cierre

    «instalar() se puede repetir sin riesgo: crea las pestañas que falten y aplica dos migraciones que solo tocan lo que aún está en el formato viejo: las fechas pasan a hora de Perú y las cargas por departamento pasan a cargas por carrera. Resumen: versiones que nunca se borran, un candado para las escrituras, un bloqueo para las personas y una copia por si algo falla.»

2.3Conceptos clave

Las ocho pestañas de «SISCargaLectiva-BD»

PestañaColumnasPara qué sirveQuién escribe
Usuarioscorreo, rol, carreras, departamentoRol de cada cuenta (se guarda 5 min en caché)instalar() la siembra; luego se edita a mano
Planesciclo, carrera, estado, version, actualizado_por, actualizado_en, firma_xlsx, totales, fila_versionÍndice de planes: versión vigente de cada carrera en cada cicloregistrarVersion_ (REPO_PLAN_)
Versionesciclo, carrera, n, estado, tipo, usuario, fecha, motivo, contenido…Una fila por guardado de un plan, con el JSON completoregistrarVersion_
Bloqueosciclo, ref, correo, desde, expiraQuién edita qué documento y hasta cuándotomarBloqueo_, soltarBloqueo_
Fuentestipo, archivo_id, nombre, modificado, procesado_en, avisosFirma de los .xlsx ya procesados (bloque 3)revisarFuentes_
Cargasciclo, carrera, version, actualizado_por, actualizado_en, fila_versionÍndice de cargas docentesregistrarVersion_ (REPO_CARGA_)
VersionesCargaciclo, carrera, n, tipo, usuario, fecha, motivo, planes, contenido…Una fila por guardado de una carga; «planes» guarda la versión de cada plan que se tenía a la vistaregistrarVersion_
Recuperacionciclo, ref, correo, fecha, version_base, contenido…Lo que se editaba sin guardarguardarRecuperacion
Captura 2.1La pestaña Planes después de leer los Excel: una fila por plan, todos Registrado v1, escritos por «xlsx: …». La última columna, fila_version, dice en qué fila de Versiones está el contenido vigente.

Reglas de escritura en la hoja

ReglaDóndePor qué
Todas las celdas con formato texto (@)prepararTabla_ · BaseDatos.js:73
asegurarTamano_ · :136
Sheets convertiría «2026-1» o «1-2» en fechas y quitaría ceros. Las filas y columnas nuevas se crean ya con ese formato.
Apóstrofo ante = + - @celdaTexto_ · :122Evita que un texto escrito por alguien se ejecute como fórmula (inyección de fórmulas).
Leer devuelve objetos con _filaleerTabla_ · :103Se trabaja por nombre de columna y se sabe qué fila reescribir o borrar.
Escribir en bloqueescribirFilas_ · :167Una sola llamada a setValues: cada llamada a Sheets cuesta tiempo y cuota.
Borrar de abajo arriba, agrupando filas seguidasborrarFilasDonde_ · :200Al borrar de abajo arriba los números de las filas pendientes no cambian.
Contenido en trozos de 45 000 caracterestrocear_ · :241
unirDesde_ · :232
Una celda admite 50 000. Un plan grande ocupa varias celdas seguidas desde «contenido».
Claves de caché con el id de la hojaclaveCache_ · :262«sis:<bdId>:<tipo>:<resto>»: si se reinstala, la caché vieja no sirve datos de la hoja anterior.

Dos niveles de exclusión

Candado del scriptBloqueo de edición
HerramientaLockService.getScriptLock() en conCandado_Una fila en la pestaña Bloqueos (tomarBloqueo_)
DuraciónLo que tarda una escritura (milisegundos); espera hasta 30 s30 minutos desde la última actividad (SIS_MINUTOS_BLOQUEO)
ProtegeQue dos ejecuciones escriban la hoja a la vezQue dos personas editen el mismo plan o la misma carga
AlcanceTodo el script: una escritura a la vezUn documento: plan:ING o carga:ING en un ciclo
Si está ocupadoError OCUPADO: «Inténtalo de nuevo en unos segundos»Error BLOQUEADO: se ofrece consultar en solo lectura
Quién lo liberafinally: siempre, después de SpreadsheetApp.flush()Salir, registrar, cerrar la pestaña, expirar; si no, caduca solo

Índice y versiones: cómo se lee la vigente sin recorrer la tabla

REPO_PLAN_ = {indice: "Planes", versiones: "Versiones", clave: "carrera"}

Planes (índice)una fila por plan
ciclocarreraestadoversionfila_version
2026-1DERRegistrado13
2026-1INGRegistrado312 →
2026-1INDRegistrado313
Versionesuna fila por guardado
filacarrerantipocontenido
4ING1importación desde xlsx[{"sem":1,…
10ING2guardar cambios[{"sem":1,…
12ING3restauración de v1[{"sem":1,…

Los números de fila son ilustrativos. contenidoVersion_ mira primero la caché; si no está, lee solo la fila que indica fila_version y comprueba con esLaVersion_ que sea esa versión. Si no coincide (alguien borró filas a mano), busca la fila en la lista de versiones y, si hace falta, vuelve a leer la lista sin caché.

Ciclo de vida de un bloqueo

LibreNo hay fila vigente para la ref
→
Tomado«Editar» → abrirEdicion → tomarBloqueo_expira = ahora + 30 min
→
RenovadoLatido con clic o tecla, máx. 1 por minuto → renovarBloqueo
→
SoltadoSalir, registrar, cerrar la pestaña o expirar → liberarBloqueo
CaducadoPasó «expira» sin latido: la fila queda, pero bloqueoVigente_ la ignora
→
Tomado por otra cuentatomarBloqueo_ reescribe la fila con el nuevo correo
→
PerdidoEl siguiente latido de la primera cuenta recibe BLOQUEO_PERDIDO

Línea de tiempo de una sesión de edición

0:00Pulsa Editar. La grilla se abre al instante con el contenido del tablero; Guardar queda desactivado hasta que el servidor confirma el bloqueo.
cada ≤ 1 minCon cada clic o tecla, si pasó el intervalo, latido: renovarBloqueo mueve «expira».
cada 1 minSi hay cambios sin guardar, copia de recuperación con guardarRecuperacion.
al guardarVersión nueva, se borra la copia y se renueva el bloqueo.
30 min sin actividadExpira: autoguardado de lo pendiente, se suelta el plan y se avisa.

El intervalo del latido es min(60 s, minutosBloqueo × 15 s): con 30 minutos es un minuto; con 1 minuto de bloqueo serían 15 segundos.

Qué se guarda en la caché del script y cuánto

DatoTiempoPor qué no queda viejo
Contenido de una versión6 h (21 600 s, el máximo)Una versión nunca cambia. Si pasa de 45 000 caracteres no se guarda.
Lista de versiones de un documento6 hSe completa al guardar; si no llega a la vigente del índice, se vuelve a leer.
Rol de cada cuenta5 minUn cambio en Usuarios tarda como mucho eso.
Revisión de los .xlsxSIS_MINUTOS_REVISION_XLSX (5 min)«Releer Excel» la fuerza.
Catálogo de docentes1 hSe precalienta cuando le quedan menos de 10 min.
Docentes por grupo6 hLa clave lleva la versión de cada plan y de cada carga: cualquier guardado cambia la clave.

2.4Recorrido con capturas

Usa dos pestañas: una con ?usuario=mfloresl@ulasalle.edu.pe (cuenta A) y otra con ?usuario=fmaresg@ulasalle.edu.pe (cuenta B).

  1. Tomar el bloqueo de un plan

    con la cuenta A, en el tablero, pulsa Editar en Ingeniería de Software.

    Captura 2.2El reloj de la sesión. Antes de que el servidor responda dice «Tomando el bloqueo de edición…» y los botones de guardar están desactivados.
    Verás
    La grilla se abre al instante con el contenido que trajo el tablero. En cuanto llega la respuesta, el reloj empieza a contar 30:00 hacia atrás.
    En el código
    abrirEdicion (GrillaEdicion.html:107) pinta la grilla y llama de fondo a abrirEdicion del servidor (Planes.js:200), que dentro de conCandado_ ejecuta tomarBloqueo_ y devuelve expira y la copia de recuperación si existe. confirmarEdicion arranca la sesión con sesionPlan.iniciar(expira).
    Detalle fino
    Si el navegador ya tenía la versión vigente, el servidor no vuelve a mandar el contenido (versionConocida). Si entretanto se guardó otra versión y aún no hay cambios, se carga esa y aparece «Se cargó la versión más reciente del plan».
  2. La reserva en la pestaña Bloqueos

    mira la pestaña Bloqueos mientras la cuenta A edita.

    Captura 2.3La fila plan:ING: ciclo, referencia, correo, desde y expira, en hora de Perú (-05:00).
    En el código
    tomarBloqueo_ (Bloqueos.js:71) reescribe la fila si ya existía para esa referencia, o añade una. Si el bloqueo vigente es de otra cuenta devuelve ok: false. «desde» se conserva en las renovaciones; «expira» siempre se mueve.
  3. La otra cuenta ve el plan bloqueado

    con la cuenta B, abre Registrar plan de ciclo.

    Captura 2.4Para la cuenta B, ING aparece «Bloqueado», con el correo de quien edita y la versión v2 que A acaba de guardar. La barra de avance cuenta «1 en edición por otra cuenta».
    En el código
    leerTablero_ (Planes.js:122) añade a cada fila su bloqueo vigente. pintarTablero (Tablero.html:187) muestra «Bloqueado» solo si el bloqueo es de otra cuenta.
  4. Intentar editar un plan bloqueado

    con la cuenta B, pulsa Editar en ING.

    Captura 2.5El servidor respondió BLOQUEADO con el correo y la hora en detalle.
    Captura 2.6La consulta en solo lectura dice quién lo está editando.
    En el código
    Como el tablero ya sabe que otra cuenta lo tiene, abrirEdicion no abre la grilla: llama a abrirEdicionEsperando, que espera la respuesta. falloBloqueado_ (Bloqueos.js:60) arma el error y avisarBloqueado (GrillaEdicion.html:197) ofrece consultar.
  5. Guardar crea una versión

    con la cuenta A, haz un cambio y pulsa Guardar cambios; luego abre Historial de versiones.

    Captura 2.7El historial: v1 «importación desde xlsx» escrita por el Excel y v2 «guardar cambios» con el motivo. El selector de abajo elige qué versión restaurar.
    En el código
    obtenerHistorial (Planes.js:266) devuelve metaVersiones_ sin el contenido ni la fila. dialogoHistorial (Edicion.html:166) es común al plan y a la carga.
  6. Restaurar no borra el historial

    elige v1 y pulsa Restaurar.

    Captura 2.8La nota de estado ahora dice v3: restaurar la v1 creó una versión nueva de tipo «restauración de v1».
    Captura 2.9En Versiones quedan las tres versiones de ING y las de IND: la v2 de ING llevaba una fusión con IND, así que IND recibió su propia versión de tipo «fusión», y al restaurar la v1 la fusión se deshizo también en IND.
    En el código
    restaurarVersion (Planes.js:281) lee la versión pedida, la pasa por prepararFusiones_ y la registra con Dominio.TIPO_VERSION.restauracion(k). El estado nunca baja de Registrado (resolverEstado_).
  7. Salir de la edición suelta el plan

    pulsa Salir de la edición y confirma.

    Captura 2.10Lo que no se guardó se descarta y se libera el bloqueo.
    Captura 2.11En la pestaña de la cuenta B, que consultaba ING, aparecen dos avisos: la consulta ya muestra la v3 que A restauró, sin pedirla al servidor, y el plan quedó libre.
    En el código
    terminarEdicion (GrillaEdicion.html:67) descarta la copia de recuperación, avisa a las otras pestañas con anunciar("soltado") y llama a liberarBloqueo. Si se cierra la pestaña, el evento pagehide llama a liberarBloqueo sin esperar respuesta.
  8. La misma cuenta en dos pestañas

    con la cuenta A editando Derecho, abre otra pestaña con la misma cuenta y pulsa Editar en Derecho.

    Captura 2.12El servidor no lo impediría, porque el bloqueo es de la misma cuenta. El navegador lo sabe por el canal entre pestañas y advierte.
    En el código
    otraPestanaEditando y avisarOtraPestana (Pestanas.html:69 y :75). La lista de planes en edición se llena con los mensajes «editando», y una pestaña nueva la pide con «hola».
  9. Conflicto de versiones

    elige «Editar aquí de todos modos», guarda en la segunda pestaña y después guarda en la primera.

    Captura 2.13Al guardar en la segunda pestaña, la primera recibe el mensaje «guardado» y avisa de que se le preguntará antes de guardar.
    Captura 2.14El servidor respondió CONFLICTO con los datos de la versión v2 en detalle. «Guardar de todos modos» repite la llamada con forzar: true y crea la v3.
    En el código
    El navegador manda versionBase. conflictoPlan_ (Planes.js:109) compara con la vigente; guardar (GrillaEdicion.html:223) captura el código y llama a confirmarConflicto (Edicion.html:150).
  10. Copia de recuperación

    edita un plan sin guardar y cierra la pestaña de golpe (o espera a que pase un minuto). Vuelve a pulsar Editar en ese plan.

    Captura 2.15La pestaña Recuperacion: una fila por cuenta y documento, con la versión sobre la que se trabajaba (version_base) y el contenido.
    Captura 2.16Al volver a editar se ofrece recuperar o descartar. Si se cierra el diálogo sin elegir, la copia se conserva.
    Captura 2.17Con «Recuperarlos», la grilla queda con los cambios y marcada como sin guardar.
    En el código
    La sesión llama a copia() cada 60 s y solo envía si el contenido cambió desde la última copia que el servidor confirmó. guardarRecuperacion (Recuperacion.js:88) usa conCandadoSiLibre_ con 2 s: si está ocupado responde {omitida: true} y se repite al minuto. abrirEdicion devuelve la copia con leerRecuperacion_.
    Para la demo
    En la captura, la copia se creó llamando a guardarRecuperacion desde la vista previa, que es lo mismo que hace el navegador cada minuto.
  11. Bloqueo perdido

    deja pasar el tiempo de bloqueo sin actividad mientras otra cuenta toma el plan; luego haz clic en la grilla.

    Captura 2.18El latido recibió BLOQUEO_PERDIDO: la sesión se detiene, se vuelve al tablero y lo no guardado queda en la copia de recuperación.
    En el código
    renovarBloqueo_ (Bloqueos.js:89) falla si tomarBloqueo_ devuelve ok: false. En el navegador, latido (Edicion.html:61) reconoce el código y llama a op.alPerder, que en el plan es bloqueoPerdido (GrillaEdicion.html:24).
    Para la demo
    Para no esperar, en la captura se cambió el correo de la fila de Bloqueos por el de la cuenta B, que es lo que hace tomarBloqueo_ cuando otra cuenta toma un bloqueo caducado.
  12. La sesión expira y se autoguarda

    haz un cambio y no toques nada hasta que el reloj llegue a cero.

    Captura 2.19Los cambios se guardaron como una versión «autoguardado» y se liberó el bloqueo. Para la captura se usó SIS_MINUTOS_BLOQUEO = 1.
    En el código
    expirar (GrillaEdicion.html:35) llama a guardarPlan con tipo: "autoguardado" y forzar: true, y luego a liberarBloqueo. Si no había cambios solo suelta el plan.

2.5Catálogo de acciones y posibilidades

AcciónCómo se disparaEndpoint o funciónResultado
Tomar el bloqueo de un plan«Editar» en el tablero, «Editar este plan» en la consulta o «Seguir editando el plan» tras registrarabrirEdicion → tomarBloqueo_Sesión de 30 min; BLOQUEADO si otra cuenta lo tiene
Renovar (latido)Automático con clic o tecla, como mucho una vez por minutorenovarBloqueoNueva «expira»; BLOQUEO_PERDIDO si otra cuenta lo tomó
Soltar el bloqueo«Salir de la edición», registrar el plan, expirar o cerrar la pestañaliberarBloqueoBorra la fila de Bloqueos de esa cuenta
Guardar una versiónGuardar borrador, Registrar, Guardar cambios, autoguardado, fusión, cambio de departamento, importaciónguardarPlan → registrarVersion_Fila nueva en Versiones; índice actualizado; copia borrada
Ver el historial«Historial de versiones» en la grillaobtenerHistorialLista de versiones con tipo, usuario, fecha y motivo
Restaurar una versión«Restaurar» en el historialrestaurarVersionVersión nueva «restauración de vN»
Guardar a pesar del conflicto«Guardar de todos modos»guardarPlan con forzarVersión nueva encima de la más reciente
Copia de recuperaciónAutomática cada 60 s si hay cambios y el bloqueo está confirmadoguardarRecuperacionUna fila por cuenta y documento; omitida si está ocupado
Recuperar o descartarDiálogo al volver a editardescartarRecuperacionRecuperar pinta la copia; descartar la borra
Editar en otra pestaña de todos modosDiálogo «Ya editas este plan en otra pestaña»abrirEdicion(sig, true)Dos pestañas con el mismo bloqueo; el conflicto avisará al guardar
Lo mismo con la carga docente«Editar asignaciones» en Asignar docentesabrirEdicionCarga, renovarBloqueoCarga, liberarBloqueoCarga, guardarCarga, restaurarCargaMismas piezas con la referencia carga:ING y el repositorio REPO_CARGA_
Instalar o reinstalarEjecutar instalar() desde el editor de Apps ScriptprepararTablas_, migracionesCrea lo que falte; retira la pestaña obsoleta Asignaciones

2.6Código clave comentado

Escribir texto seguro y escribir en bloque

BaseDatos.js · celdaTexto_ y escribirFilas_líneas 122–181
function celdaTexto_(v) {
  const t = v == null ? "" : String(v);
  return /^[=+\-@]/.test(t) ? "'" + t : t;   // nunca una fórmula
}

function escribirFilas_(nombre, fila, filas) {
  if (!filas.length) return;
  const h = hojaBd_(nombre);
  const ancho = filas.reduce(function (n, f) { return Math.max(n, f.length); }, 0);
  asegurarTamano_(h, fila + filas.length - 1, ancho);   // filas y columnas nuevas, en formato texto
  h.getRange(fila, 1, filas.length, ancho).setValues(filas.map(function (f) {
    const v = f.map(celdaTexto_);
    while (v.length < ancho) v.push("");
    return v;
  }));                                                   // una sola llamada a Sheets
}

El candado del script

BaseDatos.js · conCandado_líneas 318–328
function conCandado_(fn) {
  const candado = LockService.getScriptLock();
  if (!candado.tryLock(30000))
    fallo_("OCUPADO", "El sistema está ocupado con otra operación. Inténtalo de nuevo en unos segundos.");
  try {
    return fn();
  } finally {
    SpreadsheetApp.flush();      // escribe todo antes de soltar
    candado.releaseLock();
  }
}

Todo endpoint que escribe hace sus comprobaciones dentro de conCandado_: así, entre leer el bloqueo o la versión vigente y escribir la nueva, ninguna otra ejecución puede colarse. El flush antes de soltar garantiza que la siguiente ejecución ya lea lo escrito.

Registrar una versión

Versionado.js · registrarVersion_líneas 276–305
function registrarVersion_(repo, ciclo, clave, fila, datos) {
  const n = vigente_(fila).version + 1;                       // la siguiente versión
  const texto = JSON.stringify(datos.contenido);
  const comunes = { ciclo: ciclo, n: n, usuario: datos.usuario, fecha: datos.fecha };
  comunes[repo.clave] = clave;
  const meta = Object.assign({}, datos.meta || {}, comunes);
  const camposVersion = TABLAS_[repo.versiones].slice(0, columnaContenido_(repo));
  const filaVersion = agregarFila_(repo.versiones,             // 1. fila nueva en Versiones
    camposVersion.map(function (c) { return meta[c] == null ? "" : meta[c]; }).concat(trocear_(texto)));

  const indice = Object.assign({}, fila || {}, datos.indice || {}, {
    ciclo: ciclo, version: n, actualizado_por: datos.usuario, actualizado_en: datos.fecha, fila_version: filaVersion
  });
  indice[repo.clave] = clave;
  const valores = TABLAS_[repo.indice].map(function (c) { return indice[c] == null ? "" : indice[c]; });
  if (fila) escribirFila_(repo.indice, fila._fila, valores);   // 2. índice: reescribe
  else agregarFila_(repo.indice, valores);                   //    o añade si es el primer guardado

  cachearTexto_(claveContenido_(repo, ciclo, clave, n), texto); // 3. caché del contenido
  /* …y completa en la caché la lista de versiones, si estaba */
  return n;
}

La misma función sirve para planes (REPO_PLAN_) y para cargas (REPO_CARGA_): solo cambian los nombres de las pestañas. Por eso registrarVersionPlan_ (Planes.js:82) y registrarVersionCarga_ son envoltorios de tres líneas. Debe llamarse dentro de conCandado_.

Tomar o renovar un bloqueo

Bloqueos.js · tomarBloqueo_líneas 71–80
function tomarBloqueo_(ciclo, ref, correo) {
  const b = leerBloqueos_().filter(function (x) { return x.ciclo === ciclo && x.ref === ref; })[0];
  if (bloqueoVigente_(b) && b.correo !== correo) return { ok: false, bloqueo: b };  // es de otra cuenta
  const desde = bloqueoVigente_(b) ? b.desde : Dominio.isoPeru();                  // se conserva al renovar
  const expira = Dominio.isoPeru(Date.now() + config_().minutosBloqueo * 60000);
  const valores = [ciclo, ref, correo, desde, expira];
  if (b) escribirFila_(TABLA_.BLOQUEOS, b._fila, valores);   // reutiliza la fila de esa ref
  else agregarFila_(TABLA_.BLOQUEOS, valores);
  return { ok: true, desde: desde, expira: expira };
}

El latido en el navegador

Edicion.html · crearSesionEdicion → latidolíneas 61–80
async function latido(){
  if (!s.temporizador) return;                                   // no hay sesión activa
  const intervalo = Math.min(60000, CONFIG.minutosBloqueo * 15000);
  if (Date.now() - s.ultimoLatido < intervalo) return;            // como mucho uno por intervalo
  s.ultimoLatido = Date.now();
  const generacion = s.generacion;
  let r;
  try {
    r = await op.renovar();
  } catch (e) {
    if (generacion !== s.generacion || !s.temporizador) return;  // respuesta de una sesión anterior
    if (e && e.codigo === "BLOQUEO_PERDIDO") { detener(); return op.alPerder(e.detalle ? e.detalle.correo : ""); }
    return;   // un fallo de red: la siguiente actividad lo reintenta
  }
  if (generacion !== s.generacion || !s.temporizador) return;
  s.expira = Date.parse(r.expira);
  pintar();
  if (op.alRenovar) op.alRenovar();
}
["click", "keydown"].forEach(ev => document.addEventListener(ev, latido, { passive: true }));

crearSesionEdicion es una fábrica: recibe qué hacer al renovar, al perder, al expirar y cómo copiar, y devuelve una sesión. El plan (sesionPlan, GrillaEdicion.html:9) y la carga docente (sesionCarga, AsignacionEdicion.html) son dos sesiones del mismo código. La «generación» descarta respuestas que llegan cuando ya se salió de esa sesión.

Guardar con conflicto

GrillaEdicion.html · guardarlíneas 223–242
async function guardar(datos){
  const p = app.plan, sig = app.carrera;
  const base = { asignaturas: p.asignaturas, versionBase: p.version, fusionesDisueltas: p.fusionesDisueltas || [] };
  let r;
  try {
    r = await llamar("guardarPlan", app.ciclo, sig, Object.assign({}, base, datos));
  } catch (e) {
    if (e.codigo !== "CONFLICTO") throw e;
    if (!await confirmarConflicto(e.detalle, "del plan")) return null;   // advertir…
    r = await llamar("guardarPlan", app.ciclo, sig, Object.assign({}, base, datos, { forzar: true }));  // …no prohibir
  }
  Object.assign(p, { version: r.version, estado: r.estado, sucio: false, fusionesDisueltas: [] });
  sesionPlan.olvidarCopia();
  sesionPlan.renovada(r.expira);
  anunciarGuardado(sig, r.estado, r.version, p.asignaturas);           // avisa a las otras pestañas
  /* … */
}

La copia de recuperación con candado breve

Recuperacion.js · guardarRecuperacionlíneas 88–112
const guardada = conCandadoSiLibre_(function () {
  const f = filaRecuperacion_(c, r.ref, usuario.correo);
  const fecha = Dominio.isoPeru();
  const valores = [c, r.ref, usuario.correo, fecha, String(parseInt(String(datos && datos.versionBase), 10) || 0)].concat(trocear_(texto));
  if (f) { /* limpia las celdas sobrantes y reescribe su fila */ }
  else agregarFila_(TABLA_.RECUPERACION, valores);
  return { fecha: fecha };
}, MS_CANDADO_RECUPERACION_);                 // 2000 ms
return guardada || { fecha: null, omitida: true };

Mensajes entre pestañas

MensajeLo envíaQué hacen las demás pestañas
holaUna pestaña que arrancaResponden con «editando» si tienen un plan abierto
editandoAl confirmar el bloqueo y en cada renovaciónMarcan el plan como bloqueado en su tablero; si es nuevo, avisan
soltadoAl salir, expirar, perder el bloqueo o registrarLo quitan del tablero; si lo consultaban, avisan de que quedó libre
guardadoTras guardar, restaurar o una fusión ajenaActualizan la fila del tablero sin pedirla al servidor; si editan ese plan, advierten
tableroTras «Releer Excel»Vuelven a leer el tablero
asignacionesTras guardar una carga docentePiden de nuevo los docentes por grupo y la carga, si la ven

2.7Errores, avisos y casos límite

CódigoCuándo ocurreQué ve la persona
OCUPADOEl candado no se consiguió en 30 s«El sistema está ocupado con otra operación. Inténtalo de nuevo en unos segundos.»
BLOQUEADOAbrir o guardar un plan que edita otra cuenta, o una fusión que toca un plan bloqueado«El plan está siendo editado» y paso a solo lectura
BLOQUEO_PERDIDOUn latido cuando el bloqueo ya es de otra cuenta«Se perdió el bloqueo de edición»
CONFLICTOSe guardó otra versión después de la versionBase«Hay una versión más reciente del plan» con Cancelar y Guardar de todos modos
NO_ENCONTRADORestaurar una versión que no existe, o una fila de versión que desapareció«No se encontró la versión vN del plan.»
NO_INSTALADOFalta SIS_BD_ID o una pestaña sin definiciónPide ejecutar instalar()
DATO_INVALIDOReferencia de recuperación que no es plan:XXX o carga:XXX«Referencia de recuperación no válida.»
INTERNOCualquier excepción no prevista; se registra con console.errorMensaje genérico, sin detalles técnicos
Casos límite que conviene mencionar Una caché sin espacio no rompe nada: guardarEnCache_ ignora los valores de más de 100 KB y se vuelve a leer la hoja. Si alguien borra filas a mano en Versiones, textoDeVersion_ detecta que fila_version ya no es esa versión y la busca. Un bloqueo caducado no se borra: la fila queda hasta que alguien la reutiliza o la suelta, y bloqueoVigente_ la ignora.

2.8Preguntas probables

¿Por qué una hoja de cálculo y no una base de datos?

Apps Script no trae una base de datos, la institución ya trabaja con Google Sheets y la hoja se puede auditar a mano. Sus límites se resuelven en el código: texto plano para que nada se convierta, 45 000 caracteres por celda con el contenido repartido, LockService para la concurrencia y caché para no leerla entera cada vez.

¿Qué pasa si dos personas guardan exactamente al mismo tiempo?

conCandado_ las pone en fila: la segunda espera hasta 30 s a que termine la primera. Si son el mismo plan, solo una puede tener el bloqueo; la otra recibe BLOQUEADO. Si las dos tienen el bloqueo (misma cuenta en dos pestañas), la segunda recibe CONFLICTO.

¿Cuál es la diferencia entre el candado y el bloqueo?

El candado protege la escritura física de la hoja durante milisegundos y abarca todo el script. El bloqueo protege el trabajo de una persona durante minutos y abarca un documento. Sin candado, dos guardados podrían intercalar sus escrituras; sin bloqueo, dos personas editarían el mismo plan sin saberlo.

¿Se puede perder trabajo?

Las versiones nunca se borran. Lo que aún no se guardó tiene tres redes: la copia de recuperación cada minuto, el autoguardado al expirar y el aviso del navegador si se intenta cerrar la página con cambios (beforeunload). Lo único que se pierde es lo editado en el último minuto si la página se cae.

¿Por qué el conflicto no impide guardar?

Es un principio del caso de uso: el sistema advierte, no prohíbe. La persona decide, y la otra versión sigue en el historial, así que nada es irreversible: se puede restaurar.

¿Qué pasa si cierro la pestaña sin salir de la edición?

El evento pagehide llama a liberarBloqueo sin esperar respuesta. Si esa llamada no llega, el bloqueo caduca solo a los 30 minutos. Lo no guardado queda en la copia de recuperación si ya pasó un minuto.

¿Cómo se lee la versión vigente sin recorrer toda la tabla?

El índice guarda fila_version. Se lee solo esa fila y se comprueba con esLaVersion_ que ciclo, clave y número coincidan. Además, el contenido vigente de varios planes se lee en un solo bloque cuando sus filas están cerca (contenidosVigentes_), como pasa tras leer los Excel.

¿La caché puede servir datos viejos?

No en lo importante: el contenido de una versión no cambia nunca, así que su caché es siempre correcta. Lo que sí cambia (docentes por grupo) lleva en la clave las versiones de todos los planes y cargas, y cualquier guardado cambia la clave. Las claves llevan el id de la hoja del sistema.

¿Qué pasa cuando Versiones crezca mucho?

Leer la vigente no se encarece, porque el índice dice su fila. El historial sí lee la tabla. La política propuesta (cabecera de Versionado.js, auditoría P3.6-j) es archivar en otro libro las versiones de cada ciclo cerrado y dejar solo la vigente y el último registro.

¿Las pestañas del navegador sustituyen al servidor?

No. Son avisos para que la pantalla se actualice al momento. Cada decisión (bloqueo, conflicto, permiso) la vuelve a comprobar el servidor. Si el navegador no tiene BroadcastChannel, todo funciona igual sin esos avisos.

¿Por qué las fechas terminan en -05:00?

Dominio.isoPeru guarda la hora de Perú (UTC−5, sin horario de verano) en ISO 8601 con su desfase. En la hoja se leen como hora local y Date.parse las entiende igual que las antiguas en UTC. La migración convertirFechasAPeru_ pasó las viejas a este formato.

2.9Para practicar

node --test AppScript/pruebas/infraestructura.test.mjs AppScript/pruebas/servidor.test.mjs

Bloque 3

Entrada de datos: Excel y cargas masivas

Responsable: mfloresl@ulasalle.edu.pe

3.1Ficha del bloque

Pregunta que responde
¿Cómo una tabla (un Excel de Drive, un rango pegado o un archivo subido) se convierte en asignaturas, grupos y asignaciones, sin rechazar nunca lo que llega?
Archivos del servidor
LectorXlsx.js 310OfertaXlsx.js 90FuenteDrive.js 217CargaDocenteCatalogo.js 95
Archivos del navegador
Rejilla.html 148ImportarOferta.html 184AsignacionCarga.html 191
Parte de Dominio.js
L116–240 normalización y textoL465–611 deducir columnas e interpretarL1357–1516 hoja de CargaXDepartamento
Lo usa
El bloque 4 recibe las asignaturas interpretadas en la grilla. El bloque 2 guarda las versiones de importación. El bloque 5 recibe las asignaciones de la carga masiva y el catálogo de docentes.
Pruebas
AppScript/pruebas/lector.test.mjs: los Excel reales dan la misma oferta que el prototipo, 8 carreras, 345 asignaturas y 365 grupos.
Idea central
El sistema interpreta y no valida: lo que se entiende entra, lo que no se entiende se conserva como texto, y solo se toman las filas de la carrera del plan (CU-001 v4.0).

3.2Guion de exposición · unos 12 minutos

  1. 0:00 – 1:00

    El principio: interpretar, nunca rechazar

    «Hoy la oferta vive en varios Excel que cada carrera llena a su manera. Si el sistema rechazara un archivo por una columna mal escrita, nadie lo usaría. Por eso el caso de uso dice que la carga nunca se rechaza: se interpreta lo que llega, el texto que no se entiende se guarda tal cual y el coordinador lo corrige después en la grilla.»

  2. 1:00 – 2:00

    Tres puertas de entrada

    «Los datos entran por tres caminos: los Excel de la carpeta de Drive, que el sistema lee solo; la carga masiva de la oferta, donde el coordinador pega un rango o sube un archivo; y la carga masiva de asignaciones, donde el director pega la hoja de su departamento.»

    Muestra: el diagrama de las tres entradas (más abajo).

  3. 2:00 – 4:00

    Los Excel de Drive

    «En la carpeta del sistema hay tres tipos de archivo y se reconocen por el nombre: CargaXCarrera, CargaXDepartamento y CargaDeDocentes. Si hay varios del mismo tipo se usa el más reciente, y si alguien lo convirtió a Hoja de Google se avisa y no se lee. El sistema nunca los modifica. Como mirar la carpeta cuesta decenas de llamadas a Drive, se mira como mucho cada cinco minutos; el botón Releer Excel lo fuerza.»

    Muestra: «Releer Excel» (captura 3.2) y los avisos de la carpeta (3.4).

  4. 4:00 – 5:30

    Saber si algo cambió

    «Cada archivo tiene una firma: tipo, id de Drive y fecha de modificación, que se guarda en la pestaña Fuentes. Si la firma es igual, no se lee nada. Si cambió, se lee el Excel fuera del candado, porque tarda, y después, dentro del candado, se compara plan por plan con un hash de su contenido. Solo los planes cuyo contenido cambió reciben una versión: “importación desde xlsx” si es la primera, “actualización desde xlsx” si ya tenía.»

    Muestra: la pestaña Fuentes (3.3) y aplicarOferta_ (FuenteDrive.js:181).

  5. 5:30 – 7:00

    Leer un .xlsx sin convertirlo

    «Un .xlsx es un zip con archivos XML. descomprimirXlsx_ lo abre con Utilities.unzip y leerLibroXlsx_ lee el libro, las cadenas compartidas, los estilos para reconocer fechas y cada hoja. Devuelve cada celda como el texto que mostraría Excel. Esta parte es una función pura, así que se prueba en Node con los Excel reales.»

  6. 7:00 – 8:30

    Deducir columnas e interpretar

    «Dominio.interpretar busca la fila de cabeceras, con sinónimos como TH por TOTAL HORAS. Si falta alguna columna, la deduce por su contenido: un código tiene la forma 3.1.1.24, una malla es un año, un semestre puede ser “1-2”, que significa semestre 1, grupo 2. Agrupa por código, ordena por semestre e ignora las filas de otra carrera. Este mismo código corre en el servidor y en el navegador.»

    Muestra: pegado con cabeceras (3.7) y sin cabeceras (3.10 y 3.12).

  7. 8:30 – 10:00

    La carga masiva de la oferta

    «En la pantalla de carga hay tres opciones: pegar desde Google Sheets en una rejilla con aspecto de hoja, subir un archivo o descargar la plantilla. La rejilla muestra lo pegado y dice si reconoció las cabeceras antes de procesar. Para los libros de cálculo se usa SheetJS, que pesa casi un megabyte y por eso se descarga solo al entrar aquí. Si se reemplaza un plan, los grupos que siguen existiendo conservan su identificador y sus docentes.»

    Muestra: capturas 3.5 a 3.14.

  8. 10:00 – 11:30

    La carga masiva de asignaciones

    «El director pega la hoja de su departamento de CargaXDepartamento, donde el nombre del docente aparece una vez y sus cursos debajo. interpretarCargaDepartamento recuerda el último docente, salta las filas de TOTAL y cruza cada fila con las secciones del ciclo por carrera, código y grupo. Cada fila queda en uno de cuatro estados, y los nombres que no están en el catálogo se resuelven con sugerencias antes de aplicar.»

    Muestra: capturas 3.16 a 3.21.

  9. 11:30 – 12:00

    Cierre

    «En resumen: tres entradas, una sola forma de interpretar, ninguna carga rechazada y los archivos originales intactos. Los avisos explican lo que no se pudo leer, para que se corrija en el Excel.»

3.3Conceptos clave

Las tres entradas

1 · Excel de la carpeta de Drive (servidor)

CarpetalocalizarXlsx_el más reciente de cada tipo
→
¿Cambió?firmaFuentes_fuentesIguales_
→
Leer sin candadodescomprimirXlsx_leerLibroXlsx_ofertaDesdeLibros_
→
Aplicar con candadoaplicarOferta_versión solo si el hash del plan cambió

2 · Carga masiva de la oferta (navegador)

Pegar o subircrearRejillaPegadoleerArchivoComoMatriz
→
Texto a matrizmatrizDesdeTextoo SheetJS para libros
→
InterpretarDominio.interpretarcabeceras + pistas
→
A la grillaprocesarse guarda desde el bloque 4

3 · Carga masiva de asignaciones (navegador)

Hoja del dpto.leerArchivoDocenteselige la hoja con la sigla
→
Cruzar con seccionesinterpretarCargaDepartamento
→
Resolver nombressugerirDocentes
→
Aplicarsolo lo que le toca; se guarda desde el bloque 5

Los archivos de la carpeta y cómo se reconocen

TipoEl nombre contieneQué aportaSi falta
carreraCARGAXCARRERALa oferta: una hoja por carreraAviso FALTA_CARGA_CARRERA; no se carga nada
departamentoCARGAXDEPARTAMENTOLa CONDICIÓN de cada código: si habla de INTERNACIONAL, VIRTUAL o FUSIÓN y la asignatura no tiene observación, pasa a serloSe lee la oferta sin esas observaciones
docentesCARGADEDOCENTES o DOCENTESEl catálogo de docentes (bloque 5)Aviso CATALOGO_FALTA; los nombres se escriben a mano

El nombre se normaliza (mayúsculas, sin tildes ni espacios) antes de buscar, en tipoDeFuente_ (FuenteDrive.js:94). Un archivo convertido a Hoja de Google se avisa con XLSX_CONVERTIDO y se ignora.

Columnas de la plantilla y cómo se deducen sin cabecera

ColumnaSinónimosPista por contenido (puntos por celda)
CODIGO—^\d+(\.\d+){2,3}$ → 3 (3.1.1.24)
MALLA—19xx o 20xx → 3
SEMESTRE—«1-2» o «1/2» → 3 · un número de 1 a 12 → 1
DPTO—DIMA, DAHUC, DACJ, DACTE → 3 · 3 a 6 letras → 1
CARRERA—sigla, nombre o alias de una carrera → 3
ASIGNATURA—palabra de 4+ letras y más de 6 caracteres → 2
HT, HP, TOTAL HORASTH y TOTAL DE HORAS = TOTAL HORASnúmero de 1 o 2 cifras → 1
GRUPO—número del 1 al 99 → 1
OBSERVACIONESOBSERVACIONcualquier letra → 1
Cómo decide deducirColumnas Primero manda la cabecera. Para cada campo que falta, en el orden CODIGO, MALLA, CARRERA, DPTO, SEMESTRE, ASIGNATURA, HT, HP, TOTAL HORAS, GRUPO y OBSERVACIONES, puntúa cada columna libre con sus primeras 25 celdas y se queda con la mejor si su promedio pasa de 0,34. Por debajo de ese umbral no se arriesga y el campo queda vacío.

Reglas de interpretación

SituaciónQué hace interpretar
SEMESTRE «1-2»Semestre 1, grupo 2 (si la columna GRUPO está vacía)
SEMESTRE que no es númeroSe conserva el texto y da nombre a su bloque en la grilla
Fila de otra carreraSe ignora y se cuenta: «1 fila de otra carrera se ignoró»
CARRERA vacíaLa fila se toma
Mismo código en varias filasUna asignatura con varios grupos
Sin códigoSe agrupa por el nombre; sin código ni nombre, cada fila vale sola
Mismo código y grupo repetidosEn la lectura de Drive (sinDuplicados) la segunda fila no se lee y se avisa FILA_REPETIDA; en el pegado se añade como otro grupo con el mismo número
HT o HP con textoSe guarda tal cual; para sumar se toma el número que contenga

Estados de una fila en la carga masiva de asignaciones

EstadoCuándoAl aplicar
Se asignaLa fila coincide con una sección de la carrera que asigna el departamento de quien cargaReemplaza las asignaciones de ese componente de la sección
No está en el planNo hay sección con esa carrera, código y grupoSe agrega «sin sección» (solo el director de la carrera)
De otra carreraLa fila es de una carrera distinta a la que se está cargandoSe omite
La asigna otro departamentoLa sección existe pero la asigna otro departamento (por su carrera o por derivación)Se omite

3.4Recorrido con capturas

Usa ?usuario=mfloresl@ulasalle.edu.pe para la oferta y ?usuario=jhuamanih@ulasalle.edu.pe (director de DIMA) para las asignaciones.

  1. Los avisos de la lectura de Drive

    abre Registrar plan de ciclo en 2026-1.

    Captura 3.1El Excel de ejemplo tiene en la hoja Psicología dos filas con el mismo código y grupo: la segunda no se lee y el aviso dice qué corregir en el Excel.
    Verás
    Las 8 carreras en Registrado v1. La primera vez que alguien abre el tablero se leen los Excel; mientras, la página ya se muestra con lo que había.
    En el código
    datosIniciales_ no mira Drive para no retrasar la página: si hace falta, marca pendiente y el navegador pide obtenerTablero de fondo. ofertaDesdeLibros_ (OfertaXlsx.js:14) genera el aviso FILA_REPETIDA, que se guarda en Fuentes y se muestra a todos mientras el Excel no cambie.
  2. Releer Excel

    pulsa Releer Excel.

    Captura 3.2«Excel revisado». Las otras pestañas abiertas reciben el mensaje «tablero» y vuelven a leerlo.
    En el código
    cargarTablero({releer: true}) (Tablero.html:142) → obtenerTablero(ciclo, {releer: true}) → sincronizarFuentes_(true) (FuenteDrive.js:14), que mira la carpeta aunque la caché diga que se revisó hace poco y además vuelve a leer el catálogo de docentes (calentarCatalogo_(true)).
  3. La firma de los archivos

    mira la pestaña Fuentes de la hoja del sistema.

    Captura 3.3Una fila por archivo: tipo, id de Drive, nombre, fecha de modificación y cuándo se procesó. La fila de «carrera» guarda además los avisos de la lectura.
    En el código
    fuentesIguales_ (FuenteDrive.js:157) compara «tipo|archivo_id|modificado» de lo guardado con lo actual. Si coinciden, no se descarga ningún archivo.
  4. Problemas en la carpeta

    sube a la carpeta una segunda copia de CargaXCarrera y una versión convertida a Hoja de Google; pulsa Releer Excel.

    Captura 3.4Tres niveles de aviso: el archivo convertido no se lee, con dos CargaXCarrera se usa el más reciente (informativo) y el aviso de la fila repetida se mantiene.
    En el código
    localizarXlsx_ (FuenteDrive.js:108) recorre la carpeta, descarta las Hojas de Google con XLSX_CONVERTIDO, ordena los candidatos de cada tipo por fecha y avisa VARIOS_ARCHIVOS.
    Para la demo
    En la vista previa, los archivos se subieron a la carpeta simulada desde el script de capturas.
  5. Un ciclo sin datos

    cambia el ciclo a 2026-2.

    Captura 3.5Los Excel son de 2026-1 (SIS_CICLO_XLSX), así que en 2026-2 todo está Sin Iniciar y el plan se arma con la carga masiva.
  6. Las opciones de carga

    pulsa Editar en Ingeniería de Software.

    Captura 3.6Un plan vacío abre directamente la carga masiva: pegar, subir archivo o descargar la plantilla. Arriba, la nota del bloqueo y el reloj de la sesión (bloque 2).
    En el código
    entrarEnEdicion llama a irACarga (ImportarOferta.html:81) cuando el plan no tiene asignaturas. irACarga empieza a descargar SheetJS por si se sube un libro.
    Plantilla
    «Descargar plantilla oficial» genera en el navegador un .csv con BOM, separado por punto y coma, con las 11 columnas y una fila de ejemplo (Plantilla_Plan_Ciclo.csv).
  7. Pegar un rango con cabeceras

    pulsa Pegar desde Google Sheets, copia un rango en Sheets (con su fila de cabeceras) y pulsa Ctrl+V.

    Captura 3.7La rejilla vacía espera el pegado; también acepta soltar un archivo encima.
    Captura 3.8Con letras de columna y números de fila, como una hoja. El pie dice cuántas filas y columnas hay y en qué fila están las cabeceras.
    En el código
    crearRejillaPegado (Rejilla.html:92) escucha el evento paste del documento solo mientras la rejilla está a la vista, convierte el texto con matrizDesdeTexto y dibuja hasta 300 filas (se procesan todas). filaCabecera usa ubicarCabeceras para resaltar la fila.
  8. Procesar: la fila de otra carrera se ignora

    pulsa Procesar contenido.

    Captura 3.9La fila de ADM se ignoró y se avisa. «Ing. Software» se reconoció como ING por su alias, «1-2» se convirtió en el grupo 2 y la observación VIRTUAL cuenta en «Intl. / virtual».
    En el código
    procesar (ImportarOferta.html:134) llama a interpretar(matriz, {carrera}), conserva los identificadores de los grupos que siguen existiendo (Dominio.conservarIds), marca el plan como sucio y pinta la grilla. Todavía no se guardó nada: el guardado es del bloque 4.
  9. Pegar sin cabeceras

    desde la grilla, Reemplazar contenido → Continuar; pega un rango sin fila de cabeceras y con las columnas en otro orden.

    Captura 3.10Volver a las opciones de carga avisa de que el contenido nuevo reemplazará todo el plan.
    Captura 3.11«No se reconocen las cabeceras», pero el botón Procesar sigue disponible: no se rechaza.
    Captura 3.12Como el plan ya tenía asignaturas, se confirma el reemplazo. Se pierden desdobles, ajustes y fusiones; los grupos con el mismo código y número conservan sus docentes.
    Captura 3.13Las columnas se dedujeron por su contenido: código, asignatura, semestre, HT, HP y departamento.
    En el código
    deducirColumnas (Dominio.js:512) con las PISTAS (Dominio.js:469). Al reemplazar, las fusiones con otras carreras que se pierden se anotan en fusionesDisueltas para deshacerlas también en esos planes al guardar.
  10. Subir un libro con varias hojas

    en otra carrera vacía, pulsa Subir archivo y elige SISCargaLectiva-Ejemplo-CargaXCarrera.xlsx.

    Captura 3.14El libro tiene una hoja por carrera: se elige cuál importar.
    Captura 3.15La hoja «Adm. Neg. Int.» importada: 60 asignaturas y 60 grupos.
    En el código
    leerArchivoComoMatriz (Rejilla.html:54): .xlsx, .xlsm, .xlsb, .xls y .ods se leen con SheetJS (XLSX.read, sheet_to_json con raw: false para tener el texto que se ve). Cualquier otra extensión, o un libro que no se deja leer, se lee como texto con matrizDesdeTexto. No hay extensiones prohibidas.
  11. La pantalla de asignación del director

    abre la vista previa con ?usuario=jhuamanih@ulasalle.edu.pe y entra en Asignar docentes.

    Captura 3.16La carga de ING, que asigna DIMA. Esta pantalla es del bloque 5; aquí solo importa su botón «Carga masiva», que aparece al pulsar «Editar asignaciones».
  12. Pegar la hoja del departamento

    Editar asignaciones → Carga masiva; pega la hoja «CARGA DIMA 2026-I» o súbela con «Subir CargaXDepartamento».

    Captura 3.17La misma rejilla de la oferta, en otra pantalla.
    Captura 3.18El formato real: el docente aparece una vez, sus cursos debajo y una fila TOTAL entre docentes.
    En el código
    Al subir el libro, leerArchivoDocentes (AsignacionCarga.html:93) elige sola la hoja cuyo nombre contiene la sigla del departamento de quien carga; si no la encuentra, pregunta.
  13. Previsualizar antes de aplicar

    pulsa Previsualizar asignaciones.

    Captura 3.19De la hoja de DIMA se leyeron 92 filas, con la cabecera en la fila 2: 54 se asignan, 3 no están en el plan de ING, 35 son de otra carrera (ADM, IND, ARQ…) y ninguna la asigna otro departamento. Debajo empieza la lista de nombres por resolver.
    Captura 3.20Cada fila con el docente leído, la carrera, el código, el grupo, los componentes que cubre (T, P, o los subgrupos de un desdoble) y su resultado.
    Captura 3.21Los nombres que no coinciden con el catálogo se agrupan; para cada uno se sugieren los cinco más parecidos o se usa el nombre tal cual.
    En el código
    interpretarCargaDepartamento (Dominio.js:1475) con ubicarColumnasCarga (:1402) y componentesDeFila (:1448). sugerirDocentes (:1381) ordena por similitudNombres: la parte de las palabras que comparten los dos nombres.
  14. Aplicar

    elige a quién corresponde cada nombre y pulsa Aplicar a la asignación.

    Captura 3.22«Carga aplicada: 104 asignaciones y 1 sin sección. Revísalas y guarda.» Aparecen «Cambios sin guardar», la cobertura y las advertencias del balance. Nada se guardó todavía: el guardado, con sus controles de horas, es del bloque 5.
    En el código
    El botón «Aplicar» (AsignacionCarga.html:150) quita las asignaciones anteriores de cada componente que trae la hoja y pone las nuevas; lo demás de la carga se conserva. Las filas «fuera del plan» solo las agrega el director de la carrera.

3.5Catálogo de acciones y posibilidades

AcciónDóndeFunciónResultado
Revisión automática de DriveAl abrir la página o el tablero, si la última revisión fue hace más de 5 minsincronizarFuentes_(false)Versiones nuevas solo en los planes que cambiaron; avisos
Releer ExcelBotón del tableroobtenerTablero(c, {releer: true})Revisión inmediata y catálogo releído; «Excel revisado»
Pegar desde Google SheetsCarga masiva de la ofertacrearRejillaPegadoRejilla con cabeceras detectadas
Soltar un archivo en la rejillaRejilla de pegadoalSoltarArchivoEl archivo se dibuja para revisarlo antes de procesar
Subir archivoOpción «Subir archivo» (o soltarlo sobre ella)leerArchivo → procesarSe procesa directamente; si hay varias hojas, se elige una
Descargar plantilla oficialOpciones de carga#op-plantillaPlantilla_Plan_Ciclo.csv con cabeceras y ejemplo
Procesar contenidoRejilla de pegadoprocesar → interpretarAsignaturas a la grilla; aviso de filas de otras carreras
LimpiarRejilla de pegadorejilla.limpiarVacía la rejilla
Reemplazar contenidoBarra inferior de la grillaaccion("reemplazar") → irACargaVuelve a las opciones; el reemplazo se confirma al procesar
Carga masiva de asignacionesAsignar docentes, editando una carga#btn-doc-masivaPantalla de pegado o subida de CargaXDepartamento
Previsualizar asignacionesEsa pantallainterpretarCargaDepartamentoTabla de filas con su estado y nombres por resolver
Resolver un nombreTabla «Docentes que no están en el catálogo»sugerirDocentesSe elige un docente sugerido o el nombre tal cual
Aplicar o descartarPie de la previsualización#btn-aplicar-previa-docCambia la carga en pantalla (sin guardar) o vuelve a la rejilla

3.6Código clave comentado

Revisar la carpeta: leer fuera del candado, aplicar dentro

FuenteDrive.js · revisarFuentes_líneas 40–76
const f = localizarXlsx_(carpeta, avisos);
if (!f.carrera) { /* aviso FALTA_CARGA_CARRERA */ return avisos; }

const actuales = firmaFuentes_(f);                  // tipo, id, nombre, modificado
const guardadas = leerTabla_(TABLA_.FUENTES);
if (fuentesIguales_(guardadas, actuales)) return avisos.concat(avisosGuardados_(guardadas));
try {
  const lectura = leerOfertaXlsx_(f);               // lento: se hace SIN el candado
  const deLectura = conCandado_(function () {
    const otraVez = leerTabla_(TABLA_.FUENTES);       // ¿otro usuario ya lo procesó?
    if (fuentesIguales_(otraVez, actuales)) return avisosGuardados_(otraVez);
    const nuevos = aplicarOferta_(f, lectura);
    reemplazarFilas_(TABLA_.FUENTES, /* firma de cada archivo + avisos */);
    return nuevos;
  });
  return avisos.concat(deLectura);
} catch (e) {
  avisos.push(Dominio.aviso("LECTURA_XLSX_FALLIDA", "No se pudieron leer los xlsx de la carpeta: " + e.message));
  return avisos;
}

Es el patrón de «comprobar dos veces»: se compara la firma antes de leer (para no leer en vano) y otra vez dentro del candado (por si otra ejecución lo procesó mientras tanto). Leer fuera del candado deja que los demás sigan guardando.

Aplicar la oferta plan por plan

FuenteDrive.js · aplicarOferta_líneas 181–217
Object.keys(r.oferta).forEach(function (sig) {
  const asignaturas = r.oferta[sig];
  const firma = Dominio.hash(Dominio.contenidoCanonico(asignaturas));
  const fila = buscarPlan_(planes, ciclo, sig);
  if (fila && fila.firma_xlsx === firma) return;        // esa hoja no cambió
  const p = estadoDePlan_(fila);
  const antes = p.version ? contenidoPlan_(ciclo, sig, p.version, p.filaVersion) : [];
  Dominio.conservarIds(asignaturas, antes);                 // los docentes siguen a sus grupos
  const fus = prepararFusiones_(ciclo, sig, antes, asignaturas, [], null, { sinBloqueo: true });
  registrarVersionPlan_(ciclo, sig, fila, {
    estado: p.estado === Dominio.ESTADOS.SIN ? Dominio.ESTADOS.REGISTRADO : p.estado,
    tipo: p.version ? Dominio.TIPO_VERSION.ACTUALIZACION_XLSX : Dominio.TIPO_VERSION.IMPORTACION_XLSX,
    usuario: "xlsx: " + f.carrera.getName(), fecha: Dominio.isoPeru(modificado),
    motivo: "", asignaturas: fus.asignaturas, firma_xlsx: firma
  });
  /* si la fusión tocó otros planes, se guardan y se relee Planes */
});
  • La firma del plan es el hash cyrb53 de su contenidoCanonico (semestre, malla, código, nombre, departamento, observaciones y grupos), así que cambiar un archivo sin cambiar una hoja no crea versiones en esa carrera.
  • Un plan Sin Iniciar pasa directamente a Registrado v1: la oferta del Excel ya es la oficial.
  • La actualización no comprueba bloqueos (sinBloqueo): si alguien edita ese plan, al guardar recibirá CONFLICTO.

Leer una celda del XML de una hoja

LectorXlsx.js · xlsxFilas_líneas 270–310
if (ac.t === "s") texto = v != null ? (compartidas[parseInt(v, 10)] || "") : "";   // cadena compartida
else if (ac.t === "inlineStr") texto = xlsxTexto_(/* <is>…</is> */);            // texto en la celda
else if (ac.t === "b") texto = v === "1" ? "TRUE" : v != null ? "FALSE" : "";   // booleano
else if (ac.t === "str" || ac.t === "e" || ac.t === "d") texto = v != null ? xlsxDecodificar_(v) : "";
else if (v != null) {                                                       // número
  const formato = ac.s != null ? estilos[parseInt(ac.s, 10)] : null;
  texto = formato && isFinite(Number(v)) ? xlsxFormatearFecha_(Number(v), formato)  // con formato de fecha
                                         : xlsxNumero_(v);                          // como lo muestra «General»
}

Excel guarda los textos repetidos una sola vez en sharedStrings.xml y cada celda apunta a su índice. Las fechas son números con un estilo de fecha: por eso se leen los estilos (xlsxEstilos_) para mostrarlas como en Excel. La celda se coloca en su columna por la letra de su referencia («C7» → columna 2).

Del texto pegado a una matriz

Dominio.js · matrizDesdeTextolíneas 128–155
const lineas = String(txt).replace(/\r\n?/g, "\n").split("\n").filter(l => l.trim() !== "");
/* El separador se deduce del conjunto del texto, no de la primera línea:
   un rango pegado puede empezar con un título o una fila en blanco. */
if (lineas.some(l => l.includes("\t"))) return lineas.map(l => l.split("\t").map(c => c.trim()));
const cuenta = c => lineas.reduce((n, l) => n + (l.split(c).length - 1), 0);
const sep = cuenta(";") > cuenta(",") ? ";" : ",";
/* …y se separa respetando las comillas ("a;b" es una sola celda, "" es una comilla) */

Interpretar fila por fila

Dominio.js · interpretarlíneas 562–610
filas.forEach(f => {
  if (carrera && mapa.CARRERA !== undefined) {
    const c = val(f, "CARRERA");
    if (c && !coincideCarrera(c, carrera)) { ignoradas++; return; }    // otra carrera
  }
  const semBruto = val(f, "SEMESTRE");
  const ds = descomponerSemestre(semBruto);                          // «1-2» → sem 1, grupo 2
  const sem = !isNaN(ds.sem) ? ds.sem : (semBruto || "Sin semestre");
  const grupoBruto = val(f, "GRUPO");
  const grupo = grupoBruto !== "" ? grupoBruto : (!isNaN(ds.grupo) ? ds.grupo : 1);
  const codigo = val(f, "CODIGO"), nombre = val(f, "ASIGNATURA");
  const clave = codigo || nombre || ("fila-" + (++sinCodigo));      // cómo se agrupa
  if (!porClave.has(clave)) porClave.set(clave, { sem, malla: val(f, "MALLA"), codigo, nombre,
                                                dpto: val(f, "DPTO"), obs: val(f, "OBSERVACIONES"), grupos: [] });
  const a = porClave.get(clave);
  if (sinDuplicados && a.grupos.some(g => String(g.n) === String(grupo))) { duplicadas.push(/*…*/); return; }
  a.grupos.push(nuevoGrupo(grupo, val(f, "HT"), val(f, "HP")));      // horas tal cual
});

Clasificar una fila de CargaXDepartamento

Dominio.js · interpretarCargaDepartamentolíneas 1475–1516
for (let r = cab + 1; r < filas.length; r++) {
  const f = filas[r] || [];
  if (f.every(vacia) || f.some(c => /^\s*TOTAL/i.test(String(c == null ? "" : c)))) continue;
  docente = val(f, "DOCENTE") || docente;                // celdas combinadas: se arrastra el último
  /* … carrera, grupo (de GRUPO o de SEMESTRE «1-2»), HT, HP … */
  const seccion = (codigo && idx.get(carrera + "|C|" + codigo + "|" + n))
               || (nombre && idx.get(carrera + "|N|" + normalizar(nombre) + "|" + n)) || null;
  const estado = dest.carrera && deCarrera && deCarrera !== dest.carrera ? "otra-carrera"
    : !seccion ? "fuera"
    : dest.dpto && seccion.dpto !== dest.dpto ? "otro-dpto" : "ok";
}

3.7Errores, avisos y casos límite

AvisoNivelCuándo
CARPETA_INACCESIBLEerrorNo se puede abrir la carpeta de SIS_CARPETA_ID
LECTURA_XLSX_FALLIDAerrorEl .xlsx no se pudo descomprimir o leer
FALTA_CARGA_CARRERAavisoLa carpeta no tiene CargaXCarrera
XLSX_CONVERTIDOavisoUn archivo es Hoja de Google: hay que subir el .xlsx sin convertir
VARIOS_ARCHIVOSinfoHay varios del mismo tipo; se usa el más reciente
HOJA_DESCONOCIDAavisoUna hoja con datos que no corresponde a ninguna carrera
HOJA_REPETIDAavisoDos hojas de la misma carrera; se usa la primera
FILA_REPETIDAavisoCódigo y grupo repetidos en una hoja; la segunda fila no se lee
CATALOGO_FALTA · _SIN_CARPETA · _SIN_CABECERAS · _ILEGIBLEavisoProblemas con CargaDeDocentes; se puede asignar escribiendo los nombres
Mensaje en el navegadorCuándo
«El contenido pegado está vacío.»Se pegó solo espacio en blanco
«No se pudo sacar nada de «archivo».»El archivo no tenía ni una celda con contenido
«No se encontró ninguna fila con contenido.»Se procesó, pero ninguna fila era de esta carrera o tenía datos
«N filas de otras carreras se ignoraron…»Nota en la grilla tras procesar
«Elige a quién corresponde cada nombre que no está en el catálogo.»Se pulsó Aplicar con nombres sin resolver
Casos límite Sin internet, SheetJS no se descarga y un .xlsx subido se intenta leer como texto (no saldrá nada útil): en ese caso conviene pegar desde Sheets. Los avisos de una lectura de Drive se guardan en Fuentes y se siguen mostrando mientras el Excel no cambie. Los .xlsx de Drive siempre se asignan al ciclo SIS_CICLO_XLSX.

3.8Preguntas probables

¿Por qué no convertir los .xlsx a Hojas de Google y leerlos con SpreadsheetApp?

Convertir crea otro archivo y puede cambiar formatos (fechas, números). Leer el .xlsx original deja la carpeta como fuente única y los archivos intactos. Como el lector es una función pura, además se prueba con los Excel reales sin Google.

¿Cómo sabe el sistema que un Excel cambió?

En dos niveles. Por archivo: tipo, id y fecha de modificación guardados en Fuentes. Por plan: el hash del contenido de cada carrera se compara con firma_xlsx del índice. Solo los planes cuyo contenido cambió reciben una versión nueva.

Si el Excel cambia, ¿se pierde lo que editó el coordinador?

El plan recibe una versión «actualización desde xlsx» con el contenido del Excel, pero las versiones anteriores siguen en el historial y se pueden restaurar. Los grupos con el mismo código y número conservan su identificador, así que sus docentes los siguen, y la derivación a otro departamento también se conserva.

¿Qué pasa si no hay fila de cabeceras?

deducirColumnas reconoce cada columna por su contenido con las PISTAS. Si ninguna columna se parece lo suficiente a un campo (umbral 0,34), ese campo queda vacío en lugar de adivinar.

¿Por qué «nunca rechaza»? ¿No es peligroso?

Es lo que pide el caso de uso: la validación la hace la persona en la grilla, con los totales y avisos a la vista. El texto que no se entiende se conserva, y nada se guarda hasta pulsar un botón de guardado.

¿Por qué se leen los Excel fuera del candado?

Leer y descomprimir tarda segundos. Con el candado tomado, nadie podría guardar mientras tanto. Por eso se lee fuera y dentro solo se compara la firma de nuevo y se escriben las versiones.

¿Por qué SheetJS se descarga solo al entrar en la carga masiva?

Pesa casi 1 MB y la mayoría de las visitas no lo necesitan. Se pide al entrar en las opciones de carga, para que esté listo cuando se elija un archivo.

¿Cómo empareja los nombres de los docentes?

Primero busca el nombre exacto normalizado (claveDocente). Si no está, sugiere los del catálogo que comparten más palabras: palabras comunes divididas por las del nombre más largo. La persona confirma; nunca se asigna por parecido sin preguntar.

¿Qué pasa con las filas de otro departamento en la carga masiva?

Se omiten. Cada director solo cambia las secciones que le tocan y el servidor, al guardar, conserva las asignaciones de las demás (combinarCarga_, bloque 5).

3.9Para practicar

node --test AppScript/pruebas/lector.test.mjs

Bloque 4

Plan de ciclo (CU-001)

Responsable: jortizc@ulasalle.edu.pe

4.1Ficha del bloque

Pregunta que responde
¿Cómo registra el coordinador el plan de ciclo de su carrera: qué asignaturas y grupos abre, con qué horas, y cómo lo deja listo para asignar docentes?
Archivos del servidor
Planes.js 353Fusiones.js 62
Archivos del navegador
Tablero.html 310Grilla.html 273GrillaAcciones.html 303GrillaEdicion.html 370
Parte de Dominio.js
L612–648 totalesL649–886 operaciones de ediciónL887–1018 fusiones entre carreras
Usa
El bloque 2 para el bloqueo, las versiones y la recuperación; el bloque 3 para la carga masiva; Acceso.js para los permisos.
Pruebas
dominio.test.mjs (operaciones y totales), servidor.test.mjs (endpoints con Google simulado) y recorrido-ui.mjs (el recorrido en Chrome).
Idea central
Todo se edita en el navegador con las funciones de Dominio, sin llamar al servidor; el servidor solo interviene al abrir, guardar, restaurar o derivar, y vuelve a comprobarlo todo.

4.2Guion de exposición · unos 13 minutos

  1. 0:00 – 1:00

    Qué es un plan de ciclo

    «El plan de ciclo es la oferta de una carrera en un ciclo: sus asignaturas por semestre, y de cada asignatura sus grupos con horas de teoría y de práctica. Pasa por tres estados: Sin Iniciar, Borrador y Registrado. Registrarlo habilita la asignación docente. Un plan registrado se sigue pudiendo editar, pero nunca vuelve a borrador, y cada guardado deja una versión.»

    Muestra: el diagrama de estados.

  2. 1:00 – 2:30

    Inicio y tablero

    «El inicio resume el ciclo: planes registrados, en borrador, sin iniciar, grupos y horas de dictado. El tablero lista las ocho carreras con su estado, totales, versión y quién lo está editando. El botón Editar solo aparece en las carreras que coordina la cuenta. El tablero trae además el contenido vigente de cada plan, así que consultar o empezar a editar no espera al servidor.»

    Muestra: capturas 4.1, 4.2 y 4.3.

  3. 2:30 – 3:30

    Consultar en solo lectura

    «Al pulsar una fila se consulta el plan: el resumen de nueve cifras y un bloque por semestre. La columna Docente se llena aparte, en segundo plano, con las asignaciones vigentes.»

    Muestra: captura 4.4.

  4. 3:30 – 5:00

    Entrar en edición y la grilla

    «Editar abre la grilla al instante, mientras el bloqueo se pide de fondo; hasta que llega no se puede guardar. Arriba, la nota del estado y el resumen; después los semestres, que se pueden plegar. Al seleccionar un grupo aparece en el dock inferior con las acciones que le aplican.»

    Muestra: capturas 4.5 a 4.7.

  5. 5:00 – 7:30

    Editar sin validar

    «Se pueden editar las horas, que se guardan tal como se escriben; editar los datos de la asignatura; agregar asignaturas o grupos, uno o a todo un semestre; y eliminar. Antes de eliminar un grupo que ya tiene docente se avisa: la asignación no se borra, queda pendiente para que el director la reasigne. El desdoble divide la práctica o la teoría de un grupo en dos a cinco subgrupos y se marca, por ejemplo, PRA×3.»

    Muestra: capturas 4.8 a 4.15.

  6. 7:30 – 9:30

    Fusiones

    «Fusionar une grupos en una sola sección con un docente. Se elige un grupo principal y los demás toman sus horas; las horas de antes se guardan para poder deshacer. Si los grupos difieren en nombre, código u horas, se advierte con A05 pero se fusiona igual. Se puede fusionar con grupos de otra carrera: al guardar, el servidor lleva la fusión a ese otro plan, con una versión de tipo fusión, salvo que alguien lo esté editando. En las horas de dictado, la sección fusionada cuenta una sola vez.»

    Muestra: capturas 4.16 a 4.22.

  7. 9:30 – 11:30

    Guardar y registrar

    «Hay tres formas de guardar: Guardar borrador, Registrar plan, que muestra el resumen y pide confirmar, y Guardar cambios cuando ya está registrado, con un motivo opcional. Al registrar se ve la pantalla de éxito con tres caminos: volver al tablero, seguir editando o ir a asignar docentes. El historial permite restaurar cualquier versión.»

    Muestra: capturas 4.24 a 4.29.

  8. 11:30 – 12:30

    Derivar un curso a otro departamento

    «Por regla, los docentes de una carrera los asigna el departamento de esa carrera. La excepción es derivar un curso: el director del departamento que hoy lo asigna, o el coordinador, lo pasa a otro departamento desde la consulta. Se guarda como versión “cambio de departamento”, la columna DPTO del Excel no cambia y las asignaciones siguen al curso.»

    Muestra: capturas 4.30 a 4.32.

  9. 12:30 – 13:00

    Cierre

    «El navegador edita con el mismo Dominio que usa el servidor; el servidor solo recibe el plan completo al guardar, lo sanea, comprueba permiso, bloqueo y conflicto, reconcilia las fusiones y guarda la versión.»

4.3Conceptos clave

Estados del plan

Sin Iniciar→ Borrador→ Registrado⟲ cada guardado: versión nueva, sigue Registrado

resolverEstado_ (Planes.js:56): si el plan ya está Registrado, queda Registrado; si no, queda Registrado solo si se pidió registrar, y Borrador en cualquier otro guardado. Un plan Sin Iniciar que llega desde el Excel pasa directo a Registrado v1 (bloque 3). «Bloqueado» no es un estado del plan: es como lo ve otra cuenta mientras alguien lo edita.

El modelo de datos

Tipos.js · Asignatura y Grupo, con un ejemplocontenido de una versión
[
  {
    "sem": 1, "malla": "2024", "codigo": "3.1.1.24", "nombre": "Introducción a la Programación",
    "dpto": "DIMA",               // columna DPTO del Excel: solo informativa
    "obs": "",                    // INTERNACIONAL o VIRTUAL la marcan como «Intl.»
    "derivado": "DAHUC",          // opcional: lo asigna otro departamento
    "grupos": [
      { "id": "g8k2…", "n": 1, "ht": "2", "hp": "4",
        "desdoble": "PRA×3",      // o null
        "fusion": null },         // marca «F…» si está fusionado
      { "id": "g1x9…", "n": 2, "ht": "2", "hp": "4", "desdoble": null,
        "fusion": "FQ3K7ZB", "fusionPrincipal": true,
        "fusionCon": [ { "carrera": "IND", "grupoId": "g7…", "codigo": "17.1.5.24", "n": 1, "principal": false } ],
        "previas": { "ht": "3", "hp": "2" } }   // horas antes de fusionar, para deshacer
    ]
  }
]

El id de cada grupo es lo que siguen las asignaciones docentes; por eso se conserva al reemplazar el contenido o al actualizar desde el Excel. Las horas se guardan tal como se escribieron (texto o número): solo se interpretan para sumar.

Las nueve cifras del resumen

CifraCómo se calcula (Dominio.totales, L621)
Asignaturas · GruposCuántas asignaturas y cuántos grupos tiene el plan
Horas teóricas · prácticas · totalSuma de HT, de HP y de HT+HP de todos los grupos
Horas de dictadoComo el total, pero cada sección fusionada cuenta una sola vez; si su grupo principal está en el plan de otra carrera, la cuenta ese plan y este no
Intl. / virtualAsignaturas cuya observación dice INTERNACIONAL o VIRTUAL
Fusiones · DesdoblesMarcas de fusión distintas y grupos con desdoble

Tipos de versión

Tipo (así queda en el historial)Lo crea
importación desde xlsxLa primera lectura del Excel (bloque 3)
actualización desde xlsxUn Excel que cambió el contenido de esa carrera
guardar borrador«Guardar borrador»
registrar«Registrar plan»
guardar cambios«Guardar cambios» de un plan registrado
autoguardadoLa sesión de edición expiró con cambios
fusiónOtra carrera fusionó, cambió o deshizo una fusión con un grupo de este plan
cambio de departamentoDerivar un curso
restauración de vNRestaurar desde el historial

Quién puede hacer qué

CuentaConsultarEditar, guardar, registrar, restaurarDerivar un curso
Coordinador de la carreraSíSí, en las carreras de su columna «carreras»Sí, en sus carreras
Coordinador de otra carreraSíNoNo
Director de departamentoSíNo (SIN_PERMISO)Solo los cursos que hoy asigna su departamento

Qué pasa al guardar, en el servidor

IdentidadexigirUsuario_exigirEdicion_
→
SanearsanearAsignaturassolo campos del modelo, límites
→
Con candadoexigirSinBloqueoAjeno_conflictoPlan_
→
FusionesprepararFusiones_reconciliar con los otros planes
→
VersionesregistrarVersionPlan_guardarFusionesAjenas_
→
LimpiarborrarRecuperacion_tomarBloqueo_renueva

4.4Recorrido con capturas

Usa ?usuario=mfloresl@ulasalle.edu.pe (coordinador de todas las carreras) y, para derivar, ?usuario=jhuamanih@ulasalle.edu.pe (director de DIMA).

  1. El inicio

    abre la aplicación.

    Captura 4.1Saludo con el rol, anillo de planes registrados, cifras del ciclo y los accesos a las dos secciones. El reporte aún está marcado «Próximamente».
    En el código
    pintarInicio (Inicio.html) calcula todo con las filas del tablero que trajo la página; no llama al servidor.
  2. El tablero

    entra en Registrar plan de ciclo.

    Captura 4.2Ciclo, «Releer Excel», avisos, la barra de avance y una fila por carrera: estado, semestres, asignaturas, grupos, horas de dictado, versión y quién edita.
    Captura 4.3Una cuenta que solo coordina ING ve el botón Editar únicamente en esa fila; las demás las consulta.
    En el código
    pintarTablero (Tablero.html:187) y pintarAvance (:225). Si los datos tienen más de 15 s, mostrarTablero los vuelve a pedir de fondo (VIGENCIA_TABLERO_MS). Cambiar de ciclo vuelve a leer.
  3. Consultar un plan

    pulsa la fila de Derecho.

    Captura 4.4La consulta: estado y versión, el resumen de nueve cifras y un bloque por semestre, con la columna Docente.
    En el código
    abrirLectura (Tablero.html:245) usa el contenido que trajo el tablero; solo si no lo tiene llama a obtenerPlan. Los docentes llegan aparte con obtenerDocentesPorGrupo y, cuando llegan, se repinta.
  4. La grilla de edición

    vuelve al tablero y pulsa Editar en Ingeniería de Software.

    Captura 4.5La nota de estado (Registrado v1), el resumen, la barra de herramientas y el dock inferior con los botones de guardado.
    En el código
    pintarGrilla (Grilla.html:177): el botón principal dice «Registrar plan» o «Guardar cambios» según el estado; «Guardar borrador» se desactiva en un plan registrado; los dos esperan a que el bloqueo esté confirmado (p.confirmado).
  5. Seleccionar un grupo

    pulsa una fila de la grilla.

    Captura 4.6La fila queda marcada y el dock describe el grupo: número, código, nombre y horas.
    Captura 4.7El dock: «Quitar desdoble» y «Deshacer fusión» solo se activan si el grupo tiene desdoble o fusión (data-req).
  6. Editar horas y datos de la asignatura

    con un grupo seleccionado, pulsa Editar HT / HP y luego Editar asignatura.

    Captura 4.8HT y HP se guardan tal cual se escriben; el total se recalcula.
    Captura 4.9Código, departamento (DPTO), nombre y observaciones. La nota recuerda que DPTO es informativa.
  7. Agregar una asignatura o grupos

    pulsa Agregar asignatura; después selecciona un grupo y pulsa Agregar grupo; prueba también Agregar grupo al semestre.

    Captura 4.10Sin validación: se guarda lo que se escriba. El semestre acepta «5» o «1-2».
    Captura 4.11Un grupo correlativo en todas las asignaturas del semestre, con las horas de su primer grupo.
    Captura 4.12«Agregar grupo» copia las horas del grupo seleccionado y le da el siguiente número.
    En el código
    Dominio.agregarAsignatura (L660) reordena por semestre y código; agregarGrupo (L692) y agregarGrupoSemestre (L705) usan siguienteN.
  8. Desdoblar

    selecciona un grupo, pulsa Desdoblar, elige Práctica y 3 subgrupos.

    Captura 4.13Tipo (práctica o teoría) y número de subgrupos, de 2 a SIS_MAX_SUBGRUPOS (5).
    Captura 4.14«Quitar desdoble» devuelve el grupo a una sola sección.
    Captura 4.15La marca PRA×3 junto al nombre. En la asignación docente, cada subgrupo de práctica (P1, P2, P3) se asigna por separado.
    Validación
    Si el grupo no tiene horas del tipo elegido: «El desdoble no aplica». Si la cantidad no está entre 2 y el máximo: «Cantidad de subgrupos no válida» y se vuelve a preguntar. Son las únicas validaciones del plan, y no rechazan nada guardado.
  9. Fusionar grupos del mismo plan

    pulsa Fusionar grupos, marca dos grupos con Ctrl y elige el principal.

    Captura 4.16La lista incluye los grupos de este plan y, debajo, los de las otras carreras del ciclo.
    Captura 4.17A05: los grupos difieren del principal. La fusión se crea igual.
    Captura 4.18El resumen cuenta la fusión y las horas de dictado bajan: la sección fusionada se dicta una vez.
    Captura 4.19La marca FUS; al pasar el ratón dice con qué grupos está fusionado. El grupo no principal ya muestra las horas del principal.
    En el código
    Dominio.fusionar (L803) crea la marca «F» + 6 caracteres, guarda las horas previas de los no principales en previas, les copia las del principal y devuelve las diferencias para A05. Si un grupo ya estaba en otra fusión, la anterior se limpia con limpiarFusionHuerfana.
  10. Deshacer la fusión y eliminar

    selecciona un grupo fusionado y pulsa Deshacer fusión; prueba también Eliminar grupo y Eliminar asignatura.

    Captura 4.20Cada grupo recupera las horas que tenía antes.
    Captura 4.21Si el grupo es el único de su asignatura o está fusionado, se explica qué pasará.
    Captura 4.22Eliminar la asignatura quita todos sus grupos. Si alguno tuviera docente, una nota lo nombra y explica que la asignación queda pendiente.
    En el código
    Antes de eliminar se llama a asegurarDocentesGrupo (Tablero.html:106) para saber si hay docentes asignados. Una fusión que se queda con menos de dos grupos se deshace sola.
  11. Fusionar con otra carrera

    en el diálogo de fusión marca un grupo de ING (Cálculo I) y uno de IND que está más abajo en la lista (Matemática y álgebra lineal); el principal es el de ING.

    Captura 4.23Los grupos de otras carreras aparecen con su sigla. La nota explica que la fusión se llevará a su plan al guardar, sin pedir aprobación.
    Captura 4.24A05 nombra el grupo de IND, que tiene otro código y otras horas.
    En el servidor
    prepararFusiones_ (Fusiones.js:21) pide a Dominio.reconciliarFusiones (L934) cómo quedan las fusiones en todos los planes del ciclo; si alguno de esos planes lo edita otra cuenta, falla con BLOQUEADO y no se guarda nada. guardarFusionesAjenas_ (:50) registra en cada plan tocado una versión «fusión».
    Regla
    Desde un plan solo se fusionan secciones que incluyan al menos un grupo suyo: si se eligen solo grupos ajenos, se pide volver a seleccionar.
  12. Plegar los semestres

    pulsa Colapsar todo (o la cabecera de un semestre).

    Captura 4.25Cada semestre muestra en su cabecera cuántas asignaturas, grupos y horas tiene.
  13. Guardar cambios de un plan registrado

    pulsa Guardar cambios, escribe un motivo y confirma.

    Captura 4.26El motivo es opcional y queda en el historial.
    Captura 4.27«Cambios guardados como v2» y «La fusión se guardó también en IND (v2)». El reloj volvió a 30:00 porque guardar renueva el bloqueo.
  14. Registrar un plan nuevo

    en un plan en Sin Iniciar o Borrador, pulsa Registrar plan.

    Captura 4.28Resumen de cifras y el mensaje del caso de uso: al confirmar se habilita la asignación docente para DIMA, DAHUC, DACJ y DACTE.
    Captura 4.29«¡Plan registrado!» con la versión y tres caminos. Al registrar se suelta el bloqueo (con movimiento, además, sale confeti).
  15. Guardar como borrador

    en un plan que todavía no está registrado, pulsa Guardar borrador.

    Captura 4.30El borrador no habilita la asignación docente.
    Captura 4.31La nota de estado cambia a Borrador (v1).
    Captura 4.32El tablero de 2026-2 después: ING Registrado, ADM en Borrador, el resto Sin Iniciar.
  16. Derivar un curso a otro departamento

    con ?usuario=jhuamanih@ulasalle.edu.pe (director de DIMA), consulta ING y pulsa Derivar en un curso.

    Captura 4.33El director no tiene «Editar», pero ve «Derivar» en los cursos que asigna su departamento.
    Captura 4.34Departamento que lo asignará y motivo. Elegir el departamento de la carrera quita la derivación.
    Captura 4.35DPTO sigue diciendo DIMA, pero ahora «Asigna DAHUC». En ese curso el director de DIMA ya no ve «Derivar»: ahora lo asigna otro departamento.
    En el código
    derivarAsignatura (Tablero.html:299) → cambiarDepartamento (Planes.js:321): comprueba que quien deriva sea el director del departamento que hoy lo asigna o el coordinador de la carrera, que el plan no lo edite otra cuenta, y guarda una versión «cambio de departamento» con el motivo «código nombre: DIMA → DAHUC».

4.5Catálogo de acciones y posibilidades

AcciónRequiereFunciónQué hace
Cambiar de ciclo—#sel-ciclo → mostrarTableroLee el tablero de ese ciclo; el ciclo se comparte con Asignar docentes
Consultar un plan—abrirLecturaSolo lectura, con docentes por grupo
Editar un planCoordinar la carreraabrirEdicionGrilla con bloqueo; carga masiva si está vacío
Colapsar / expandir—#btn-colapsarPliega o despliega todos los semestres
Agregar asignatura—Dominio.agregarAsignaturaAsignatura con un grupo; reordena
Agregar grupo al semestre—Dominio.agregarGrupoSemestreUn grupo en cada asignatura del semestre seleccionado (o del primero)
Fusionar gruposDos o más grupos, uno de este planDominio.fusionarSección con un grupo principal; A05 si difieren
Historial de versionesBloqueo confirmadoobtenerHistorial, restaurarVersionVer y restaurar versiones
Editar HT / HPGrupo seleccionadoaccion("editar")Cambia las horas tal cual
Editar asignaturaGrupo seleccionadoaccion("editar-asignatura")Código, nombre, DPTO y observaciones
Agregar grupoGrupo seleccionadoDominio.agregarGrupoGrupo siguiente con las mismas horas
Desdoblar / Quitar desdobleGrupo seleccionado / con desdobleDominio.desdoblar, quitarDesdoblePRA×n o TEO×n, de 2 a 5
Deshacer fusiónGrupo fusionadoDominio.deshacerFusionDisuelve la sección; en otras carreras al guardar
Eliminar grupo / asignaturaGrupo seleccionadoDominio.eliminarGrupo, eliminarAsignaturaAvisa si hay docentes o fusiones
Reemplazar contenido—irACargaVuelve a la carga masiva (bloque 3)
Guardar borradorPlan no registrado, bloqueo confirmadoguardarPlan (tipo «guardar borrador»)Versión en Borrador
Registrar planBloqueo confirmadoguardarPlan (tipo «registrar»)Versión Registrado, pantalla de éxito, suelta el bloqueo
Guardar cambiosPlan registradoguardarPlan (tipo «guardar cambios»)Versión nueva con motivo opcional
Salir de la edición—terminarEdicionDescarta lo no guardado y suelta el bloqueo
Derivar un cursoDirector del dpto. que lo asigna o coordinadorcambiarDepartamentoVersión «cambio de departamento»
Ir a asignar docentes—data-ir="docentes"Desde la pantalla de éxito (bloque 5)

4.6Código clave comentado

El endpoint de guardado

Planes.js · guardarPlanlíneas 228–264
function guardarPlan(ciclo, sig, datos) {
  return responder_(function () {                               // contrato {ok, datos}
    const usuario = exigirUsuario_();
    const c = validarCiclo_(ciclo), s = validarCarrera_(sig);
    exigirEdicion_(usuario, s);                                    // SIN_PERMISO si no coordina
    const T = Dominio.TIPO_VERSION;
    if (!datos || [T.BORRADOR, T.REGISTRAR, T.CAMBIOS, T.AUTOGUARDADO].indexOf(datos.tipo) < 0)
      fallo_("DATO_INVALIDO", "Tipo de guardado desconocido.");
    const recibidas = Dominio.sanearAsignaturas(datos.asignaturas);   // nunca se confía en el navegador
    const disueltas = Dominio.sanearMarcas(datos.fusionesDisueltas);

    return conCandado_(function () {
      const b = exigirSinBloqueoAjeno_(c, s, usuario, "no se guardó");
      const fila = filaPlan_(c, s);
      const p = estadoDePlan_(fila);
      const conflicto = datos.forzar ? null : conflictoPlan_(c, s, p.version, datos.versionBase);
      if (conflicto) fallo_("CONFLICTO", "Mientras editabas se guardó la versión v" + conflicto.n + " del plan.", conflicto);
      const estado = resolverEstado_(p.estado, datos.estado);         // Registrado no vuelve atrás
      const fecha = Dominio.isoPeru();
      const antes = p.version ? contenidoPlan_(c, s, p.version, p.filaVersion) : [];
      const f = prepararFusiones_(c, s, antes, recibidas, disueltas, usuario);
      const n = registrarVersionPlan_(c, s, fila, {
        estado: estado, tipo: datos.tipo, usuario: usuario.correo, fecha: fecha,
        motivo: String(datos.motivo || "").slice(0, 500), asignaturas: f.asignaturas
      });
      const otros = guardarFusionesAjenas_(c, f.otros, usuario.correo, fecha);
      borrarRecuperacion_(c, Dominio.REF.plan(s), usuario.correo);
      const r = b ? tomarBloqueo_(c, Dominio.REF.plan(s), usuario.correo) : null;   // renueva
      return { version: n, estado: estado, fecha: fecha, expira: r && r.ok ? r.expira : null,
               asignaturas: f.asignaturas, otros: otros };
    });
  });
}

En una sola función se ven los tres bloques juntos: permisos y saneamiento (plataforma), candado, bloqueo, conflicto y versión (bloque 2), y estado y fusiones (bloque 4). sanearAsignaturas se queda solo con los campos del modelo, recorta textos y rechaza lo desmedido: más de 2000 asignaturas, más de 60 grupos por asignatura o más de 600 000 caracteres de JSON.

Las horas de dictado

Dominio.js · totaleslíneas 621–647
(asignaturas || []).forEach(a => a.grupos.forEach(g => {
  if (!g.fusion) return;
  if (g.fusionPrincipal) principalAqui.add(g.fusion);
  if ((g.fusionCon || []).some(x => x.principal)) principalFuera.add(g.fusion);
}));
(asignaturas || []).forEach(a => {
  t.asig++; sems.add(a.sem);
  if (esInternacional(a)) t.intl++;
  a.grupos.forEach(g => {
    t.grupos++; t.ht += num(g.ht); t.hp += num(g.hp); t.th += th(g);
    if (g.desdoble) t.des++;
    if (g.fusion) {
      if (!fus.has(g.fusion)) {                                 // la sección cuenta una vez…
        fus.add(g.fusion);
        if (principalAqui.has(g.fusion) || !principalFuera.has(g.fusion)) t.dictado += th(g);  // …en el plan del principal
      }
    }
    else t.dictado += th(g);
  });
});

Fusionar: el principal define las horas

Dominio.js · fusionar (núcleo)líneas 803–843
locales.forEach(v => {
  const s = par(v);                                          // {a, g} de "ci:gi"
  const esPrincipal = !!pLocal && s.g === pLocal.g;
  if (!esPrincipal && difiereDe(s.a.nombre, s.a.codigo, s.g.ht, s.g.hp, ref))
    difs.push(s.a.codigo + " " + s.a.nombre + " (grupo " + s.g.n + ")");   // → advertencia A05
  if (s.g.fusion && s.g.fusion !== marca) anteriores.add(s.g.fusion);
  if (!esPrincipal && (s.g.ht !== ref.ht || s.g.hp !== ref.hp) && !s.g.previas)
    s.g.previas = { ht: s.g.ht, hp: s.g.hp };                    // para deshacer
  s.g.fusion = marca;
  if (esPrincipal) s.g.fusionPrincipal = true; else delete s.g.fusionPrincipal;
  if (fusionCon.length) s.g.fusionCon = fusionCon.map(x => Object.assign({}, x)); else delete s.g.fusionCon;
  if (!esPrincipal) { s.g.ht = ref.ht; s.g.hp = ref.hp; }        // toma las horas del principal
});
anteriores.forEach(m => limpiarFusionHuerfana(asignaturas, m));

Reconciliar fusiones entre carreras

Dominio.reconciliarFusiones(sig, antes, despues, disueltas, otros) (L934) no toca la hoja: recibe el contenido de todos los planes del ciclo y devuelve qué cambia en cada uno. Para cada marca afectada:

  1. Si el usuario la deshizo y ya no queda ningún grupo suyo, la quita de los otros planes.
  2. Incorpora a la fusión los grupos de otras carreras que pide fusionCon (si un grupo ya no existe, se ignora).
  3. Si quedan menos de dos miembros, disuelve la fusión en todos.
  4. Si no, elige el principal (el marcado, o el pedido) e iguala: los demás toman sus horas, guardan previas y cada uno lista a los miembros de las otras carreras.

Los cambios se hacen sobre copias, y cada plan tocado anota un motivo («fusión con ING», «fusión disuelta») que queda en su versión.

Validar el desdoble en el navegador

GrillaAcciones.html · accion("desdoblar")líneas 147–176
const { tipo: clase, cant } = r();
const horas = num(clase === "Práctica" ? s.g.hp : s.g.ht);
const n = parseInt(cant, 10);
if (!horas) {
  await informar("El desdoble no aplica", "El grupo no registra horas de " + clase.toLowerCase() + "…");
  return;
}
if (!(n >= 2 && n <= CONFIG.maxSubgrupos)) {
  await informar("Cantidad de subgrupos no válida", "…entre 2 y " + CONFIG.maxSubgrupos + "…");
  return accion("desdoblar");                                // vuelve a preguntar
}
Dominio.desdoblar(s.g, clase, n);                             // g.desdoble = "PRA×3"

Permiso para derivar

Planes.js · cambiarDepartamentolíneas 321–353
const propio = Dominio.dptoDeCarrera(s);                                  // ING → DIMA
const actual = Dominio.DPTOS.indexOf(a.derivado) >= 0 ? a.derivado : propio;   // quién lo asigna hoy
const esDirector = usuario.rol === "director" && !!usuario.departamento && usuario.departamento === actual;
if (!esDirector && usuario.carreras.indexOf(s) < 0) fallo_("SIN_PERMISO", /* … */);
if (actual === d) return { /* … */ sinCambios: true };
exigirSinBloqueoAjeno_(c, s, usuario, "el cambio de departamento no se guardó");
if (d === propio) delete a.derivado; else a.derivado = d;           // volver al propio quita la derivación

4.7Errores, avisos y casos límite

Código o avisoCuándoQué ve la persona
SIN_PERMISOAbrir, guardar o restaurar un plan que no coordina; un director que intenta editar; derivar un curso que no asignaMensaje que explica qué sí puede hacer
BLOQUEADOEl plan, o un plan que la fusión cambia, lo edita otra cuenta«No se guardó: la fusión cambia el plan de …, que está editando …»
CONFLICTOSe guardó otra versión mientras se editabaDiálogo con «Guardar de todos modos» (bloque 2)
DATO_INVALIDOCiclo no configurado, carrera inexistente, tipo de guardado desconocido, grupo no válido o un plan desmedidoEl mensaje del saneamiento
NO_ENCONTRADORestaurar una versión que no existe; derivar una asignatura que ya no está«La versión vN no existe.» · «…Actualiza la pantalla.»
A05Fusión de grupos con distinto nombre, código u horasAdvertencia; la fusión se crea igual
«El desdoble no aplica»El grupo no tiene horas del tipo elegidoNo se generan subgrupos
«Hacen falta al menos dos grupos para fusionar.»El ciclo tiene menos de dos gruposAviso breve
«El plan no tiene asignaturas que guardar.»Guardar o registrar un plan vacíoAviso breve; no se llama al servidor
Casos límite Eliminar un grupo con docente no borra la asignación: queda «sin sección» y la carga avisa SIN_SECCION para que el director la reasigne. Si dos carreras se fusionan y una cambia su grupo principal, la sección pasa a la carga de la carrera del nuevo principal. Una fusión con un grupo que ya no existe en su plan se ignora en la reconciliación. La derivación se conserva aunque el Excel actualice el plan.

4.8Preguntas probables

¿Por qué un plan registrado no puede volver a borrador?

Porque registrarlo habilita la asignación docente: los departamentos ya pueden estar asignando sobre él. Volver a borrador dejaría esas asignaciones sobre un plan «no oficial». Se puede seguir editando, y cada cambio queda como versión.

¿Qué valida el sistema al editar?

Casi nada a propósito: advierte (A05, docentes asignados, filas ignoradas) pero no bloquea. Las únicas comprobaciones son el desdoble (que haya horas y de 2 a 5 subgrupos) y, en el servidor, el saneamiento contra datos imposibles de guardar.

¿Por qué el navegador edita sin hablar con el servidor?

Porque cada acción es inmediata y las funciones de Dominio son las mismas en los dos lados. El servidor solo recibe el plan completo al guardar y vuelve a comprobar permiso, bloqueo, conflicto y fusiones: nunca confía en lo que calculó el navegador.

¿Cómo puede una carrera cambiar el plan de otra sin su aprobación?

Solo en lo que toca a la fusión, porque la sección tiene un único docente y debe verse igual en los dos planes. El otro plan recibe una versión «fusión» con el motivo, y si alguien lo está editando, el guardado se detiene con BLOQUEADO.

¿Qué diferencia hay entre DPTO y derivar?

DPTO es la columna del Excel y solo informa. Quien asigna el docente es el departamento de la carrera, salvo que el curso se derive: entonces lo asigna el departamento elegido (asignatura.derivado), aunque el curso sigue en la carga de su carrera.

¿Por qué el tablero trae el contenido de todos los planes?

Para que consultar o empezar a editar sea instantáneo y para ofrecer los grupos de otras carreras al fusionar. Se lee en bloque y desde la caché de versiones, así que no es caro. Lo que sí es caro, los docentes por grupo, se pide aparte y en segundo plano.

¿Por qué «Registrar» está desactivado al abrir la grilla?

La grilla se abre al instante, pero el bloqueo aún no está confirmado. Hasta que el servidor responde no se puede guardar; se puede ir editando.

¿Qué pasa con los docentes si reemplazo el contenido?

Los grupos que siguen existiendo (mismo código y número de grupo) conservan su identificador, y las asignaciones los siguen. Los que desaparecen dejan sus asignaciones «sin sección».

4.9Para practicar

node --test AppScript/pruebas/dominio.test.mjs AppScript/pruebas/servidor.test.mjs
node AppScript/pruebas/recorrido-ui.mjs

En Windows, el recorrido necesita la ruta de Chrome en la variable CHROME, por ejemplo C:/Program Files/Google/Chrome/Application/chrome.exe.

Glosario

TérminoQué significa en este sistema
Plan de cicloLa oferta de una carrera en un ciclo: asignaturas por semestre con sus grupos y horas.
CicloPeriodo académico, como 2026-1. Los configurados están en SIS_CICLOS; los Excel son de SIS_CICLO_XLSX.
HT · HP · TotalHoras teóricas, horas prácticas y su suma, por grupo y por semana.
Horas de dictadoEl total contando una sola vez cada sección fusionada.
GrupoUna sección de una asignatura. Lleva un id que siguen las asignaciones docentes.
DesdobleDividir la práctica o la teoría de un grupo en subgrupos (PRA×n, TEO×n).
FusiónVarios grupos, de una o varias carreras, que se dictan como una sola sección con un docente. El grupo principal define las horas.
DerivarPasar a otro departamento la asignación del docente de un curso.
VersiónUn guardado completo de un plan o de una carga. Nunca se borra.
ÍndiceLa pestaña (Planes o Cargas) con la versión vigente de cada documento y su fila.
Bloqueo de ediciónLa reserva de un plan o una carga para una cuenta, con caducidad.
CandadoLockService: impide que dos ejecuciones escriban la hoja a la vez.
LatidoLa renovación del bloqueo que manda el navegador cuando hay actividad.
Copia de recuperaciónLo que se edita sin guardar, copiado cada minuto por si la página se cierra.
ConflictoIntentar guardar sobre una versión que ya no es la vigente.
RefEl nombre de un documento bloqueable: plan:ING o carga:ING.
FirmaLo que identifica un contenido para saber si cambió: «tipo|id|fecha» de un archivo, o el hash del contenido de un plan.
InterpretarConvertir una tabla en asignaturas y grupos sin rechazar nada.
SecciónEn la asignación docente, lo que pide un docente: un grupo o una fusión entera.
ComponenteParte de una sección que se asigna por separado: T, P, T1…, P1…, o C cuando no hay horas.
EndpointFunción del servidor sin «_» final, que el navegador llama con google.script.run.
DominioDominio.js: la lógica pura que se ejecuta igual en el servidor y en el navegador.