# Fase 8 — Estados de asistencia con pre-registro diario

Plan para Cursor (o implementación directa, a decidir). Diseñado con el
usuario en conversación (2026-10-01), antes de tocar código. Sistema
**en producción real** con datos reales — cualquier migración de esta
fase debe tratarse con el mismo cuidado que una migración de producción
(backup antes de `migrate`, verificación posterior).

## Objetivo

Hoy `attendance_records` solo tiene una fila cuando alguien ficha — no
hay forma de saber qué clases programadas (`assignments`) no se
dictaron. Esta fase agrega un estado explícito por clase programada,
generado por adelantado, para que "no fichó" sea un hecho en BD, no un
cómputo implícito que no existe.

## Decisiones de diseño acordadas

1. **Un solo comando artisan hace las dos cosas, en este orden**:
   generar primero, barrer después (si se barre antes de generar, lo
   recién generado y ya vencido se queda `PENDING` hasta la siguiente
   corrida — doble click para lo mismo).
2. **El comando es tolerante a no correr todos los días** (no hay cron
   real todavía, solo un botón). Guarda hasta qué fecha generó
   (`system_configuration.last_generated_date`) y cada corrida rellena
   el rango faltante hasta la fecha pedida (default hoy). El tope de 30
   días hacia atrás **solo aplica cuando ya existe una fecha real muy
   vieja** — la primera corrida en la vida del sistema (fecha null)
   arranca en la fecha pedida, nunca retrocede 30 días sola (ajustado
   durante la implementación: retroceder por defecto generaba y barría
   un mes de historial fabricado la primera vez que se activa la
   función, que no es lo mismo que "no se usó el botón unos días").
3. **El barrido revisa TODAS las filas `PENDING` de cualquier fecha**,
   no solo las de hoy — si no se usó el botón por varios días, al
   usarlo se pone todo al día de una.
4. **`REGULARIZED` se decide por quién llena el dato, no por si ya
   venció o no.** Cualquier fila que completa un admin pasa a
   `REGULARIZED` con `admin_observation` obligatoria — vencida o no
   todavía — porque sin cron real el estado guardado puede estar
   desactualizado respecto a la hora real. El candado de "no editar"
   se calcula con la hora real (fin de clase + ventana), no con el
   estado guardado.
5. **Editar una fila ya `ATTENDED`** (el docente fichó) se queda
   `ATTENDED` — es una corrección de un dato real, reusa
   `attendance_corrections` (ya existe, Fase 4), no cambia de estado.
6. **"Crear nueva" manual queda como excepción**, no como flujo
   principal — solo para sesión sin asignación, o asignación sin fila
   generada todavía (de alta reciente). Si ya existe una fila para esa
   asignación+fecha, el sistema dirige a editarla en vez de duplicar.

## Modelo de datos

### Nuevo enum `App\Enums\AttendanceStatus`

```php
enum AttendanceStatus: string
{
    case Pending = 'PENDING';
    case Attended = 'ATTENDED';
    case Absent = 'ABSENT';
    case Regularized = 'REGULARIZED';
}
```

### Migración sobre `attendance_records` (existente, edita no crea nueva — seguimos en desarrollo activo de esta fase)

- `status` — enum nuevo, `NOT NULL`, sin default a nivel de columna (el
  comando/kiosko/admin siempre lo setean explícito).
- `check_in_time`, `check_out_time`, `student_count`,
  `identification_method` — pasan a **nullable** (una fila `PENDING`
  no tiene esto todavía).
- Índice único `(assignment_id, class_date)` — identidad real de "esta
  clase programada en esta fecha", usado por el comando para no
  duplicar (`assignment_id` nullable: varias filas manuales sin
  asignación con `NULL` no chocan entre sí, MySQL lo permite).
- **Backfill de los registros existentes** (mismo patrón que la
  migración de `class_group_id`, con `chunkById`): `status = ATTENDED`
  si `created_by IS NULL`, `status = REGULARIZED` si `created_by` no
  es null.
- **`created_by` cambia de significado**: deja de setearse al *crear*
  la fila (ahora casi todas las crea el comando, nadie en particular) y
  pasa a setearse en el momento en que un admin **regulariza**
  (transiciona una fila a `REGULARIZED`). `AttendanceRecord::isExpiredRegularization()`
  (ya existe, `created_by !== null`) sigue funcionando igual sin
  tocarla — mismo criterio, momento distinto.

### Migración sobre `system_configuration`

- `last_generated_date` — `date`, nullable, default `null`.

## Comando `php artisan attendance:sync`

```
php artisan attendance:sync [--date=YYYY-MM-DD] [--dry-run]
```

`--date` por defecto hoy (límite superior del rango a generar).
`--dry-run` imprime qué haría sin escribir, para probar.

**Paso 1 — generar (catch-up):**
- Rango: desde `last_generated_date + 1 día` (o `--date - 30 días` si
  `last_generated_date` es null) hasta `--date`.
- Por cada fecha del rango: `DayOfWeek` de esa fecha, `Assignment`
  activos del periodo académico activo con ese `day_of_week` (misma
  consulta que `Kiosk::todaysAssignments()`, parametrizada por fecha en
  vez de "hoy").
- Por cada asignación: `firstOrCreate(['assignment_id' => ..., 'class_date' => ...], [status: Pending, teacher_id, course_id, class_group_id, cycle_id, classroom_id, academic_period_id, resto null])`
  — idempotente por el índice único.
- Al terminar sin error, actualizar `last_generated_date = --date`.

**Paso 2 — barrer vencidas:**
- Todas las filas `status = PENDING` (cualquier fecha), cargando su
  `assignment`.
- Si `now() > $assignment->registrationClosesAt($windowMinutes)`
  (mismo método que ya usa el kiosko) → `status = ABSENT`.
- `$windowMinutes` = `SystemConfiguration::current()->registration_window_minutes`.

## Botón en el panel admin

Vive en `/admin/attendance-records` (donde el admin ya trabaja con
esto). Llama al mismo comando (`Artisan::call('attendance:sync')`),
muestra cuándo fue la última corrida y un resumen (generadas / marcadas
ausentes). Mismo código que correrá el cron el día que exista — nunca
dos implementaciones de la misma lógica.

## Cambios en el kiosko (`app/Livewire/Kiosk/Kiosk.php`)

- `save()`: en vez de `AttendanceRecord::create(...)`, buscar la fila
  por `assignment_id` + `class_date` (debería existir como `PENDING`
  si el comando ya corrió) y `update()` con los datos del fichaje +
  `status = Attended`. Si no existe (el comando no alcanzó a generarla
  todavía — caso de borde), hacer `create()` como red de seguridad, igual
  que hoy.
- `recordedAssignmentIds()`: hoy considera "ya fichó" a cualquier fila
  que exista; con `PENDING` generado de antemano eso ya no sirve —
  debe filtrar `whereIn('status', [Attended, Regularized])`.
- `hasOverlappingRecord()`: revisar si sigue haciendo falta tal cual —
  con la fila ya identificada por `assignment_id`+`class_date`, gran
  parte de su chequeo por solape de horas pierde sentido para el
  camino con asignación (que es el único que existe hoy en el
  kiosko). Evaluar simplificar durante la implementación, no de
  antemano.

## Cambios en el panel admin (`AttendanceRecords.php` + `AttendanceRecordDetail.php`)

- **Listado**: columna/badge de `status`, filtro por estado (incluir
  `PENDING`/`ABSENT` para que el admin encuentre qué regularizar — este
  es el reporte real que pidió el usuario: "qué clases no se
  dictaron").
- **Flujo principal pasa a ser editar, no crear**: desde el listado, una
  fila `PENDING`/`ABSENT` se abre en `AttendanceRecordDetail` para
  completarla.
- **Candado de edición en `AttendanceRecordDetail`**: si
  `status === Pending` y `now() < assignment->registrationClosesAt(...)`
  → bloquear edición (el docente todavía puede fichar solo). En
  cualquier otro caso, editable.
- **Al guardar una edición**:
  - Si `status` actual es `Attended` → sigue `Attended`, motivo
    obligatorio, usa `attendance_corrections` (ya existe, sin tocar su
    mecánica).
  - Si `status` actual es `Pending` (vencida en la práctica, aunque el
    barrido no haya corrido) o `Absent` → pasa a `Regularized`,
    `admin_observation` obligatoria (ya existe la validación), setear
    `created_by = Auth::id()` en ese momento.
- **"Crear nueva" (modal actual)**: se mantiene solo para sin-asignación
  (igual que hoy) o asignación sin fila generada (selector opcional de
  asignación). Si la combinación asignación+fecha ya tiene fila,
  redirigir a editarla (mismo patrón que el `$this->duplicateError`
  actual). Toda alta por este modal nace directo en `Regularized`.

## Revisar (no rediseñar de antemano, auditar al implementar)

Cualquier vista que hoy cuenta "filas de `attendance_records`" como
sinónimo de "asistencias" va a contar también las `PENDING` — dashboard,
reportes exportables, tarjeta de "modo manual" (CLAUDE.md §11, regla no
negociable). Revisar cada una y filtrar por `status` donde corresponda.

## Testing

- Factory de `AttendanceRecord` necesita `status` por defecto
  (`Attended`, para no romper los 118 tests existentes que no lo
  conocen) — revisar uno por uno cuáles rompen y por qué antes de
  "arreglarlos a ciegas".
- Nuevos: comando idempotente (correrlo dos veces no duplica),
  catch-up de rango (simular `last_generated_date` viejo), barrido con
  `Carbon::setTestNow()`, candado de edición por hora real, transición
  `Attended` (corrección) vs `Pending/Absent → Regularized`
  (`admin_observation`), alta manual con asignación ya generada →
  redirige en vez de duplicar.

## Riesgos / pendientes de decidir en la implementación

- Backup de BD real antes de correr la migración en producción —
  confirmar con el usuario antes de ese paso específico.
- Ubicación exacta del botón "Sincronizar" en la UI — a definir en el
  armado, no bloqueante para el diseño.
- Sin periodo académico activo, el comando no genera nada (mismo
  comportamiento que `Kiosk::todaysAssignments()` hoy).
