# Contrato técnico del módulo DOFA v2 (fase Base)

Versión 1.0 · 3 de septiembre de 2026 · Complementa `docs/ESPECIFICACION_DOFA_v2.md` (que manda en caso de duda).

Este documento fija los nombres exactos que los demás agentes (Backend DOFA, Frontend DOFA, Acceso, Insumos) deben usar: tablas, modelos, columnas, constantes, el motor de cálculo, las rutas con sus nombres y props, la resolución de "sesión activa" y las reglas de autorización.

Estado de lo entregado por Base: migraciones ejecutadas en MySQL local, rol y permisos sembrados, modelos, motor `DofaCalculadora` con pruebas verdes (`php artisan test --filter Dofa`), `routes/dofa.php` y `routes/acceso.php` registrados y vacíos.

---

## 1. Tablas, modelos y columnas

Todos los modelos están en `App\Models`, con `$table` explícito. Claves foráneas: `cascadeOnDelete`, salvo las que apuntan a `users` (`nullOnDelete`) y `dofa2_aportes.factor_id` (`nullOnDelete`, para que borrar un factor borrador no borre los aportes crudos; al borrarlo el aporte queda con `factor_id = null` y conserva su `estado`, el servicio debe volverlo a `activo` si corresponde).

| Modelo | Tabla | Columnas (además de `id`, `timestamps`) |
|---|---|---|
| `Dofa2Session` | `dofa2_sesiones` | `project_id`, `nombre` (150), `fase` enum, `config` json, `abierta_en`, `cerrada_en`, `created_by` |
| `Dofa2Participant` | `dofa2_participantes` | `sesion_id`, `user_id`, `rol_en_sesion` enum('participante','facilitador','observador'), `aporte_enviado_en`, `calificacion_enviada_en`, `cruces_enviados_en`, `tendencias_enviadas_en`, `ultimo_visto_en`. Única (`sesion_id`,`user_id`) |
| `Dofa2Contribution` | `dofa2_aportes` | `sesion_id`, `user_id`, `categoria` enum(F,D,O,A,T), `texto` (300), `estado` enum('activo','fusionado','descartado'), `factor_id` nullable |
| `Dofa2Factor` | `dofa2_factores` | `sesion_id`, `categoria`, `codigo` (10, nullable hasta publicar), `texto` (200), `detalle` text nullable, `orden` uint, `estado` enum('borrador','aprobado'), `fuente` enum('ia','consultor','banco_2024','entrevistas','aporte'), `origen` json, `created_by` |
| `Dofa2Rating` | `dofa2_calificaciones` | `sesion_id`, `factor_id`, `user_id`, `importancia` tinyint nullable, `calificacion` tinyint nullable, `prioridad` tinyint nullable, `peso_normalizado` decimal(8,6) nullable. Única (`factor_id`,`user_id`) |
| `Dofa2Cross` | `dofa2_cruces` | `sesion_id`, `factor_interno_id`, `factor_externo_id`, `user_id`, `calificacion` tinyint. Única (`factor_interno_id`,`factor_externo_id`,`user_id`) |
| `Dofa2TrendVote` | `dofa2_tendencias_votos` | `sesion_id`, `user_id`, `factor_id`. Única (`user_id`,`factor_id`) |
| `Dofa2Consolidation` | `dofa2_consolidaciones` | `sesion_id`, `skill_version` (40), `modelo` (80), `prompt` longtext, `respuesta_cruda` longtext, `propuesta` json, `estado` enum('propuesta','aplicada','descartada'), `tokens_entrada`, `tokens_salida`, `created_by` |
| `Dofa2Result` | `dofa2_resultados` | `sesion_id`, `version` uint, `calculado_en`, `datos` json (sección 8), `analisis_ia` longtext nullable, `participantes_incluidos` json. Única (`sesion_id`,`version`) |
| `InsumoDiagnostico` | `insumos_diagnostico` | `project_id`, `tipo` enum('entrevistas','banco_2024','cifras','tendencias_sugeridas'), `titulo` (200), `contenido` json, `orden` uint, `visible_participantes` bool |

`users` (columnas nuevas): `codigo_acceso` string(6) único nullable, `token_acceso` string(64) único nullable, `acceso_expira_en` timestamp nullable, `cargo` string(120) nullable, `es_participante_retiro` bool default false. Todas están en `$fillable`. `codigo_acceso` y `token_acceso` están en `$hidden`: para mostrarlas usar `$user->makeVisible(['codigo_acceso', 'token_acceso'])`.

Las columnas nullables de `dofa2_calificaciones` existen porque `dofa.calificaciones.store` hace upsert parcial (autoguardado). El motor ignora, por métrica, los valores nulos.

### 1.1 Constantes

`Dofa2Session`
- Fases: `FASE_CONFIGURACION`, `FASE_APORTE`, `FASE_CONSOLIDACION`, `FASE_CALIFICACION`, `FASE_CRUCES`, `FASE_CERRADA`; `FASES` (lista ordenada); `ETIQUETAS_FASE` (texto en español).
- `MODO_CRUCE_INDIVIDUAL = 'individual'`, `MODO_CRUCE_PLENARIA = 'plenaria'`.
- `CONFIG_POR_DEFECTO`: `factores_por_categoria` 7, `modo_cruce` individual, `cruces_limitados_a` null, `mostrar_banco_2024` true, `mostrar_insumos` true, `min_aportes_por_categoria` 1, `max_aportes_por_categoria` 5, `permitir_editar_tras_enviar` false, `acceso_horas` 72, `historial_fases` [].

`Dofa2Participant`: `ROL_PARTICIPANTE`, `ROL_FACILITADOR`, `ROL_OBSERVADOR`, `ROLES`, `COLUMNA_ENVIO` (fase => columna; clave extra `'tendencias'`).

`Dofa2Contribution`: `ESTADO_ACTIVO`, `ESTADO_FUSIONADO`, `ESTADO_DESCARTADO`, `ESTADOS`, `MAX_TEXTO = 300`.

`Dofa2Factor`: `CAT_FORTALEZA` 'F', `CAT_DEBILIDAD` 'D', `CAT_OPORTUNIDAD` 'O', `CAT_AMENAZA` 'A', `CAT_TENDENCIA` 'T'; `CATEGORIAS` (F,D,O,A,T), `CATEGORIAS_INTERNAS`, `CATEGORIAS_EXTERNAS`, `CATEGORIAS_CALCULABLES` (F,D,O,A); `ETIQUETAS_CATEGORIA`; `CALIFICACIONES_VALIDAS` (F y O => [3,4]; D y A => [1,2]); `ESTADO_BORRADOR`, `ESTADO_APROBADO`, `ESTADOS`; `FUENTE_IA`, `FUENTE_CONSULTOR`, `FUENTE_BANCO_2024`, `FUENTE_ENTREVISTAS`, `FUENTE_APORTE`, `FUENTES`; `MAX_TEXTO = 200`.

`Dofa2Rating`: `IMPORTANCIA_MIN = 1`, `IMPORTANCIA_MAX = 10`, `PRIORIDADES = [0,1,3,6,9]`, `ETIQUETAS_PRIORIDAD`.

`Dofa2Cross`: `ESCALA = [0,1,3,6,9]`, `ETIQUETAS_ESCALA`.

`Dofa2TrendVote`: `MAX_POR_PARTICIPANTE = 3`.

`Dofa2Consolidation`: `ESTADO_PROPUESTA`, `ESTADO_APLICADA`, `ESTADO_DESCARTADA`, `ESTADOS`.

`InsumoDiagnostico`: `TIPO_ENTREVISTAS`, `TIPO_BANCO_2024`, `TIPO_CIFRAS`, `TIPO_TENDENCIAS_SUGERIDAS`, `TIPOS`.

`Database\Seeders\Dofa2RolesPermissionsSeeder`: `ROL_PARTICIPANTE`, `PERMISO_PARTICIPAR = 'participar_dofa'`, `PERMISO_FACILITAR = 'facilitar_dofa'`, `ASIGNACIONES`.

### 1.2 Relaciones y métodos

`Dofa2Session`
- Relaciones: `project()`, `creador()`, `participantes()` (hasMany `Dofa2Participant`), `usuarios()` (belongsToMany `User` con pivot), `aportes()`, `factores()`, `calificaciones()`, `cruces()`, `tendenciasVotos()`, `consolidaciones()`, `resultados()`.
- Scopes: `deProyecto($projectId)`, `noCerradas()`, `cerradas()`, `enFase($fase)`.
- Estáticos: `activaPara(int $projectId): ?Dofa2Session`, `activaDelProyectoActivo(): ?Dofa2Session`, `indiceFase($fase)`, `esFaseValida($fase)`.
- Fases: `puedeAvanzarA(string $fase): bool` (solo a la siguiente o a la anterior), `siguienteFase()`, `faseAnterior()`, `faseAlcanzada($fase)`, `estaEnFase($fase)`, `esCerrada()`, `etiquetaFase()`.
- Config: `configCompleta()`, `configValor($clave, $porDefecto)`, `modoCruce()`, `esCrucePlenaria()`, `crucesLimitadosA(): ?int`, `permiteEditarTrasEnviar()`.
- Otros: `participanteDe(User|int): ?Dofa2Participant`, `ultimoResultado(): ?Dofa2Result`.

`Dofa2Participant`: `sesion()`, `user()`; scopes `deSesion`, `conRol`, `queCalifican` (rol participante), `queEnviaron($fase)`, `huerfanos($sesionId)`; `envio($fase): bool`, `esFacilitador()`, `esObservador()`.

**Filas sin usuario.** `dofa2_participantes.user_id` es `nullOnDelete`: al borrar un usuario ya inscrito, su inscripción sobrevive sin dueño. Una fila así no es participante de nadie y, si se contara, inflaría los denominadores del panel y del monitor. Por eso `Dofa2Participant::deSesion()` y la relación `Dofa2Session::participantes()` filtran `user_id IS NOT NULL`, `DofaSesionService::sincronizarParticipantes()` borra las huérfanas que encuentre (el `whereNotIn` no las alcanza, porque en SQL `NULL NOT IN (...)` no es verdadero) y `ParticipantesController::destroy()` retira las inscripciones antes de borrar al usuario. `scopeHuerfanos` existe solo para localizarlas y limpiarlas. Cubierto por `FlujoCompletoTest::test_borrar_un_participante_no_deja_inscripciones_huerfanas`.

`Dofa2Contribution`: `sesion()`, `autor()`, `factor()`; scopes `deSesion`, `deUsuario`, `activos`, `porCategoria`, `ordenados`.

`Dofa2Factor`: `sesion()`, `creador()`, `aportes()`, `calificaciones()`, `crucesComoInterno()`, `crucesComoExterno()`, `votosTendencia()`; scopes `deSesion`, `aprobados`, `borradores`, `porCategoria(string|array)`, `internos`, `externos`, `calculables`, `tendencias`, `ordenados` (F, D, O, A, T y luego `orden`, `id`); `esInterno()`, `esExterno()`, `esTendencia()`, `esCalculable()`, `estaAprobado()`, `calificacionesValidas()`, `etiquetaCategoria()`; estáticos `esInterna($cat)`, `esExterna($cat)`.

`Dofa2Rating`: `sesion()`, `factor()`, `user()`; scopes `deSesion`, `deUsuario`, `deUsuarios(array)`, `completas`; `estaCompleta()`, `paraCalculadora()`.

`Dofa2Cross`: `sesion()`, `interno()`, `externo()`, `user()`; scopes `deSesion`, `deUsuario`, `deUsuarios`; `paraCalculadora()`.

`Dofa2TrendVote`: `sesion()`, `user()`, `factor()`; scopes `deSesion`, `deUsuario`, `deUsuarios`; `paraCalculadora()`.

`Dofa2Consolidation`: `sesion()`, `creador()`; scopes `deSesion`, `propuestas`; `factoresPropuestos()`, `descartados()`, `notasParaElFacilitador()`.

`Dofa2Result`: `sesion()`; scopes `deSesion`, `ultimoPrimero`; estático `siguienteVersion($sesionId)`; `datosPublicos()` (quita `por_participante`).

`InsumoDiagnostico`: `project()`; scopes `deProyecto`, `porTipo`, `visibles`, `ordenados`.

`User` (añadido): `dofa2Sesiones()` (belongsToMany), `iniciales(): string` (hasta 3 letras, sin nombre), `accesoVigente(): bool`, `puedeFacilitarDofa(): bool`, `puedeParticiparDofa(): bool`.

`Project` (añadido): `dofa2Sessions()`, `insumosDiagnostico()`, scope `activos()`, estático `Project::activo(): ?Project` (último con `status = 'active'`).

### 1.3 Roles y permisos (Spatie, guard `web`)

Rol nuevo `participante`. Permisos nuevos `participar_dofa` (participante, junta, sponsor, champion, owner, consultor) y `facilitar_dofa` (consultor). Los crea la migración `2026_09_03_100200_seed_dofa2_roles_permissions` mediante `Dofa2RolesPermissionsSeeder` (idempotente; también `php artisan db:seed --class=Dofa2RolesPermissionsSeeder`).

---

## 2. Motor `App\Services\Dofa\DofaCalculadora`

Clase `final`, pura (sin Eloquent). Un solo método público de cálculo:

```php
$resultado = (new \App\Services\Dofa\DofaCalculadora)->calcular(array $entrada): array;
$mime = (new DofaCalculadora)->mime(float $x, float $y): array;   // utilitario, X = MEFE, Y = MEFI
```

Constantes útiles: `VERSION`, `TOP_N = 30`, `MAX_DESACUERDOS = 10`, `CORTE_BAJO = 2.0`, `CORTE_ALTO = 3.0`, `UMBRAL_SENSIBILIDAD = 0.15`, `DECIMALES = 6`, `ZONAS` (ATAQUE, RESISTA, DESPOSEER con descripción), `ZONA_POR_CELDA`.

### 2.1 Entrada

```php
[
  'factores' => [   // F, D, O, A y T. Cualquier orden: el motor ordena F, D, O, A, T y, dentro, por orden de llegada
    ['id' => 1, 'codigo' => 'F1', 'categoria' => 'F', 'texto' => 'Excelente reputación'],
    ['id' => 14, 'codigo' => 'O1', 'categoria' => 'O', 'texto' => '...', 'importancia_fac' => 0.0578], // importancia_fac es opcional
    ['id' => 35, 'codigo' => 'T1', 'categoria' => 'T', 'texto' => '...'],
  ],
  'calificaciones' => [   // una fila por participante y factor F/D/O/A (Dofa2Rating::paraCalculadora())
    ['user_id' => 3, 'factor_id' => 1, 'importancia' => 8, 'calificacion' => 4, 'prioridad' => 9],
  ],
  'cruces' => [           // una fila por participante y pareja (Dofa2Cross::paraCalculadora())
    ['user_id' => 3, 'factor_interno_id' => 1, 'factor_externo_id' => 14, 'calificacion' => 6],  // acepta también interno_id / externo_id
  ],
  'votos_tendencias' => [ ['user_id' => 3, 'factor_id' => 35] ],
  'participantes' => [ ['user_id' => 3, 'iniciales' => 'NF'] ],   // opcional, solo iniciales
  'config' => ['cruces_limitados_a' => null, 'modo_cruce' => 'individual'],
  'sesion' => ['id' => 1, 'nombre' => 'Retiro 1'],                // opcional, se copia
  'calculado_en' => '2026-09-05T10:40:00-05:00',                  // opcional
]
```

Reglas:
- Los participantes incluidos son los `user_id` distintos presentes en `calificaciones`, `cruces` o `votos_tendencias`. El servicio (`DofaResultadosService`) debe pasar solo las filas de quienes enviaron cada fase.
- `importancia`, `calificacion` y `prioridad` pueden ser `null`: la fila no cuenta en esa métrica. Un participante que no calificó un factor no cuenta en ese factor.
- Pesos: `peso[p][k] = importancia[p][k] / Σ importancias de p en esa matriz`; `peso_k = promedio_p`. Si la suma de `peso_k` de una matriz no es 1 (faltantes), se renormaliza y se anota en `notas`.
- `importancia_fac` por factor es opcional; si falta, `importancia_fac_k = peso_k`. `ponderacion_k = importancia_fac_k × prioridad_k`.
- `cruces_limitados_a = N`: solo cuentan las parejas entre los N internos y los N externos de mayor ponderación; los votos de otras parejas se ignoran (se anota cuántos).
- Las filas mal formadas (sin ids, categorías desconocidas, valores no numéricos) se ignoran sin lanzar excepciones.
- Desviaciones: estándar poblacional (÷ n). Precisión completa en el cálculo; la salida se redondea a 6 decimales.

### 2.2 Salida (estructura de la sección 8, con claves adicionales)

```php
[
  'sesion' => ['id', 'nombre', 'calculado_en', 'participantes_incluidos' => 9, 'modo_cruce', 'cruces_limitados_a'],
  'factores' => [ // solo F, D, O, A, en orden F, D, O, A
    ['id', 'codigo', 'categoria', 'texto', 'peso', 'calificacion', 'valor', 'importancia_fac', 'prioridad', 'ponderacion',
     'desv_peso', 'desv_calificacion', 'desv_prioridad', 'n_votos', 'total_cruzada'],
  ],
  'mefi' => ['total', 'subtotal_F', 'subtotal_D', 'suma_pesos_F', 'suma_pesos_D', 'n_F', 'n_D', 'calif_prom_F', 'calif_prom_D', 'suma_pesos'],
  'mefe' => ['total', 'subtotal_O', 'subtotal_A', 'suma_pesos_O', 'suma_pesos_A', 'n_O', 'n_A', 'calif_prom_O', 'calif_prom_A', 'suma_pesos'],
  'mime' => ['x', 'y', 'celda' => 'V', 'zona' => 'RESISTA', 'zona_descripcion' => 'Retener y mantener', 'sensibilidad' => null|string],
  'fac' => [
    'internos' => [['id', 'codigo', 'categoria', 'texto', 'importancia_fac', 'prioridad', 'ponderacion', 'incluido_en_cruces']],
    'externos' => [...], 'suma_pond_int', 'suma_pond_ext',
  ],
  'cruzada' => [
    'internos' => ['F1', ...], 'externos' => ['O1', ...], 'internos_ids' => [...], 'externos_ids' => [...],
    'internos_incluidos' => [...], 'externos_incluidos' => [...],
    'calificaciones' => [[...]], 'valores' => [[...]], 'desviaciones' => [[...]], 'n_votos' => [[...]],  // filas = internos, columnas = externos; null = no calificado
    'totales_fila' => ['F1' => 40.03], 'totales_columna' => ['O1' => 34.54], 'gran_total' => 340.96,
    'cuadrantes' => ['FO' => ['suma', 'n', 'n_posibles', 'n_posibles_en_limite', 'pct', 'calif_prom'], 'FA' => ..., 'DO' => ..., 'DA' => ...],
    'cuadrante_dominante' => 'FO',
    'cuadrantes_empatados' => [],   // ['FA','DO'] cuando la diferencia entre los dos primeros es <= 1 punto porcentual
    'top_valor' => [['rango', 'interno', 'externo', 'interno_id', 'externo_id', 'cuadrante', 'valor', 'calif', 'desv', 'n']],
    'top_calif' => [...],
    'dominantes_fila' => [... + 'total_fila'], 'dominantes_columna' => [... + 'total_columna'],
    'parejas_posibles', 'parejas_calificadas', 'parejas_no_calificadas', 'parejas_fuera_del_limite', 'limitado_a',
  ],
  'tendencias' => [['rango', 'id', 'codigo', 'texto', 'votos', 'pct']],   // pct sobre participantes_incluidos
  'tendencias_resumen' => ['n_votantes', 'n_participantes', 'votos_total'],
  'desacuerdos' => [['tipo' => 'factor'|'cruce', 'ref' => 'D1' | 'D1 x A3', 'campo' => 'calificacion'|'prioridad'|'importancia'|'cruce', 'desv', 'desv_rel', 'n', 'promedio']],  // 10 mayores por desv_rel
  'por_participante' => ['resumen' => [['user_id', 'iniciales', 'mefi', 'mefe', 'n_calificados', 'n_cruces', 'n_tendencias']]],  // solo facilitador
  'notas' => ['...'],
  'version_motor' => '1.0.0',
]
```

Notas de presentación: 4 decimales en tablas de cálculo, 2 en resúmenes (redondea el frontend). `mime.sensibilidad` es `null` si el punto está a 0,15 o más de los cortes 2,0 y 3,0. Si no hay datos, `mime.celda` y `mime.zona` son `null` y `cuadrante_dominante` es `null`.

Ejemplo mínimo:

```php
$r = (new DofaCalculadora)->calcular([
    'factores' => [
        ['id' => 1, 'codigo' => 'F1', 'categoria' => 'F', 'texto' => 'Reputación'],
        ['id' => 2, 'codigo' => 'O1', 'categoria' => 'O', 'texto' => 'Mercado'],
    ],
    'calificaciones' => [
        ['user_id' => 1, 'factor_id' => 1, 'importancia' => 10, 'calificacion' => 4, 'prioridad' => 9],
        ['user_id' => 1, 'factor_id' => 2, 'importancia' => 7, 'calificacion' => 3, 'prioridad' => 6],
    ],
    'cruces' => [['user_id' => 1, 'factor_interno_id' => 1, 'factor_externo_id' => 2, 'calificacion' => 9]],
]);
// $r['mefi']['total'] = 4.0, $r['mefe']['total'] = 3.0, $r['mime'] = celda I, ATAQUE
// factor F1: peso 1.0, ponderacion 9.0; O1: peso 1.0, ponderacion 6.0
// $r['cruzada']['valores'] = [[486.0]] (9 x 6 x 9), gran_total 486.0, cuadrante_dominante 'FO'
```

Pruebas: `tests/Feature/Dofa/CalculadoraExcel2024Test.php` (fixture `tests/Fixtures/dofa/excel_2024.json`, generado con `python docker/scripts/extraer_dofa_2024.py` en el host) y `tests/Feature/Dofa/CalculadoraMultiParticipanteTest.php`. `tests/Feature/Dofa/Dofa2EsquemaTest.php` cubre migraciones, permisos y modelos en SQLite.

### 2.3 Cómo debe usarlo `DofaResultadosService` (Backend)

1. `Dofa2Factor::deSesion($id)->aprobados()->ordenados()->get()` → `factores` (id, codigo, categoria, texto).
2. Participantes incluidos: `Dofa2Participant::deSesion($id)->queCalifican()->queEnviaron('calificacion')` (y para cruces, `queEnviaron('cruces')`; en modo plenaria los cruces se guardan con el `user_id` del facilitador, que se incluye solo en `cruces`).
3. `Dofa2Rating::deSesion($id)->deUsuarios($ids)->get()->map->paraCalculadora()`, igual para `Dofa2Cross` y `Dofa2TrendVote`.
4. `participantes` = `[['user_id' => $u->id, 'iniciales' => $u->iniciales()]]`.
5. Guardar `Dofa2Result::create(['sesion_id', 'version' => Dofa2Result::siguienteVersion($id), 'calculado_en' => now(), 'datos' => $r, 'participantes_incluidos' => $participantes])`.
6. A participantes y exportaciones entregar `$resultado->datosPublicos()`.

---

## 3. Rutas

Registro (hecho en `bootstrap/app.php`, `withRouting(then:)`): `routes/dofa.php` bajo `['web', 'auth']` sin prefijo; `routes/acceso.php` bajo `['web']`. Nadie edita `routes/web.php` ni `bootstrap/app.php`. `{project}` es `App\Models\Project`; `{sesion}` es `App\Models\Dofa2Session` (route model binding por id; registrar el binding explícito si el nombre del parámetro no coincide con el tipo); `{aporte}` es `Dofa2Contribution`.

**Colisión con el módulo viejo: resuelta.** El bloque del DOFA anterior se retiró de `routes/web.php` (quedó el comentario de las líneas 26 a 31). Hoy `GET projects/{project}/dofa` resuelve a `dofa.panel` y no hay nombres duplicados; `routes/dofa.php` conserva `dofa.index` (`/inicio`) y `dofa.rate` (`/rate`) como redirecciones de compatibilidad al panel y a `/participar`. Comprobado con `php artisan route:list --path=dofa` (29 rutas) y con la prueba de humo, que devuelve 200 en `projects/{project}/dofa`.

Respuestas JSON: `{ ok: true, ... }` o `{ ok: false, error: '...' }` con código HTTP adecuado (400 validación de negocio, 403 autorización, 404, 409 fase incorrecta o ya enviado, 422 validación de campos).

### 3.1 Facilitador (`routes/dofa.php`, prefijo `projects/{project}/dofa`, nombre `dofa.`)

| Método y ruta | Nombre | Devuelve / props |
|---|---|---|
| GET `/` | `dofa.panel` | Inertia `Dofa/Panel`: `project`, `sesiones[]` (id, nombre, fase, abierta_en, cerrada_en, participantes_count), `sesion_activa` (objeto o null), `participantes[]` (id, nombre, cargo, rol_en_sesion, aporte_enviado, calificacion_enviada, cruces_enviados, tendencias_enviadas, ultimo_visto_en), `usuarios_disponibles[]` (id, nombre, cargo, roles) para sincronizar, `config` (configCompleta), `resumen` (conteos: aportes por categoría, factores aprobados, enviaron por fase), `url_acceso` (`route('acceso.codigo')`) |
| POST `/sesiones` | `dofa.sesiones.store` | body `{nombre, config?}` → `{ok, sesion}` |
| PATCH `/sesiones/{sesion}` | `dofa.sesiones.update` | body `{nombre?, config?}` → `{ok, sesion}` |
| POST `/sesiones/{sesion}/fase` | `dofa.sesiones.fase` | body `{fase}`; valida `puedeAvanzarA`; registra en `config.historial_fases[]` `{de, a, en, por}`; al pasar a calificación exige factores aprobados con código; al cerrar calcula → `{ok, sesion}` |
| POST `/sesiones/{sesion}/participantes` | `dofa.sesiones.participantes` | body `{user_ids: []}` sincroniza `dofa2_participantes` (rol participante) → `{ok, participantes}` |
| GET `/sesiones/{sesion}/estado` | `dofa.sesiones.estado` | Con `?monitor=1` la respuesta **no lleva `nombre` ni `cargo`** (la pide la pantalla proyectada en el beam, que solo dibuja iniciales); sin el parámetro sí los lleva (la pide el panel, que es privado). JSON `{ok, fase, participantes: [{id, nombre, iniciales, aporte, aporte_enviado, calificados, calificaciones_total, calificacion_enviada, cruces, cruces_total, cruces_enviados, tendencias_enviadas, visto_hace_seg}], totales: {aportes_por_categoria, enviaron_aporte, enviaron_calificacion, enviaron_cruces}, factores_aprobados, actualizado_en}` |
| GET `/sesiones/{sesion}/consolidar` | `dofa.consolidar` | Inertia `Dofa/Consolidar`: `project`, `sesion`, `aportes` (por categoría, con `autor` visible), `factores` (todos, ordenados), `consolidaciones[]`, `insumos`, `banco_2024`, `skill` `{version, texto}` |
| POST `/sesiones/{sesion}/consolidar/ia` | `dofa.consolidar.ia` | body `{factores_por_categoria?, instrucciones?}` síncrono ≤ 300 s → `{ok, consolidacion, propuesta}` |
| POST `/sesiones/{sesion}/factores` | `dofa.factores.guardar` | body `{factores: [{id?, categoria, texto, detalle?, orden, origen?, fuente?}], eliminar: [ids]}` → `{ok, factores}` |
| POST `/sesiones/{sesion}/factores/aplicar-propuesta` | `dofa.factores.aplicar` | body `{consolidacion_id}` → `{ok, factores}` |
| POST `/sesiones/{sesion}/factores/publicar` | `dofa.factores.publicar` | asigna códigos F1…, D1…, O1…, A1…, T1… por `orden`, marca aprobado → `{ok, factores}` |
| POST `/sesiones/{sesion}/calcular` | `dofa.calcular` | → `{ok, resultados}` (fila `Dofa2Result` con `datos`) |
| GET `/sesiones/{sesion}/resultados` | `dofa.resultados` | Inertia `Dofa/Resultados`: `project`, `sesion`, `resultados` (`datos` de la versión pedida, con `por_participante`), `versiones[]` (id, version, calculado_en, n_participantes), `factores`, `analisis_ia` |
| GET `/sesiones/{sesion}/resultados.json` | `dofa.resultados.json` | JSON `{ok, resultados, version, calculado_en}` (query `?version=`) |
| POST `/sesiones/{sesion}/analisis-ia` | `dofa.analisis.ia` | → `{ok, analisis_ia}` |
| GET `/sesiones/{sesion}/export/pdf` | `dofa.export.pdf` | PDF (`datosPublicos()`) |
| GET `/sesiones/{sesion}/export/xlsx` | `dofa.export.xlsx` | Excel |
| GET `/sesiones/{sesion}/monitor` | `dofa.monitor` | Inertia `Dofa/Monitor`: `project`, `sesion`, `estado_url` (`dofa.sesiones.estado` **con `?monitor=1`**), `estado_inicial` (mismo JSON sin nombres, para el primer pintado), `resultados_url` (`dofa.resultados.json`), `factores` (aprobados), `url_acceso`, `resultados` (último, público) |
| POST `/sesiones/{sesion}/cruces-plenaria` | `dofa.cruces.plenaria` | body `{cruces: [{interno_id, externo_id, calificacion}]}` (modo plenaria; se guarda con el `user_id` del facilitador) → `{ok, guardados}` |
| GET `/insumos` | `dofa.insumos` | Inertia `Dofa/Insumos`: `project`, `insumos` agrupados por tipo |

### 3.2 Participante (`routes/dofa.php`, prefijo `dofa/sesiones/{sesion}`, nombre `dofa.`)

| Método y ruta | Nombre | Devuelve |
|---|---|---|
| POST `/aportes` | `dofa.aportes.store` | body `{categoria, texto}` → `{ok, aporte}` |
| PATCH `/aportes/{aporte}` | `dofa.aportes.update` | body `{texto, categoria?}` solo el autor y en fase aporte → `{ok, aporte}` |
| DELETE `/aportes/{aporte}` | `dofa.aportes.destroy` | → `{ok}` |
| POST `/enviar/{fase}` | `dofa.enviar` | `{fase}` ∈ aporte, calificacion, cruces, tendencias; valida completitud → `{ok, faltantes: []}` (409 si no completo o fase incorrecta) |
| POST `/calificaciones` | `dofa.calificaciones.store` | body `{calificaciones: [{factor_id, importancia?, calificacion?, prioridad?}]}` upsert parcial → `{ok, guardadas, progreso}` |
| POST `/cruces` | `dofa.cruces.store` | body `{cruces: [{interno_id, externo_id, calificacion}]}` upsert → `{ok, guardados, progreso}` |
| POST `/tendencias` | `dofa.tendencias.store` | body `{factor_ids: [≤ 3]}` reemplaza → `{ok, factor_ids}` |

### 3.3 Acceso (`routes/acceso.php`, sin prefijo)

| Método y ruta | Nombre | Devuelve |
|---|---|---|
| GET `/acceso` | `acceso.codigo` | Inertia `Acceso/Codigo`: `error?` |
| POST `/acceso` | `acceso.validar` | body `{codigo}` → login y redirect a `/participar`; ver nota de límites |
| GET `/acceso/{token}` | `acceso.token` | login por token y redirect; ver nota de límites |
| GET `/participar` | `participar` | (auth) Inertia `Dofa/Participar`: `sesion` (id, nombre, fase, config pública), `fase`, `config`, `factores` (aprobados; en aporte va `[]`), `mis_aportes`, `mis_calificaciones` (por factor_id), `mis_cruces` (por `interno_id-externo_id`), `mis_tendencias` (ids), `insumos` (si `mostrar_insumos`), `banco_2024` (si `mostrar_banco_2024`), `cruces` `{internos_ids, externos_ids, limitado_a}` (solo en fase cruces; el celular debe pedir exactamente esas parejas), `progreso` `{aporte: {por_categoria, enviado}, calificacion: {n, total, enviado}, cruces: {n, total, enviado}, tendencias: {n, enviado}}`, `es_facilitador`, `resultados` (público, solo en cerrada), `estado_url` (`participar.estado`). Sin sesión: Inertia `Acceso/Espera` con `estado_url` |
| GET `/participar/estado` | `participar.estado` | (auth) JSON `{ok, fase, sesion_id, factores_version, actualizado_en}` |

`factores_version` = `max(updated_at)` de los factores aprobados, para que el celular recargue cuando cambian.

Nombres y props deben usarse tal cual. El Frontend consume rutas mediante Ziggy (`route('dofa.calificaciones.store', {sesion})`).

---

## 4. Sesión activa

Regla (sección 6): proyecto activo = `Project::activo()` (último `status = 'active'`; hoy el id 1). Sesión activa = `Dofa2Session::activaPara($project->id)`: la última (mayor id) cuya fase no sea `cerrada`; si no hay, la última cerrada; `null` si no existe ninguna. Atajo: `Dofa2Session::activaDelProyectoActivo()`.

`/participar` usa esa regla y, si devuelve `null`, muestra `Acceso/Espera` con sondeo cada 5 s a `/participar/estado`. El panel del facilitador muestra `sesion_activa` con la misma regla, pero permite elegir otra sesión del proyecto.

---

## 5. Autorización

| Rutas | Regla |
|---|---|
| Todas las de `routes/dofa.php` | middleware `auth` (usuario autenticado, incluidos participantes creados por acceso sin contraseña) |
| Facilitador (`projects/{project}/dofa/*`) | `$user->can('facilitar_dofa')` y `sesion.project_id === project.id` (404 si no coincide). Implementar en `App\Policies\Dofa2SessionPolicy` (`facilitar`, `ver`, `participar`) y llamarla con `$this->authorize(...)` |
| Participante (`dofa/sesiones/{sesion}/*`) | `$user->can('participar_dofa')` y existe `Dofa2Participant` de la sesión para ese usuario con `rol_en_sesion = participante` (403 en otro caso). Además: la fase de la sesión debe corresponder al recurso (aportes en `aporte`, calificaciones y tendencias en `calificacion`, cruces en `cruces`, si no 409); si ya envió esa fase y `permitir_editar_tras_enviar` es false, 409; en modo `plenaria` los participantes no pueden escribir cruces (409) |
| `dofa.aportes.update` / `destroy` | además `aporte.user_id === user.id` (403) |
| `dofa.cruces.plenaria` | `facilitar_dofa` y `sesion.esCrucePlenaria()` (409 si no) |
| `dofa.resultados`, `dofa.resultados.json`, `dofa.monitor`, exportaciones | `facilitar_dofa`. Los participantes reciben resultados solo por `/participar` (con `datosPublicos()`) |
| `/participar`, `/participar/estado` | `auth` + `acceso.vigente`; si el usuario tiene `facilitar_dofa` se muestra aviso y enlace a `dofa.panel` (no se le inscribe como participante) |
| Todo `routes/dofa.php` | `web` + `auth` + **`acceso.vigente`**: sin él, un participante con la tarjeta vencida seguía escribiendo aportes, calificaciones y cruces mientras no volviera a pedir `/participar` |
| Todo el grupo `web` | **`RedirigirParticipanteAlRetiro`**: una navegación GET de página de alguien con `es_participante_retiro` fuera de `/acceso*`, `/participar*` y `/dofa/*` se redirige a `/participar` (antes caía en `/dashboard`, la plantilla de Jetstream en inglés). Las peticiones XHR y los POST no se tocan: los protegen los permisos |
| `/acceso*` | públicas, con tres cubetas de límite (ver nota);  validan `codigo_acceso`/`token_acceso`, `es_participante_retiro` y `accesoVigente()`; `Auth::login($user, true)` + `session()->regenerate()` |

**Límites de las rutas de acceso (desviación consciente del contrato original, que pedía 20/min).**
Con Docker publicando el puerto, el contenedor ve **una sola IP** para los quince celulares de la
sala, así que un límite de 20/min por IP se agota entre todos. Hay tres cubetas, en
`config/acceso.php`:

| Cubeta | Valor por defecto | En `.env.docker` | Qué limita |
|---|---|---|---|
| `vistas_por_minuto` | 120 | 240 | Abrir GET `/acceso` (inofensivo) |
| `intentos_por_minuto` | 20 | 120 | Enviar un código o abrir un enlace con token |
| `fallos_por_minuto` | 20 | 20 | Códigos y tokens que **no existen o están vencidos** |

La tercera es la que protege de verdad contra adivinar códigos: solo se incrementa cuando el
intento falla (`AccesoController::registrarFallo`) y se limpia en cuanto alguien entra bien desde
esa IP, así que mantiene el 20/min del contrato sin castigar a la sala. El alfabeto de 32
caracteres y 6 posiciones da 1.073.741.824 combinaciones.

Los nombres de personas solo se muestran al facilitador (`dofa.panel`, `dofa.sesiones.estado` **sin `?monitor=1`**, `dofa.consolidar`). En resultados, monitor y exportaciones solo iniciales (`User::iniciales()`), y `por_participante` se entrega únicamente al facilitador.

---

## 6. Comandos de verificación

```bash
docker compose -f docker/compose.yaml exec -T app php artisan migrate
docker compose -f docker/compose.yaml exec -T app php artisan test --filter Dofa
docker compose -f docker/compose.yaml exec -T app php docker/smoke_get_routes.php consultor@fycls.com 1 1
docker compose -f docker/compose.yaml exec -T app php docker/ensayo_retiro.php --participantes=3
python docker/scripts/extraer_dofa_2024.py     # en el host, regenera tests/Fixtures/dofa/excel_2024.json
```

`docker/ensayo_retiro.php` recorre el retiro completo por HTTP real contra la aplicación en marcha
(acceso por código, aportes, consolidación, calificación, cruces, cálculo y exportaciones), mide el
tiempo de cada paso y termina en "SIN FALLOS" o lista lo que falló. Crea sus propios usuarios y su
propia sesión y los borra al terminar, salvo con `--conservar`. Con `--ia` ejecuta además una
consolidación real contra la API de Anthropic y guarda el resultado en
`docs/ejemplo_consolidacion_ia_2.json`. Con `--participantes=15` sirve para medir la carga real de
la sala.

---

## 7. Cómo se sirve la aplicación (afecta al día del retiro)

El contenedor `app` arranca con `php artisan serve`, cuyo servidor atiende **una petición a la vez**
salvo que se le den varios trabajadores, y Laravel solo respeta `PHP_CLI_SERVER_WORKERS` si el
comando lleva `--no-reload` (si falta, avisa en el log "Only creating a single server"). Además,
`php artisan serve` corre bajo el SAPI **CLI**, así que con `opcache.enable_cli = 0` el opcache
quedaba apagado y cada petición recompilaba el framework entero leyéndolo del volumen montado desde
Windows.

Ambas cosas están corregidas y no deben revertirse sin medir:

- `docker/compose.yaml`, servicio `app`: `PHP_CLI_SERVER_WORKERS: "10"` y
  `command: ["php","artisan","serve","--host=0.0.0.0","--port=8000","--no-reload"]`.
- `docker/php.ini`: `opcache.enable_cli = 1`, `opcache.revalidate_freq = 2` (los cambios de código
  se siguen recogiendo solos, como mucho 2 s después), memoria 256 MB.

Medido en la máquina del consultor (8 CPU): 15 peticiones simultáneas a `/acceso` pasaron de no
poder solaparse a resolverse en **7,9 s en total**. Cambiar `docker/php.ini` obliga a reconstruir la
imagen (`docker compose -f docker/compose.yaml build app`); cambiar `compose.yaml` basta con
`up -d app`.
