# MODELO-DATOS.md — DocenTrack

Modelo de datos final, resultado de la revisión tabla por tabla con el
usuario (2026-09-13). Identificadores en inglés (tablas, columnas —
regla del workspace, confirmada explícitamente para este proyecto).
Comentarios en el código real: español, solo donde aporte un porqué no
obvio. No son migraciones todavía, es el diseño ya validado para
escribirlas.

## Reglas transversales acordadas

- **Nunca se elimina físicamente algo que tiene una dependencia** — ni
  catálogos (`courses`, `classrooms`, `cycles`, etc.) ni `users`. Si
  tiene alguna fila que lo referencia, el borrado se bloquea y se
  dirige a desactivar (`active = false`). Una sola regla para todo el
  sistema, sin excepciones por tabla — mismo criterio que ya aplica
  `tutoria-fichas`.
- **Un fichaje casi-duplicado se bloquea, no solo se alerta**: mismo
  `teacher_id` + `course_id` + `class_date` + horas
  declaradas que se solapan con un `attendance_record` ya existente →
  no se guarda, mensaje dirige a pedir corrección al admin. No bloquea
  sesiones distintas legítimas el mismo día (curso repetido en otro
  bloque horario), porque compara también las horas, no solo la fecha.
- Catálogos usan `active` (toggle), no soft delete — excepto `users`,
  que sí tiene soft delete además de `active` (ver tabla 1).

## 1. `users`

Ancla de identidad — una fila por persona real, sea administrador o
docente. Reutiliza/edita la migración `users` que trae Laravel por
defecto, no se crea una tabla aparte.

| Columna | Tipo | Por qué / para qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `document_type` | enum(`DNI`,`CE`,`PASSPORT`) | Embebido, sin tabla de catálogo — es un dato que casi no cambia. |
| `document_number` | string | Único **compuesto** con `document_type`. Es la llave natural de identidad acordada para integración futura con `tutor-backend`/`tutoria-fichas`. |
| `names` | string | "Nombres" tal como viene en el DNI — puede incluir más de un nombre de pila en un solo string, no se subdivide más. |
| `paternal_surname` | string | Apellido paterno — separado para ordenar/buscar por apellido en reportes. |
| `maternal_surname` | string, nullable | Apellido materno — nullable porque legalmente hay personas con un solo apellido. |
| `sex` | enum(`M`,`F`) | Dato de identificación oficial. |
| `birth_date` | date | Dato de identificación oficial. |
| `academic_degree` | enum(`BACH`,`LIC`,`ING`,`MG`,`DR`), nullable | Grado académico del docente (Bach./Lic./Ing./Mg./Dr.), de menor a mayor según la escala real — no existe nada formal por encima de `DR`. Solo se expone en el formulario de `role = TEACHER`, aunque la columna no está restringida a nivel de BD. |
| `academic_department` | enum(`HUMANIDADES`,`ADMINISTRACION`,`CIENCIAS_BASICAS`,`INGENIERIA_SISTEMAS`), nullable | Facultad/departamento académico de procedencia del docente. Nullable en BD (los administradores no lo usan); **obligatorio en el formulario de `role = TEACHER`**. |
| `teacher_type` | enum(`NOMBRADO`,`CONTRATADO`,`ASOCIADO`), nullable | Condición del docente. Mismo criterio que `academic_department`: nullable en BD (los administradores no lo usan), **obligatorio en el formulario de `role = TEACHER`**. En producción todos los docentes existentes al 2026-10-05 se cargaron como `CONTRATADO`. |
| `email` | string, nullable | Solo lo usan `role = ADMIN` para login/recuperación. |
| `phone` | string, nullable | Dato de contacto, no lo usa ninguna lógica del sistema. |
| `password` | string, nullable | Solo `role = ADMIN`. Un `TEACHER` nunca inicia sesión — sin contraseña, ni con el formulario de login se puede entrar. |
| `must_change_password` | boolean, default `false` | Se marca `true` al crear un admin (contraseña inicial = su documento) o al restablecerle la contraseña — `RequirePasswordChange` lo obliga a cambiarla antes de usar cualquier otra pantalla. |
| `role` | enum(`ADMIN`,`TEACHER`) | Reemplaza lo que iban a ser tablas de perfil separadas — hoy solo 2 roles, mutuamente excluyentes en el uso real del sistema. **Límite consciente**: una persona no puede ser `ADMIN` y `TEACHER` a la vez bajo este modelo — aceptado explícitamente por el usuario. |
| `reference_photo_path` | string, nullable | **Obligatoria para `role = TEACHER`** (es la referencia contra la que se comparan las fotos de auditoría de cada fichaje) — **opcional para `role = ADMIN`** (solo foto de perfil, sin uso funcional). Disco `local` (`storage/app/private`), carpeta `reference-photos/`; se sirve solo por ruta admin autenticada. |
| `active` | boolean | Desactiva a una persona sin borrar su historial. |
| `deleted_at` | timestamp, nullable (soft delete) | Solo para alta duplicada por error, capturada antes de cualquier actividad real — bloqueado si ya tiene dependencias (regla transversal). |
| `created_at`, `updated_at` | timestamp | Estándar. |

*(No existe `code` — se evaluó como identificador corto de docente y se
descartó: no hay un código institucional real que reemplazar, y
`document_number` ya identifica sin ambigüedad.)*

## 2. `fingerprints`

**Pendiente de Fase 6 — todavía no existe en la base de datos.** El
resto de tablas de este documento ya están migradas y en uso desde
Fases 1-4; esta es la única que sigue siendo solo diseño, bloqueada
hasta que llegue el lector físico (ver `../CLAUDE.md` § "Qué falta").

| Columna | Tipo | Por qué / para qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `user_id` | FK → `users`, único | Un docente, una huella activa — re-enrolar reemplaza la fila. Se valida a nivel de app que sea `role = TEACHER`. |
| `template` | binary/text | El dato que entrega el SDK ZKFinger — **nunca imagen**. |
| `enrolled_by` | FK → `users` | Qué admin hizo el enrolamiento. |
| `enrolled_at` | timestamp | Separado de `created_at` por si algún día se permite re-enrolar sin recrear la fila. |
| `active` | boolean | Desactivar una huella (ej. mala calidad) sin borrar el registro de que existió. |
| `created_at`, `updated_at` | timestamp | Estándar. |

**Nota de arquitectura** (no es columna, es cómo se usa): el servidor
guarda el `template` de forma permanente; el agente local sincroniza
una copia de trabajo de todas las huellas activas al arrancar (y al
enrolar/desactivar a alguien) y hace la comparación 1:N localmente
contra esa copia — el servidor no hace el matching.

## 3. `cycles`

Catálogo simple, nivel curricular (I al X).

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `name` | string | El valor del ciclo — string, se muestra/ordena como número romano. |
| `active` | boolean | Retirar sin romper referencias. |
| `created_at`, `updated_at` | timestamp | Estándar. |

## 4. `academic_periods`

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `name` | string | Código del periodo (ej. "2026-I"). |
| `active` | boolean | Uno solo activo a la vez — regla de **aplicación**, no constraint de BD (mismo criterio que `tutoria-fichas`). |
| `created_at`, `updated_at` | timestamp | Estándar. |

*(Sin `start_date`/`end_date` — se evaluaron y se quitaron: nada en la
lógica del sistema las usa, `active` solo ya decide cuál rige.)*

## 0. `campuses` (multisede, 2026-10-01)

Catálogo de sedes — hoy **Sede Abancay** y **Filial Tambobamba**. Es la
fuente real de "a qué sede pertenece algo": nunca se amarra al docente
ni al curso (ambos son compartidos entre sedes), solo al aula
(`classrooms.campus_id`) — todo lo demás (asignación, asistencia,
grupos por curso+periodo) deriva de ahí.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `name` | string, único | "SEDE ABANCAY", "FILIAL TAMBOBAMBA". |
| `active` | boolean | Retirar sin romper referencias. |
| `created_at`, `updated_at` | timestamp | Estándar. |

## 5. `classrooms`

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `campus_id` | FK → `campuses` | **2026-10-01.** Un aula pertenece a una sola sede — es la fuente real de la sede de una asignación (`Assignment::campus()`, derivado, no un campo propio). |
| `name` | string | Nombre/código del aula (ej. "Aula 204"). **Único por sede, no global** (`classrooms_campus_id_name_unique`) — dos sedes pueden tener cada una su propia "Aula 204". |
| `capacity` | integer | Informativo — no valida nada hoy (no bloquea si `student_count` la supera). |
| `active` | boolean | Retirar sin romper referencias. |
| `created_at`, `updated_at` | timestamp | Estándar. |

## 6. `courses`

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `code` | string, único | Código institucional del curso; se muestra junto al nombre para diferenciarlo en listas y selectores. |
| `name` | string | Nombre del curso (**no** único — dos cursos pueden compartir texto descriptivo; el identificador real es `code`). |
| `type` | enum(`GENERAL`,`ESPECIALIDAD`,`ESPECIFICO`) | Clasificación curricular obligatoria. `ESPECIFICO` se agregó ya en producción (2026-10-05), por eso va al final del ENUM; el orden visible lo da `CourseType`. |
| `cycle_id` | FK → `cycles`, nullable, `restrictOnDelete()` | Ciclo al que pertenece el curso (un curso es de un solo ciclo). Nullable en BD solo porque los cursos ya cargados en producción no lo tenían; **obligatorio en el formulario**. Un curso sin ciclo no se puede asignar ni usar en asistencia (`Course::hasCycleRule()`). |
| `credits` | unsignedInteger | Créditos académicos obligatorios. |
| `theory_hours` | unsignedInteger | Horas de teoría (HT) obligatorias. |
| `practice_hours` | unsignedInteger | Horas de práctica (HP) obligatorias. |
| `total_hours` | unsignedInteger | Total de horas (TH) obligatorio. |
| `prerequisite_id` | FK → `courses`, nullable | Un curso requisito opcional; no puede referirse a sí mismo y bloquea el borrado del curso requisito mientras esté en uso. |
| `resolution_id` | FK → `resolutions`, nullable | Resolución curricular opcional; `restrictOnDelete()`. |
| `study_plan_path` | string, nullable | PDF del plan de estudios (opcional). Disco `local` (`storage/app/private`), carpeta `course-study-plans/`; se sirve solo por ruta admin autenticada. Reemplaza el temario por ítems que existía antes. |
| `active` | boolean | Retirar sin romper referencias. |
| `created_at`, `updated_at` | timestamp | Estándar. |

*(Desde 2026-10-05 el ciclo vive **solo** acá. `assignments` y
`attendance_records` ya no tienen `cycle_id`: exponen `cycle()` como
`hasOneThrough` vía el curso.)*

*(Sin tabla `topics` — se retiró: basta el PDF del plan de estudios.)*

## 6b. `resolutions`

Catálogo de resoluciones curriculares referenciables desde cursos.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `name` | string(150) | Texto descriptivo (**no** único). |
| `resolution_number` | string, nullable, único | Identificador real de la resolución cuando existe. Varios `NULL` permitidos. |
| `active` | boolean | Retirar sin romper referencias. |
| `created_at`, `updated_at` | timestamp | Estándar. |

## 6c. `class_groups`

Catálogo fijo de grupos de clase (**sin CRUD** — solo sembrado): `U`
(Único), `A`–`E`.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `abbreviation` | string(5), único | Código corto (`U`, `A`, …). |
| `name` | string(50) | Etiqueta en mayúsculas (`ÚNICO`, `GRUPO A`, …). |
| `active` | boolean | Estatus ACTIVO/INACTIVO del catálogo. |
| `created_at`, `updated_at` | timestamp | Estándar. |

## 6d. `course_academic_period_groups`

Selección explícita de qué grupos del catálogo aplica un curso en un
periodo académico. Es lo que alimenta el select de grupo en
Asignaciones.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `course_id` | FK → `courses`, cascade | Curso. |
| `academic_period_id` | FK → `academic_periods`, cascade | Periodo. |
| `campus_id` | FK → `campuses` | **2026-10-01.** Mismo curso+periodo puede tener distinta cantidad de grupos por sede (el problema real que dispara esto: 2 grupos en Abancay, 1 en Tambobamba). |
| `class_group_id` | FK → `class_groups`, cascade | Grupo del catálogo — la etiqueta (`A`, `B`, …) es compartida entre sedes, lo que varía es qué combinaciones existen. |
| `created_at`, `updated_at` | timestamp | Estándar. |

Único compuesto `(course_id, academic_period_id, campus_id, class_group_id)`
(`course_period_campus_group_unique`). No se puede desmarcar un grupo si
existe alguna `assignment` o `attendance_record` con ese
curso+grupo+periodo+sede.

## 7. `assignments`

El nodo central — qué docente dicta qué curso/grupo, en qué ciclo, aula,
periodo y bloque horario. **Una fila = un bloque horario (un día)** —
si el docente dicta varios días, son varias filas (nunca un array/CSV
de días en una sola fila: rompería la comparación de horario y las
consultas por día).

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `teacher_id` | FK → `users` | Docente asignado. |
| `course_id` | FK → `courses` | Qué curso. |
| `class_group_id` | FK → `class_groups`, obligatorio, `restrictOnDelete()` | Grupo del curso en ese periodo. |
| `classroom_id` | FK → `classrooms` | En qué aula. **Sin `campus_id` propio** (2026-10-01): la sede de la asignación es siempre la del aula elegida — `Assignment::campus()` la deriva, nunca un campo aparte que pueda quedar inconsistente. |
| `academic_period_id` | FK → `academic_periods` | En qué periodo rige. |
| `day_of_week` | enum/string | Un solo día por fila. |
| `start_time`, `end_time` | time | El bloque horario asignado — contra esto se calcula `out_of_schedule` en el fichaje, y desde 2026-10-01 también el choque de horario (mismo docente o misma aula con horas que se solapan, dentro del mismo periodo académico — bloqueante, validado en `Assignments::save()`). |
| `active` | boolean | Retirar (cambio de horario) sin borrar el historial de fichajes que la referenciaron. |
| `created_at`, `updated_at` | timestamp | Estándar. |

**Regla de negocio (aplicativa, no UNIQUE de BD):** un
curso+grupo+periodo solo puede tener un docente. El mismo docente sí
puede repetir el mismo curso+grupo en distintos días/horarios, pero
**no** puede tener dos grupos distintos del mismo curso en el mismo
periodo. Liberar un grupo = eliminar la asignación que lo ocupa (ya
bloqueado si hay `attendance_records`).

## 8. `system_configuration`

Fila única — el switch abierto/cerrado del kiosko, manual, sin
horario/calendario.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK, siempre 1. |
| `is_open` | boolean | Si `false`, el kiosko muestra "SISTEMA CERRADO" y no lee huella ni hace peticiones — chequeo antes que cualquier otra cosa. |
| `registration_window_minutes` | unsignedInteger, default 120 | Minutos tras el fin de clase que el docente tiene para fichar — el mismo valor decide cuándo una fila `PENDING` de `attendance_records` pasa a `ABSENT` (Fase 8). |
| `last_generated_date` | date, nullable | Hasta qué fecha ya generó filas `PENDING` el comando `attendance:sync` (Fase 8) — `null` = nunca corrió. Permite que el botón "Sincronizar" rellene el rango faltante sin que el admin tenga que acordarse de usarlo todos los días. |
| `allow_fingerprint`, `allow_document` | boolean, default `true` | Política real de identificación del kiosko (2026-10-01) — el servidor la exige en `Kiosk::identify()`/`startIdentifying()`, no es solo ocultar un botón. Nunca pueden quedar las dos en `false` a la vez (regla aplicativa, validada en `SystemConfiguration` Livewire admin, no en la columna). |
| `updated_by` | FK → `users` | Qué admin lo cambió por última vez. |
| `created_at`, `updated_at` | timestamp | `updated_at` + `updated_by` = toda la auditoría que necesita este switch. |

## 9. `attendance_records`

Desde Fase 8, no es solo "un fichaje = una visita al kiosko": una fila
también puede ser una **clase programada pendiente de fichar**,
generada de antemano por `attendance:sync` desde `assignments`. Sigue
siendo la tabla más importante del sistema — el reemplazo digital del
cuaderno físico, ahora con estado explícito en vez de "la fila existe =
asistió".

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `teacher_id` | FK → `users` | Quién fichó (o a quién corresponde la clase, si todavía está `PENDING`). |
| `assignment_id` | FK → `assignments`, nullable | Nullable solo para altas manuales sin asignación real (sesión no programada) o regularizaciones de clase vencida previas a Fase 8. Toda fila generada por `attendance:sync`, y todo fichaje del kiosko, lleva `assignment_id`. Único junto con `class_date` (`attendance_records_assignment_date_unique`) — identidad real de "esta clase, este día", usada por el comando para no duplicar. |
| `course_id`, `classroom_id`, `academic_period_id` | FK | Valores **efectivamente confirmados** por el docente (o copiados de `assignment_id` al generar la fila `PENDING`) — pueden diferir de los de `assignment_id` tras una corrección; no se recalculan desde ahí porque la asignación puede cambiar/desactivarse después sin que el histórico se mueva. *(Sin `topic_id`: el plan de estudios vive en el curso como PDF.)* |
| `campus_id` | FK → `campuses` | **2026-10-01.** Copiado de `classroom_id` al generar/fichar/corregir (mismo criterio que `course_id` etc.) — filtro directo para reportes/dashboard/listado sin join por consulta. |
| `class_group_id` | FK → `class_groups`, nullable | Grupo de la clase — copiado de `assignment_id` al generar/fichar. |
| `status` | enum(`PENDING`,`ATTENDED`,`ABSENT`,`REGULARIZED`), `NOT NULL` | **Fase 8.** `PENDING` = generada por `attendance:sync`, el docente todavía puede fichar. `ATTENDED` = fichó él mismo (kiosko). `ABSENT` = venció la ventana sin fichar (lo marca el barrido del mismo comando). `REGULARIZED` = la completó un admin — sin importar si ya estaba `ABSENT` o seguía `PENDING` cuando la tocó (el candado de edición usa la hora real, no este campo, porque sin cron real el estado guardado puede no estar al día). |
| `student_count` | integer, nullable | Número de alumnos declarado — `null` mientras está `PENDING`. |
| `check_in_time`, `check_out_time` | time, nullable | Horas declaradas — no son timestamps reales. `null` mientras está `PENDING`. |
| `class_date` | date | Fecha de la clase — separada de `created_at`, el docente puede fichar hoy una clase de un día anterior. |
| `identification_method` | enum(`fingerprint`,`manual`), nullable | Con qué método se identificó — `null` mientras está `PENDING` (todavía no se identificó nadie). Enum y no booleano, para poder sumar métodos futuros (ej. facial) sin otra migración. |
| `out_of_schedule` | boolean | Calculado al guardar, comparando horas declaradas contra `assignment_id`. |
| `late_declaration` | boolean | Calculado al guardar: `class_date` muy alejada de `created_at`, o en el futuro. |
| `created_at` | timestamp | **Es** el timestamp real del toque/guardado en el kiosko (o de generación, si nace `PENDING`). |
| `updated_at` | timestamp | Refleja la última corrección aplicada — el valor cambia solo vía `attendance_corrections`, nunca directo. |
| `created_by` | FK → `users`, nullable | `null` = vino del kiosko o todavía no la toca nadie; con valor = un admin la **regularizó** (campo que se setea en el momento de la regularización, no al generar la fila — ver `status`). Nunca se confunde con el docente mismo — siempre visible en tabla/filtro/resumen, mismo criterio que el requisito no-negociable de "modo manual siempre visible". |
| `admin_observation` | text, nullable | Motivo obligatorio cuando un admin regulariza (`status = REGULARIZED`). `null` en fichajes normales y en filas `PENDING`/`ABSENT` sin tocar. |

**Validación al guardar (no columna, regla de negocio)**: se bloquea si
ya existe un registro con mismo `teacher_id` + `course_id`
+ `class_date` y horas que se solapan — evita fichajes duplicados por
error sin bloquear sesiones distintas legítimas el mismo día. Desde
Fase 8, el índice único `(assignment_id, class_date)` es la segunda
línea de defensa para el camino con asignación.

## 10. `attendance_evidence`

Las 3 fotos por fichaje — solo auditoría humana, nunca matching
automático.

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `attendance_record_id` | FK → `attendance_records` | A qué fichaje pertenece. |
| `type` | enum(`identification`,`form`,`confirmation`) | Las 3 etapas del flujo donde se captura cada foto. |
| `file_path` | string | Ruta del archivo en disco `local` (`storage/app/private`, carpeta `attendance-evidence/`); se sirve solo por ruta admin autenticada. |
| `created_at` | timestamp | Sin `updated_at` — inmutable, una foto no se edita. |

## 11. `attendance_corrections`

Exclusiva para corregir un `attendance_record` ya guardado — nunca
para alta (esa siempre exige huella o modo manual, jamás creación
libre).

| Columna | Tipo | Por qué |
|---|---|---|
| `id` | bigIncrements | PK. |
| `attendance_record_id` | FK → `attendance_records` | Qué registro se corrigió. |
| `corrected_by` | FK → `users` | Qué admin corrigió. |
| `field` | string | Qué campo cambió — string, no enum, porque cualquier campo del registro es corregible. |
| `old_value`, `new_value` | string | Guardados como texto independiente del tipo real del campo — mismo criterio simple que la auditoría genérica de `tutoria-fichas`. |
| `reason` | string, **obligatorio** | Regla dura: toda corrección exige motivo. |
| `created_at` | timestamp | Sin `updated_at` — una corrección tampoco se edita; si se corrige mal, se crea otra corrección. |

**Por qué no una tabla `audits` genérica** (como la de `tutor-backend`/
`tutoria-fichas`): hoy nada más en el sistema necesita auditoría, solo
las correcciones de `attendance_records`, con una regla más estricta
(motivo obligatorio) que no aplicaría igual a una auditoría genérica.
Si en el futuro se necesita auditar también catálogos, se evalúa
entonces — no se generaliza de antemano.

## Resumen — 15 tablas de dominio

| # | Tabla | Grupo | Estado |
|---|-------|-------|--------|
| 0 | `campuses` | Catálogo | ✅ Migrada (multisede, 2026-10-01) |
| 1 | `users` | Identidad | ✅ Migrada (Fase 2a) |
| 2 | `fingerprints` | Biometría | ✅ Migrada (Fase 6) |
| 3 | `cycles` | Catálogo | ✅ Migrada (Fase 1) |
| 4 | `academic_periods` | Catálogo | ✅ Migrada (Fase 1) |
| 5 | `classrooms` | Catálogo | ✅ Migrada (Fase 1; `campus_id` desde multisede) |
| 6 | `class_groups` | Catálogo | ✅ Migrada (décima pasada; sin CRUD, solo sembrado) |
| 7 | `resolutions` | Catálogo | ✅ Migrada (décima pasada) |
| 8 | `courses` | Catálogo | ✅ Migrada (Fase 1; `study_plan_path` novena; `resolution_id` + nombre no único en décima) |
| 9 | `course_academic_period_groups` | Catálogo | ✅ Migrada (décima pasada; `campus_id` desde multisede) |
| 10 | `assignments` | Horarios | ✅ Migrada (Fase 3; `class_group_id` en décima; choque de horario desde multisede) |
| 11 | `system_configuration` | Fichaje | ✅ Migrada (Fase 1; `allow_fingerprint`/`allow_document` desde permisos de identificación) |
| 12 | `attendance_records` | Fichaje | ✅ Migrada (Fase 2b, `assignment_id` en Fase 3; sin `topic_id` desde novena pasada; `status`/pre-registro diario desde Fase 8; `campus_id` desde multisede) |
| 13 | `attendance_evidence` | Fichaje | ✅ Migrada (Fase 2b) |
| 14 | `attendance_corrections` | Auditoría | ✅ Migrada (Fase 4) |

*(La tabla `topics` existió en Fase 1 y se retiró: el plan de estudios
queda como PDF opcional en `courses.study_plan_path`.)*

(+ tablas propias del framework: `sessions`, `password_reset_tokens`,
`cache`, `jobs` — no son entidades de dominio.)

## Pendiente para la fase de implementación (no bloquea el diseño)

- Índices más allá de los únicos ya especificados — ej.
  `attendance_records(teacher_id, class_date)`,
  `assignments(teacher_id, day_of_week, active)` para el prellenado del
  kiosko. Se definen al escribir las migraciones, no cambian el modelo.
- FK de "quién hizo esto" (`enrolled_by`, `corrected_by`, `updated_by`)
  no necesitan `nullOnDelete` — `users` nunca se borra físicamente si
  tiene dependencias (regla transversal), así que la referencia siempre
  es válida.
