# HelpMI — Contexto para agentes IA

## Qué es este proyecto
Reimplementación de una plataforma de gestión de consultas de microinmunoterapia
para MeGeMIT (DACH). El sistema original es Concloo CMS / IBM Lotus Domino.
No hay acceso al código fuente original (sí hubo, puntualmente en F3.5, acceso de
solo lectura al panel de admin de producción para una auditoría de paridad —
ver docs/supuestos-F3.5.md — sin tocar ni conservar datos de producción).

## Fase activa
F3 completada · Portal público de reserva (token HMAC, sin sesión).
F6 (i18n del panel admin) adelantado sobre F0-F2B — ver docs/supuestos-F6.md.
F5 completada · Estadísticas e informes: dashboard Filament `Statistiken`
(filtro de periodo, desgloses por asesor/país/Hauptthema, top 10 productos,
ratio socios/no-socios, solo `admin`) + exportación XLSX de 78 columnas
(`App\Exports\TerminExport`, headers verificados 1:1 contra el CSV de
producción) — ver docs/supuestos-F5.md.
F3.5 añadida al plan tras auditoría de paridad contra producción
(admin.mgm.concloo.net, 2026-08-03) — ver docs/supuestos-F3.5.md. Todos
los gaps que NO dependían de Verena ya están implementados y testeados
(71/71 tests, Pint + PHPStan nivel 6 limpios, verificado visualmente en
el navegador): 5 países (DE/AT/CH/IT/SK, `country` ahora VARCHAR
validado en Filament en vez de ENUM fijo — ver migraciones
2026_08_03_190001/3), 19 Hauptthemen reales (antes 14, ver
KategorieSeeder), 7 Berater reales (antes 3), campo `requiere_freitextfeld`
por Unterkategorie con etiqueta dinámica de `weitere_angaben` (admin y
portal), `divers` como Geschlecht válido, 14 campos de anamnesis
ampliada en `Termin` (solo en admin, sección "Erweiterte Anamnese" —
NO añadidos aún al wizard del portal ni al export XLSX, pendiente
confirmar con Verena), tercer campo `notas_tratamiento_interno`
(distinto de `interner_kommentar`), y en Therapeut: 4 emails + `anrede`
+ `login_name` (primera ronda) y `betreuer_asignado` + `fachrichtung` +
`akademischer_grad` + `spezialisierung` + `ausbildungsstand` (segunda
ronda, 2026-08-03 — detectados por el cliente al revisar producción
directamente, se me habían quedado sin documentar la primera vez). Ojo:
`betreuer_asignado` es un Betreuer PERSISTENTE por Therapeut ("Betreut
durch"/"assigned to" en producción), distinto de `termins.betreuer_name`
(texto libre por-cita, asumido desde F1) — ambos campos conviven sin
resolver todavía cuál es el modelo correcto (ver docs/preguntas-Verena.md
pregunta 12b). Todo esto es solo estructura, sin lógica de negocio — eso
es F4. Quedan sin implementar (ligados a la pregunta bloqueante del
límite de citas): `Aktive Fälle`, `max-Termine`, `max`, `block-new`; y
sin confirmar el propósito de `new-date` (booleano).
El límite real de citas ("casos activos por terapeuta" en vez de
"2/día" F2B o "total no-socio" F3) queda EXPLÍCITAMENTE APARCADO a
petición del cliente — NO se ha tocado la lógica síncrona de F2B/F3,
sigue pendiente de confirmación de Verena sin fecha. Tampoco se ha
tocado: vista de documentos en admin (F4), acción "Termin verschieben"
(pendiente semántica de Fallnummer), campo "Fallnummer Referenz",
integración solve360 viva, verificación de membresía vía WordPress,
modelo de Slots discretos, ni las reglas de retención/purga (F8).
Además, de un grupo de ajustes vistos en `Einstellungen → Allgemein` de
producción que se detectó en la auditoría inicial pero se quedó SIN
evaluar formalmente hasta una revisión posterior (no fue una decisión
deliberada de alcance, a diferencia del resto de puntos de esta lista),
ya se implementaron 2: `Einstellung.antelacion_minima_dias` y
`Einstellung.max_dias_antelacion_global` (este último acorta pero nunca
amplía el `advance_days` de cada Berater), wireados en
`SlotAvailabilityService::unavailabilityReason()` y testeados. De paso
se descubrió y corrigió que `advance_days` (campo de F2B) nunca se
aplicaba en ningún sitio. Quedan sin implementar el tope de slots
ofrecidos (sin equivalente en nuestra arquitectura) y el offset de la
agenda "Spezi" (depende de una funcionalidad que no existe). Ver
docs/supuestos-F3.5.md para el detalle completo antes de tocar
cualquiera de estos puntos.
F3.5 (tercera revisión, 2026-08-03): el cliente pidió revisar de nuevo
la ficha de Therapeut y luego el formulario de EDICIÓN COMPLETO de
Berater (no solo el listado, que fue lo único revisado la primera vez)
— ambas rondas encontraron campos reales que se habían quedado sin
documentar. En Therapeut: `betreuer_asignado` ("Betreut durch"/"assigned
to", persistente por Therapeut — OJO, distinto de `termins.betreuer_name`
que es por-cita, ver docs/preguntas-Verena.md pregunta 12b), `fachrichtung`,
`akademischer_grad`, `spezialisierung`, `ausbildungsstand`. En Berater:
`color` (calendario), `auto_logout_enabled`+`auto_logout_minutos`
(middleware `EnforceAutoLogout`, NO el `SESSION_LIFETIME` global de
Laravel), `force_password_change` (vive en `users`, middleware
`EnforcePasswordChange` + `App\Filament\Pages\Auth\EditProfile` custom
que limpia el flag al cambiar contraseña). Bug real encontrado y
corregido: `Carbon::diffInMinutes()` no siempre devuelve valor absoluto
— sin `abs()` el auto-logout nunca habría cerrado ninguna sesión. Dado
que esta ya es la SEGUNDA vez que una revisión adicional encuentra gaps
reales, ante cualquier duda sobre si "ya se revisó todo" en una entidad,
verificar explícitamente que se abrió el FORMULARIO DE EDICIÓN completo
en producción, no solo la lista — ver docs/supuestos-F3.5.md.
F3.5 (cuarta revisión, 2026-08-03): revisando Termin y Feiertag a fondo,
se encontró que **"Kommentar zum Termin" (comentario visible sobre la
cita) llevaba documentado desde la primera auditoría pero NUNCA se había
implementado** — se confundió por error con `interner_kommentar` al
mapear los campos. Causa raíz: documentar un campo no es lo mismo que
verificarlo contra el HTML real del formulario; a partir de ahora, tras
documentar un campo nuevo, confirmar que existe una migración/campo real
que lo implemente, no solo una entrada en supuestos-FN.md. Ya corregido:
`termins.kommentar_termin` (migración `2026_08_03_210003`), visible en
`TerminResource` sección "Buchung". Además se detectaron 2 diferencias
de modelado NO implementadas, ambas documentadas como preguntas a
Verena, sin tocar código: (a) producción distingue "Vom Therapeut
abgesagt" como estado independiente de Erledigt/Aktiv, nosotros solo
tenemos un enum `status` que no registra quién cancela; (b) producción
NO tiene ningún campo de país en Feiertag (lista plana, 12 registros),
mientras nosotros sí filtramos festivos por país del asesor — esto
podría significar que nuestra arquitectura ya es una MEJORA sobre
producción, no una divergencia a corregir; pendiente confirmar con
Verena si producción realmente respeta festivos por país o los aplica
a todos por igual. Ver docs/supuestos-F3.5.md y
docs/preguntas-Verena.md (preguntas 10, 10b, 10c).
F3.5 (quinta revisión): Medikament/Kategorie (Hauptthema/Unterkategorie/
Präparate) revisados a fondo — SIN gaps reales, mismo hallazgo que
Feiertag/país (nuestro `sort_order` no tiene equivalente editable en
producción, pero es un plus nuestro, no una carencia).
F3.5 (sexta revisión, gap ESTRUCTURAL, no solo de campos): el cliente
señaló `/Therapeut/{Land}/Termin/{code}` como sección sin réplica.
Confirmado: `TherapeutResource::getRelations()` estaba vacío desde F2A
— producción SÍ tiene una sección "Termine" dentro de cada Therapeut
(lista de sus citas + botón añadir). Implementado
`TerminsRelationManager` (enlaza a las páginas ya existentes de
`TerminResource`, no duplica lógica de creación/edición — la creación
sigue pasando por `TerminBookingService::book()`). 2 bugs reales
corregidos tras verificación visual: título sin traducir ("Termins" en
vez de "Termine") y badge de estado mostrando el enum crudo en vez de
la etiqueta traducida (`TerminResource::statusOptions()` tuvo que
pasar de `private` a `public` para reutilizarse). Patrón a repetir: si
el usuario dice "no encuentro X en nuestra versión" y la URL de
producción tiene una estructura anidada (`/EntidadA/.../EntidadB/...`),
sospechar de un RelationManager faltante, no solo de campos — revisar
`getRelations()` de la entidad padre antes de descartarlo.
F3.5 (séptima revisión, 2026-08-03): el campo "Unterlagen" (adjuntar
ficheros) de la ficha de Termin en producción no tenía réplica en el
admin — la subida de documentos solo existía en el portal público (F3).
Instalado `filament/spatie-laravel-media-library-plugin` (nueva
dependencia, confirmada con el cliente antes de añadirla) sobre la
colección `documentos` ya existente en `Termin` (disco `local` privado).
2 bugs reales encontrados y corregidos durante la verificación con
subida real de archivos en el navegador: (1) el generador de URL por
defecto del plugin asumía disco público y producía un enlace de
descarga roto (403 con y sin sesión) — corregido con
`getUploadedFileUsing()` propio + `URL::temporarySignedRoute()` +
`TerminMediaDownloadController` protegido por `TerminPolicy::view()`;
(2) **bug preexistente, no introducido aquí**: cualquier ruta fuera del
enrutado de Filament protegida solo por el middleware `auth` desnudo
devolvía 500 en vez de redirigir al login (Laravel intentaba resolver
la vista de login de Fortify, que este proyecto no usa) — afectaba ya a
`/gestion/ping` desde F1/F2A. Corregido globalmente con
`$middleware->redirectGuestsTo('/admin/login')` en `bootstrap/app.php`.
Cierra el punto ya señalado en docs/supuestos-F3.md ("URL firmada para
documentos"). Ver docs/supuestos-F3.5.md para el detalle completo.
F4 completada · Emails automáticos y Scheduler — ver docs/supuestos-F4.md.
Eventos `AppointmentCreated`/`AppointmentCancelled` añadidos (junto al ya
existente `AppointmentCompleted` de F5), disparados desde los puntos
únicos ya establecidos (`TerminBookingService::book()` y las 2 acciones
de cancelar en admin/portal). Listeners encolados en `app/Listeners`
(`SendBookingConfirmation`, `SendNonMemberWarning`) registrados por
auto-discovery de Laravel 11 — **no** registrar manualmente con
`Event::listen()`, duplica el envío (bug real encontrado y corregido en
F4, ver docs/supuestos-F4.md). 3 Jobs programados vía
`bootstrap/app.php`→`withSchedule()`: `SendAdvisorAgendaJob` (07:00,
días de antelación configurables por asesor vía `Berater.agenda_dias_antes`),
`SendReminderJob` (08:00, idempotente vía `Termin.recordatorio_enviado_at`),
`GenerateMonthlyReportJob` (día 1 de cada mes, reutiliza `TerminExport`
de F5 — genera y almacena el XLSX en disco privado, **no lo envía por
email a nadie todavía**: destinatario pendiente de Verena). Emails solo
en alemán (`lang/de/emails.php`), sin equivalente en español — mismo
criterio que el portal público de F3. `config/queue.php`: conexión
`redis` con `after_commit => true` (los listeners encolados no deben
procesarse antes de que la transacción de `TerminBookingService::book()`
haga commit).
Siguiente: F7 (arquitectura HubSpot) o F8 (testing avanzado y
seguridad) — quedan pendientes de decidir con Verena varios puntos de
F3.5/F4 antes de F9 (UAT), ver docs/supuestos-F3.5.md y
docs/supuestos-F4.md.
Importación de datos históricos (adelantada de F10, 2026-08-04): el
cliente aportó `backup/` con 2 exports DXL reales (Domino/Notes) —
Therapeut (4.193 docs) y Termine (3.787 docs) — más 3 ZIP (~10.4 GB) de
adjuntos sin procesar. `php artisan import:therapeuten {ruta} [--dry-run]`
construido y verificado en seco contra el fichero real (sin fugas de PII
en consola, `LGNCODE` nunca leído) — **ejecutado contra la BD local de
desarrollo a petición explícita del usuario: 4.193 Therapeuten creados,
0 omitidos**. Bug real encontrado y corregido durante esa ejecución:
`vorname`/`nachname` son `NOT NULL` pero el importador convertía
valores vacíos a `null` de forma uniforme — abortó a mitad (dentro de
una única transacción, revirtiendo ~2.500 registros ya procesados) por
un documento con solo `nachname`, sin `vorname`; corregido para usar
`''` en esas dos columnas. Se corrigió de paso otro bug real: el
formato de Fallnummer era `AAAA-NNNN` (nunca confirmado contra datos
reales); producción usa un contador plano secuencial —
`TerminBookingService::generateFallnummer()` ya corregido y testeado.
**Bloqueante de Berater/Slots RESUELTO (2026-08-04)**: el cliente
entregó `Berater.dxl` (8 docs) y `Slots.dxl` (109 docs) — las
referencias UNID `berater`/`termin` de Termine.dxl casan 7/7 y 99/99
respectivamente. Construidos e importadores verificados en seco contra
los DXL reales: `import:berater-unids` (enlaza los 7 Beraters ya
sembrados con su UNID, por nombre) e `import:termine` (resuelve
therapeut_id/berater_id/starts_at/ends_at + 19 campos de anamnesis
directos). El epoch del serial de fecha de `termin` (Lotus/Domino,
1899-12-30) se verificó contrastándolo con la fecha `created` real de
los documentos, no es una suposición sin contrastar. **Bloqueante de Hauptthema/Unterkategorie RESUELTO (2026-08-04, sin
DXL)**: `hauptthema`/`ukategorie` son también UNID, pero Domino no
ofrece exportación para esa base (confirmado por el gestor de la
plataforma). Se resolvió leyendo `Einstellungen → Hauptthema/
Unterkategorie` en producción (mismo acceso de solo lectura de F3.5,
Playwright) y extrayendo el UNID de la URL de cada entrada — no es un
export DXL, es `database/data/kategorie_legacy_unids.json` (nombres de
categoría, no PII). Verificado 18/18 y 91/91 contra las referencias
reales de Termine.dxl. Nuevo comando `import:kategorie-unids`. **Gap
de contenido descubierto de paso, ya CERRADO (2026-08-04)**:
`KategorieSeeder` solo tenía 51/100 Unterkategorien reales de
producción — la quinta revisión de F3.5 dio "sin gaps" sin comparar
contra el total paginado real. Ampliado a 103 (100 reales + 3 "nicht
definiert" propias, sin equivalente en producción), con el valor real
de `requiere_freitextfeld` de cada una.
**Cadena completa EJECUTADA con éxito contra la BD local (2026-08-04, a
petición explícita del usuario)**: `import:therapeuten` (4.193
Therapeuten), `import:berater-unids` (7/7) e `import:kategorie-unids`
(19/19 + 100/100), y finalmente `import:termine` → **3.553 de 3.787
Termine históricos creados** (11/82/141 omitidos por datos reales
faltantes/placeholder en producción, no por fallos del importador). 4
bugs reales de constraint de columna encontrados y corregidos durante
la ejecución real (el `--dry-run` no los detecta, no llega a ejecutar
el INSERT): `vorname`/`nachname`/`fallnummer` NOT NULL recibiendo
`null`; el placeholder `"Bitte auswählen"` de los desplegables de
producción colándose como valor real; `gewicht`/`groesse` con coma
decimal alemana sin parsear; y `ernaehrungsrichtung` con `string`(255)
en vez de `text` (bug de schema real de F3.5, no solo del importador —
migración `2026_08_04_130001`). Ver docs/supuestos-F3.5.md para el
detalle completo de cada bug.
Ver docs/supuestos-F3.5.md § "Importación de datos históricos" para el
detalle completo antes de tocar esto.
F7 completada · Arquitectura CRM (solo arquitectura, sin llamadas HTTP
reales — ver docs/supuestos-F7.md). `CrmAdapterInterface` +
`NullCrmAdapter` (activo por defecto) + `HubSpotCrmAdapter` (esqueleto,
lanza `NotImplementedException`), controlado por `CRM_ADAPTER=null|hubspot`
(`config/services.php`, binding condicional en `AppServiceProvider`).
`SyncToCrmJob` (dos named constructors: `forContact()`/`forActivity()`)
consume 5 eventos de dominio vía listeners con auto-discovery (mismo
patrón de F4 — `SyncAppointmentToCrm`, `SyncTherapistToCrm`, NUNCA
registrar manualmente con `Event::listen()`): `AppointmentCreated`/
`Completed`/`Cancelled` (ya existían) + `TherapistCreated`/
`TherapistMembershipChanged` (nuevos, disparados desde
`CreateTherapeut`/`EditTherapeut` — este último solo si
`wasChanged('mitglied')`, no en cada edición).
CRM real CONFIRMADO por el cliente (2026-08-04): **HubSpot** (pregunta 4
de docs/preguntas-Verena.md, ya resuelta). La implementación HTTP real
(auth, mapeo de campos, sincronización) sigue SIN construir — F7 solo
dejó el esqueleto (`HubSpotCrmAdapter` lanza `NotImplementedException`),
tal como marcaba el plan como fuera de alcance de esa fase. Es trabajo
nuevo, sin fase asignada todavía. Pendiente con Verena: qué pasa con la
integración solve360 que producción usa hoy en vivo (¿convivencia
durante transición, o corte directo?) antes de empezar. Ver
docs/supuestos-F7.md.
F8 en curso · checklist de seguridad. Completados y testeados: cabeceras
de seguridad HTTP (`App\Http\Middleware\SecurityHeaders`, registrada en
`bootstrap/app.php` Y en `AdminPanelProvider` — Filament tiene su propio
stack de middleware, no basta con el grupo `web` global; elimina
`X-Powered-By`, añade `Referrer-Policy`/`X-Content-Type-Options`) y 2FA
(`pragmarx/google2fa-qrcode`, `App\Services\TwoFactorAuthService`,
páginas Filament `TwoFactorSettings`/`Auth\TwoFactorChallenge`,
middleware `RequireTwoFactorChallenge` en `authMiddleware()`). Bug real
encontrado: `User::$fillable` no incluía `google2fa_secret`/
`google2fa_enabled`, así que `$user->update([...])` los descartaba en
silencio — corregido. Ver docs/supuestos-F8.md. Pendiente dentro de F8:
Sentry/DSGVO log scrubbing (bloqueado por cuenta/DSN del cliente), tests
k6, tests Dusk E2E.
Despliegue a entorno "dev" (2026-08-13): documentado en
docs/despliegue-dev.md un procedimiento manual de despliegue a un VPS con
panel (cPanel/Plesk) para que el equipo pruebe la plataforma — distinto
del plan de producción de F0 (Forge/S3), que sigue sin ejecutarse (F10).
El entorno dev arranca solo con los seeders base (roles, Hauptthemen,
Medikamente, Berater, Feiertage), sin el histórico real importado
(`storage/app/private` 11 GB + `backup/` 12 GB no se suben). Añadida
`predis/predis` como dependencia (cliente Redis en PHP puro, sin depender
de la extensión nativa `phpredis` del hosting).
Ejecutado por primera vez contra hosting real OVH mutualisé (2026-08-14,
ver docs/despliegue-dev.md § 1.1): sin `composer` ni Node 18+ en el
servidor (build de Vite se hace en local y se sube por `rsync`), y
**`composer.json` fija `config.platform.php: "8.2.31"` de forma
permanente** — varios paquetes (`openspout`, `maennchen/zipstream-php`,
`spatie/laravel-translatable`) habían derivado a requerir PHP 8.3 en el
lock file pese a que local/CI siempre corrieron en 8.3 real sin detectarlo;
el multihost de OVH sí permite PHP 8.3 pero se descartó a propósito por
afectar a otros sitios en la misma cuenta compartida. Cualquier
`composer update` futuro (en cualquier entorno) queda limitado a paquetes
compatibles con PHP 8.2 mientras este pin siga activo — revisar si cambia
el hosting.
Sin Redis contratado en OVH (solo MySQL) — el entorno dev usa
`SESSION_DRIVER=database`/`CACHE_STORE=database`/`QUEUE_CONNECTION=database`.
Bug real corregido de paso: `config/queue.php` tenía `after_commit` fijo
en `false` para la conexión `database` (a diferencia de `redis`, que ya lo
tenía en `true` desde F4) — sin esto, los listeners encolados de
`TerminBookingService::book()` podían ejecutarse antes del commit de la
transacción. Ahora es `env('DB_QUEUE_AFTER_COMMIT', false)`, activado a
`true` solo en el `.env` de dev — el valor por defecto no cambia en el
resto de entornos.

## Entorno local
- ServBay (macOS): vhost Apache en `helpmi.loc` → `public/`, PHP 8.3-FPM.
- MySQL 8.4 (`helpmi` DB, usuario `helpmi`), Redis (cache/session/queue DB 1).
- `.env.testing` usa SQLite in-memory (tests de integración, ver plan § 4).

## Reglas absolutas
- NUNCA modificar migraciones ya ejecutadas en staging o producción.
- NUNCA hardcodear strings en vistas — siempre __() o trans(). Esta regla se
  incumplió en F2A/F2B y se corrigió retroactivamente en F6-adelantado; a
  partir de ahora es innegociable en TODO código nuevo del panel admin
  (Resources, Pages, RelationManagers, Services con mensajes de usuario).
  Cada string nuevo va a lang/de/admin.php Y lang/es/admin.php en el MISMO
  commit — no se difiere a una fase F6 posterior.
- NUNCA almacenar datos personales de pacientes en logs.
- NUNCA implementar funcionalidad fuera del alcance de la fase activa.
- NUNCA crear Jobs para validaciones que deben ser síncronas (ver F2B).
- El límite de 2 citas por terapeuta por día es SÍNCRONO — no un job.

## Decisiones de arquitectura ya tomadas
- SlotAvailabilityService: interface en F1 (`App\Contracts\SlotAvailabilityServiceInterface`),
  implementación real `App\Services\SlotAvailabilityService` desde F2B (stub de F1 eliminado).
  Slots de 30 min, respeta festivos/vacaciones/horario del asesor (`weekly_schedule` + `start_time`/`end_time`),
  antelación mínima (`Einstellung.antelacion_minima_dias`) y ventana máxima de reserva
  (`min(Berater.advance_days, Einstellung.max_dias_antelacion_global)`, F3.5 — ver docs/supuestos-F3.5.md).
- Límite de 2 citas por terapeuta por día: SÍNCRONO, general (no solo no-socios), por terapeuta
  independiente del asesor (ver docs/supuestos-F2B.md — pendiente confirmar con Verena).
- Creación de Termin: SIEMPRE vía `App\Services\TerminBookingService::book()`, nunca
  `Termin::create()` directo — genera Fallnummer/FAX-Code y valida límite+disponibilidad
  dentro de una transacción con locks (mutex en fila singleton de `einstellungen` + lock
  de la fila `Berater`) para evitar condiciones de carrera.
- `TerminPolicy`: `berater` solo ve/edita sus propias citas o las del asesor al que sustituye
  activamente (`Berater::substitutedVacations()`). Scope real en `TerminResource::getEloquentQuery()`,
  no solo en la Policy — un acceso cruzado devuelve 404 (no 403) porque la query ya excluye el registro.
- `interner_kommentar`: editable por `admin` y por el `berater` asignado/sustituto (pendiente
  confirmar con Verena si debería ser solo admin).
- Campos de "Formación recomendada" en `Termin` (Grundlagenseminar, A1, A2, Lyty, Serologien,
  Bestimmte Pathologien, Weitere empfohlene Hilfen) tomados literalmente de helpmi-spec.html,
  no forman parte de las 78 columnas del CSV/export XLSX (F5).
- CRM: NullCrmAdapter activo por defecto. HubSpotCrmAdapter es un esqueleto.
- Portal (F3): autenticación por token HMAC-SHA256 (`App\Services\TherapistTokenService`),
  sin sesión Laravel. Formato: `base64url(email).timestamp.hmac`. `resolveTherapeut()` es
  el punto único reutilizado por `VerifyPortalToken` (petición HTTP real) y por
  `BuchenWizard::mount()` (fallback para `Livewire::test()`, que no ejecuta middleware).
  Ruta `/de/buchen` con prefijo fijo (NO usar `LaravelLocalization::setLocale()` como
  prefijo dinámico — solo resuelve el segmento en el momento de registrar rutas, falla
  en procesos persistentes como tests/Octane; ver docs/supuestos-F3.md).
- Portal: `BuchenWizard` (asistente de reserva: calendario+anamnesis+documentos+confirmación
  en un único componente Livewire) + `AppointmentManagement` (separado, solo cancelar —
  "modificar" fuera de alcance F3). Documentos vía `spatie/laravel-medialibrary`
  (colección `documentos` en `Termin`, disco `local` privado). Reutiliza
  `TerminBookingService` de F2B — no hay lógica de creación de citas duplicada.
- Límite no-socio en portal: TOTAL de citas acumuladas (`Einstellung.limite_citas_no_socio`),
  distinto del límite síncrono de 2/día de F2B (que es general, no solo no-socios).
- Slots: duración fija 30 min. CSV muestra duración real de llamada, no del slot.
- i18n del PORTAL PÚBLICO (F3, iframe): alemán (de) es la única lengua,
  rutas con prefijo /{locale}/ vía mcamara/laravel-localization. Sin selector
  es/de — decisión explícita del cliente, el contenido del iframe es alemán.
- i18n del PANEL ADMIN (F6 adelantado sobre F0-F2B, ver docs/supuestos-F6.md):
  selector de/es en el menú de usuario de Filament (`AdminPanelProvider::userMenuItems()`),
  persistido en `users.locale` (enum de/es, default de) vía middleware
  `App\Http\Middleware\SetLocaleFromUser` (en `authMiddleware`) y ruta
  `GET /admin/locale/{locale}` (routes/web.php). Traducción ÍNTEGRA sin
  excepciones — ni siquiera términos de dominio como Hauptthema/Betreuer/
  Fallnummer/DSGVO quedan sin traducir en la vista es (decisión explícita
  del cliente, ver docs/supuestos-F6.md). Claves en lang/de/admin.php y
  lang/es/admin.php, agrupadas por resource (therapeut, berater, medikament,
  kategorie, feiertag, einstellungen, termin, slots, vacation, pais, nav).
- Betreuer: SIN login. Campo de texto `termins.betreuer_name` (pendiente confirmar con Verena,
  ver docs/supuestos-F1.md). No crear tabla/rol `betreuer` hasta que se confirme.
- Los 61 productos por cita se guardan en tabla pivote `termin_medikament`
  (no como 61 columnas booleanas físicas, a diferencia del CSV/export XLSX de F5).
- 2FA: `pragmarx/google2fa-laravel`, NO el feature nativo de Fortify (deshabilitado
  explícitamente en config/fortify.php para respetar el stack exacto).
- Roles Spatie Permission ya sembrados: `admin`, `berater`. `betreuer` no existe (ver arriba).
- Middleware `role` (Spatie) registrado como alias en bootstrap/app.php.
  Aplicado en routes/admin.php, no solo en controladores.
- `User` implementa `Filament\Models\Contracts\FilamentUser::canAccessPanel()`
  (solo admin/berater). SIN esto, Filament deniega el acceso a TODOS los
  usuarios autenticados fuera de APP_ENV=local — bug real encontrado en F2A.
- Resources F2A (Therapeut, Berater, Medikament, Kategorie, Feiertag) protegidos
  con Policies `before()` → solo `admin`. `EinstellungenPage` con `canAccess()` propio.
- Configuración global: tabla `einstellungen` (fila única/singleton), NO
  `spatie/laravel-settings` (fuera del stack exacto).

## Stack exacto (no sugerir alternativas)
Laravel 11, Filament 3, Livewire 3, MySQL 8.0, Redis,
Pest (tests), Laravel Dusk (functional), k6 (performance),
Spatie Permission, Spatie Media Library, Spatie Translatable,
mcamara/laravel-localization, pragmarx/google2fa-laravel,
Laravel Fortify, maatwebsite/excel,
filament/spatie-laravel-media-library-plugin (añadido F3.5, gestión de
documentos en TerminResource — ver docs/supuestos-F3.5.md),
predis/predis (añadido para el despliegue dev — ver docs/despliegue-dev.md,
cliente Redis en PHP puro para no depender de la extensión nativa
`phpredis` del hosting).

## Calidad de código
- Laravel Pint (`./vendor/bin/pint`) antes de cada commit.
- PHPStan nivel 6 (`./vendor/bin/phpstan analyse`) sin errores.
- Pest (`./vendor/bin/pest`) — tests escritos antes que el código de producción.

## Fuente de verdad
La especificación completa está en docs/helpmi-spec.html.
El plan de implementación por fases está en docs/helpmi-planificacion.md.
Ante cualquier ambigüedad, consultar la especificación antes de asumir.

## Preguntas pendientes con Verena Heufler
17 preguntas bloqueantes antes de F2B y F3 (ver docs/helpmi-spec.html).
En particular: rol de Betreuer (¿tiene login?), textos de emails,
lógica del límite de 2 citas, y visibilidad entre asesores.
