## Supuestos adoptados en F2B

| Decisión | Supuesto adoptado | Alternativa descartada | Quién debe validar |
|---|---|---|---|
| Límite de 2 citas por terapeuta | Se aplica **por terapeuta por día, independientemente del asesor** (recorre todos los `berater_id` del terapeuta ese día), y **es general** (aplica a socios y no-socios por igual) — así lo describe literalmente el test crítico del plan. Es distinta del campo `einstellungen.limite_citas_no_socio` (F2A), que interpretamos como un límite de **uso total** para terapeutas no-socios (aún no implementado su enforcement — pendiente F3/F4). | (a) Límite por terapeuta+asesor+día. (b) Que el límite de 2/día solo aplicara a no-socios, unificándolo con `limite_citas_no_socio`. | Verena — pregunta explícita pendiente ("¿El límite de 2 citas es por terapeuta por día, o por terapeuta por asesor por día?"). |
| Horario del asesor (franjas horarias) | La spec (`horario_semanal`/`horario_slots`) exige días **y** franjas horarias, pero F1 solo modeló días. Se añadió `start_time`/`end_time` (una única franja diaria, igual todos los días activos del asesor), no franjas distintas por día de la semana. | Modelar `weekly_schedule` como JSON `{"mon": {"start":..,"end":..}, ...}` con horas distintas por día. | Verena / dev — simplificación razonable dado que la spec no da valores concretos por día; ampliar si se necesitan franjas distintas. |
| Campos de "Formación recomendada" | Se tomaron literalmente de `helpmi-spec.html` (Grundlagenseminar, A1, A2, Lyty, Serologien, Bestimmte Pathologien, Weitere empfohlene Hilfen) — el plan solo mencionaba "checkboxes de formaciones" de forma genérica; la especificación es la fuente de verdad y es más precisa. | Interpretar "checkboxes de formaciones" de forma libre sin campos concretos. | — (ya resuelto con la spec, no requiere validación de Verena). |
| Herencia de citas del sustituto | El sustituto ve y puede editar (incluido marcar completada) las citas del asesor ausente durante el periodo de vacaciones (`TerminPolicy::isAssignedOrActiveSubstitute`), pero **no puede crear** citas nuevas manualmente (esa acción es solo admin en F2B). No se implementó lógica de "solo las nuevas durante la ausencia". | Que el sustituto solo vea citas creadas durante su periodo de sustitución, no las preexistentes. | Verena — pregunta explícita pendiente ("¿Un asesor sustituto puede crear nuevas citas o solo ver las heredadas?"). |
| Editabilidad del `interner_kommentar` | Editable tanto por `admin` como por el `berater` asignado/sustituto (ambos pueden `update()` según `TerminPolicy`). | Que solo `admin` pueda editarlo. | Verena — pregunta explícita pendiente ("¿El interner_kommentar es editable por el berater o solo por el admin?"). |
| 403 vs 404 en acceso cruzado entre Beraters | Se decidió que `TerminResource::getEloquentQuery()` excluya a nivel de query las citas ajenas (defensa en profundidad), lo que hace que Filament devuelva **404** en vez del 403 literal del test Given/When/Then del plan — no revela si el registro existe, que es más seguro. | Confiar solo en `TerminPolicy::view()` (403) sin scope de query, dejando que cualquier ID de cita ajena sea al menos "encontrable". | Dev — decisión de seguridad, documentada como mejora sobre el criterio literal del plan. |
| Creación manual de citas | Solo `admin` puede crear citas manualmente desde el panel (`TerminPolicy::create()` → `false` para todos; el formulario de creación de Filament pasa por `TerminBookingService`, no por Eloquent::create directo). `berater` no tiene botón de creación. | Permitir también a `berater` crear citas manuales. | Coincide con el texto del plan ("Creación manual de cita (para reservas por teléfono)" bajo "Vista admin"). |
| Generación de Fallnummer/FAX-Code bajo concurrencia | Se usa la fila singleton de `einstellungen` como mutex global (`lockForUpdate()`) para serializar la generación del Fallnummer entre transacciones concurrentes, y un `lockForUpdate()` sobre la fila del `Berater` para serializar la reserva del mismo slot. Verificado manualmente contra MySQL real (no solo SQLite) generando 2 citas seguidas: `2026-0001`, `2026-0002` con FAX-Code únicos. | Usar una tabla de contador dedicada, o `SELECT MAX(...) FOR UPDATE` sin mutex global (insuficiente si no hay filas previas ese año — no bloquea nada). | Dev — decisión técnica. El test automatizado de "dos terapeutas reservan el mismo slot simultáneamente con hilos reales" queda pendiente de un test de integración con conexiones paralelas reales a MySQL (Pest con RefreshDatabase usa una única conexión); la exclusividad lógica sí está cubierta por test. |
| Evento `AppointmentCompleted` | Se creó la clase de evento y se dispara al marcar una cita como completada, sin listeners todavía (F4 añadirá el email, F7 el registro en CRM). No se crearon `AppointmentCreated` ni `AppointmentCancelled` en F2B — quedan para cuando F4/F7 los necesiten, evitando registrar eventos sin consumidor todavía. | Crear ya los 5 eventos de F7 y registrarlos en un `EventServiceProvider`. | Dev — decisión de alcance, evita scope creep hacia F7. |

## Bug encontrado tras el commit inicial de F2B (corregido)

Al probar `/admin/termins/create` manualmente contra MySQL real, guardar con
cualquier campo opcional vacío (Unterkategorie, Geschlecht, Alter, Gewicht,
Größe, PLZ, Betreuer, comentario) fallaba en silencio ("la pantalla parpadea
pero no guarda"). Causa: Filament envía `''` (no `null`) para campos vacíos,
y MySQL en modo estricto rechaza `''` en columnas numéricas/FK con
`SQLSTATE[HY000]: 1366 Incorrect integer value`. **SQLite (usado en
`.env.testing`) no detecta este error** por ser de tipado dinámico — la
suite de Pest pasaba en verde mientras el bug rompía la app contra MySQL
real. Es el mismo motivo por el que el plan (§4) pide MySQL real para el
test de concurrencia, no solo SQLite.

Corregido normalizando `'' → null` en `TerminBookingService::book()` y en
`EditTermin::mutateFormDataBeforeSave()`, con test de regresión en
`TerminBookingServiceTest`.

## Segundo bug: ValidationException sin manejar en CreateTermin (corregido)

Tras el fix anterior, seguía fallando en silencio al reservar un slot ya
ocupado por otro Termin (o al superar el límite de 2 citas/día). Causa:
`TerminBookingService::book()` lanza `ValidationException` **dentro** de
`CreateTermin::handleRecordCreation()`, un punto que Filament NO trata como
parte de su ciclo de validación de formulario — `CreateRecord::create()`
solo captura `Halt` para abortar limpiamente; cualquier otro `Throwable`
(incluida `ValidationException` lanzada manualmente) se relanza sin control,
y la petición Livewire falla sin mostrar nada al usuario ("la pantalla
parpadea pero no guarda").

Corregido envolviendo la llamada en `try/catch(ValidationException)` dentro
de `CreateTermin::handleRecordCreation()`: se muestra una `Notification`
con el mensaje de error y se lanza `Filament\Support\Exceptions\Halt` para
abortar sin propagar la excepción. Verificado con test Livewire
(`CreateTerminPageTest`) y manualmente en el navegador (banner "No se pudo
reservar la cita — Slot no disponible").

## Mejora: mensajes de error específicos y selector de horarios disponibles

A raíz de una confusión real del usuario (formato de fecha día/mes vs
mes/día del `DatePicker` de Filament, que hizo pensar que un asesor no
tenía disponibilidad cuando en realidad se había elegido el día de la
semana equivocado), se mejoró la UX de `TerminResource`:

- `SlotAvailabilityServiceInterface` gana dos métodos: `unavailabilityReason()`
  (motivo por el que un DÍA completo no tiene disponibilidad: asesor
  inactivo, festivo con nombre, vacaciones, día fuera del horario semanal)
  y `slotUnavailabilityReason()` (motivo específico de un slot: los
  anteriores + "fuera de horario laboral" + "no coincide con la cuadrícula
  de 30 min" + "ya reservado por otra cita"). `TerminBookingService` usa
  el segundo en el mensaje de la `ValidationException`, en vez del genérico
  "Slot no disponible".
- El campo único `starts_at` (DateTimePicker) del formulario se sustituyó
  por `starts_at_date` (DatePicker) + `starts_at_time` (Select reactivo,
  con las opciones calculadas en vivo vía `getAvailableSlots()` según el
  asesor y la fecha elegidos, y un `helperText` con `unavailabilityReason()`
  cuando no hay ninguna hora disponible ese día). Ambos se combinan en
  `starts_at` en `CreateTermin::handleRecordCreation()` y
  `EditTermin::mutateFormDataBeforeSave()` antes de persistir. Esto evita
  que un gestor elija a ciegas una fecha/hora inválida.
- Nota técnica: el campo `starts_at_time` usa Choices.js (`->native(false)`)
  igual que "Terapeuta"; sus opciones no aparecen en el `<select>` nativo
  del DOM hasta que se abre el desplegable — al inspeccionar con
  herramientas automatizadas hay que abrir el dropdown, no solo leer
  `select.options`.

Este archivo se revisa con Verena antes del UAT (F9). Si algún supuesto está mal, se corrige antes de F9.
