# Contrato técnico del módulo Pensamiento estratégico (PE)

Versión 1.0 · 5 de septiembre de 2026 (noche) · Complementa `docs/ESPECIFICACION_PE.md` (que manda en reglas de negocio; este documento manda en nombres, rutas, cuerpos y props). Sigue la forma de `docs/CONTRATO_RUTAS.md`.

Los cinco equipos (Base, Backend, Frontend, Acceso/Integración y Contrato) usan estos nombres tal cual. El Frontend consume rutas por Ziggy (`route('pe.capacidades.votar', {sesion})`) con `rutaSegura` como respaldo, igual que DOFA.

---

## 1. Registro y convenciones

- `routes/pe.php` se registra en `bootstrap/app.php` (`withRouting(then:)`) bajo `['web', 'auth', 'acceso.vigente']`, sin prefijo global. Lo registra Base (archivo vacío con el comentario de cabecera); Backend escribe las rutas. Nadie más toca `bootstrap/app.php`.
- Parámetros: `{project}` = `App\Models\Project`; `{sesion}` = `App\Models\PeSession` (binding implícito por id; registrar `Route::model('sesion', PeSession::class)` **dentro** de `routes/pe.php` es incorrecto porque `routes/dofa.php` usa el mismo nombre para `Dofa2Session`: usar en su lugar tipado en la firma del controlador, que Laravel resuelve por el tipo, y `whereNumber('sesion')`); `{capacidad}` = `PeCapability`; `{renuncia}` = `PeRenunciation`; `{dinamica}` ∈ `PeParticipant::DINAMICAS`.
- Respuestas JSON: `{ ok: true, ... }` o `{ ok: false, error: '...', ...extra }` con 400 negocio, 403 autorización, 404 no encontrado o sesión de otro proyecto, 409 fase incorrecta / incompleto / ya enviado (`motivo: 'ya_enviado'`), 422 validación (`errores: {campo: [..]}`). Se lanza `App\Services\Dofa\DofaException` (reutilizada, sin subclase).
- Controladores en `App\Http\Controllers\Pe\`: `PeController` (base abstracta, copia de `Dofa2Controller` con `ok`, `validar`, `exigirFacilitador`, `sesionDelFacilitador(Request, Project, PeSession)`, `participanteActivo(Request, PeSession): PeParticipant`, `exigirFase`, `exigirFaseEntre(array $fases)`, `exigirNoEnviado($sesion, $participante, $dinamica)`), `PanelController`, `AgruparController`, `CuraduriaController`, `ParticipacionController`, `ResultadosController`.
- Servicios en `App\Services\Pe\`: `PeSesionService` (crear, cambiarFase, sincronizarParticipantes, estado, marcarEnviosCompletos, deshacerEnvios), `PeCalculadora` (pura), `PeResultadosService` (calcular, resumenPublico, tablero), `PeAgrupadorIA`, `PeCuraduriaService` (opciones, capacidades, renuncias, fusiones), `PeExportService` (pdf, json).
- Fechas en ISO 8601 con zona (`toIso8601String()`); ids como enteros; booleanos como `true/false` (nunca 0/1 en JSON).

---

## 2. Rutas del facilitador (`routes/pe.php`, prefijo `projects/{project}/pe`, nombre `pe.`)

Todas exigen `facilitar_dofa` (`exigirFacilitador` o `sesionDelFacilitador`, 403) y que `sesion.project_id === project.id` (404).

### 2.1 Panel y sesión

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| GET `/` | `pe.panel` | `PanelController@panel` | query `?sesion=id` opcional (ver otra sesión del proyecto) | Inertia `Pe/Panel` (5.1) |
| POST `/sesiones` | `pe.sesiones.store` | `PanelController@store` | `{nombre: string 1..150, dofa2_session_id?: int|null}` (si se omite, se enlaza `Dofa2Session::activaPara`) | 201 `{ok, sesion}` (sesión en formato 2.6) |
| PATCH `/sesiones/{sesion}` | `pe.sesiones.update` | `PanelController@update` | `{nombre?: string, config?: {umbral_aprobacion_renuncia?: int 50..100, permitir_editar_tras_enviar?: bool, margen_empate?: int 0..3}, dofa2_session_id?: int|null, notas?: {…campos de 2.3 de la especificación, string|null ≤ 1000}}` | `{ok, sesion}` |
| DELETE `/sesiones/{sesion}` | `pe.sesiones.destroy` | `PanelController@destroy` | — · solo en `configuracion` y sin participantes inscritos (409 si no) | `{ok, sesion_id}` |
| POST `/sesiones/{sesion}/fase` | `pe.sesiones.fase` | `PanelController@fase` | `{fase: string ∈ PeSession::FASES}` | `{ok, sesion}`; 409 si la transición no es válida o falta una condición (esp. 3.1) |
| POST `/sesiones/{sesion}/participantes` | `pe.sesiones.participantes` | `PanelController@participantes` | `{user_ids: int[]}` (sincroniza rol participante; no toca facilitadores; no retira a quien ya tiene registros) | `{ok, participantes: [2.7], conservados: int[]}` (`conservados` = user_id que no venían en la lista pero se conservan por tener registros) |
| GET `/sesiones/{sesion}/estado` | `pe.sesiones.estado` | `PanelController@estado` | query `?monitor=1` opcional | JSON 2.5 (con `?monitor=1` sin `nombre` ni `cargo`) |

### 2.2 Agrupación de la aspiración

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| GET `/sesiones/{sesion}/agrupar` | `pe.agrupar` | `AgruparController@agrupar` | — | Inertia `Pe/Agrupar` (5.2). Disponible en cualquier fase ≥ `aspiracion_escribir`; el panel muestra el acceso desde `aspiracion_agrupar` |
| POST `/sesiones/{sesion}/agrupar/ia` | `pe.agrupar.ia` | `AgruparController@ia` | `{instrucciones?: string ≤ 1000}` · síncrono ≤ 120 s · fases `aspiracion_escribir..aspiracion_votar` (409 fuera) | `{ok, agrupacion: {id, estado, skill_version, modelo, tokens_entrada, tokens_salida, creado_en}, propuesta: {grupos: [{titulo, tema, aspiracion_ids, motivo}], sin_grupo: [{aspiracion_id, motivo}], notas_para_el_facilitador: []}}`; 400 si la IA no está configurada o la respuesta no cumple el esquema |
| POST `/sesiones/{sesion}/grupos` | `pe.grupos.guardar` | `AgruparController@guardar` | `{grupos: [{id?: int, titulo: string 1..300, tema: 'asociado'|'sector'|'interno'|null, orden: int, aspiracion_ids: int[]}] (0..4), eliminar: int[]}` · fases `aspiracion_escribir..aspiracion_votar` | `{ok, grupos: [2.8], aspiraciones: [2.9]}` |
| POST `/sesiones/{sesion}/grupos/aplicar-propuesta` | `pe.grupos.aplicar` | `AgruparController@aplicar` | `{agrupacion_id: int}` | `{ok, grupos, aspiraciones}` |

### 2.3 Curaduría

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| POST `/sesiones/{sesion}/opciones-donde` | `pe.opciones.guardar` | `CuraduriaController@opciones` | `{opciones: [{id?: int, dimension: 'segmento'|'producto'|'territorio', texto: string 1..150, orden: int, visible: bool}], eliminar: int[]}` · fases `configuracion..donde` (409 después) | `{ok, opciones: {segmento: [2.10], producto: [...], territorio: [...]}}` |
| POST `/sesiones/{sesion}/curar/capacidades` | `pe.curar.capacidades` | `CuraduriaController@capacidades` | `{agregar?: [{texto: string 1..200, tipo?: 'tenemos'|'construimos'|null}], editar?: [{id: int, texto?: string, tipo?: 'tenemos'|'construimos'|null, visible?: bool, orden?: int}], fusionar?: [{origen_id: int, destino_id: int}], eliminar?: int[]}` · agregar/editar/fusionar en `capacidades_proponer..cerrada` (solo `tipo` y `orden` en `renuncias_*` y `cerrada`); eliminar solo en `capacidades_proponer` | `{ok, capacidades: [2.11]}` |
| POST `/sesiones/{sesion}/curar/renuncias` | `pe.curar.renuncias` | `CuraduriaController@renuncias` | Igual, con `texto` 1..300 y `evidencia?: string|null ≤ 300` en agregar/editar; sin `tipo`; agregar/editar/fusionar en `renuncias_proponer..renuncias_votar` (`orden` también en `cerrada`); eliminar solo en `renuncias_proponer` | `{ok, renuncias: [2.12]}` |

### 2.4 Resultados, monitor y exportación

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| POST `/sesiones/{sesion}/calcular` | `pe.calcular` | `ResultadosController@calcular` | — · fases ≥ `aspiracion_votar` (409 antes) | `{ok, resultados: {id, version, calculado_en, datos}}` (`datos` = esp. 6.2 completo, con `por_participante`) |
| GET `/sesiones/{sesion}/resultados` | `pe.resultados` | `ResultadosController@resultados` | query `?version=` opcional | Inertia `Pe/Resultados` (5.4) |
| GET `/sesiones/{sesion}/resultados.json` | `pe.resultados.json` | `ResultadosController@resultadosJson` | query `?version=` | `{ok, resultados: datos|null, version: int|null, calculado_en: string|null}` (`datos` público, sin `por_participante`; lo usa el monitor) |
| GET `/sesiones/{sesion}/export/pdf` | `pe.export.pdf` | `ResultadosController@exportPdf` | query `?version=` | `application/pdf`, `hoja_captura_pe_{sesion}_v{version}.pdf`; 409 si no hay resultado |
| GET `/sesiones/{sesion}/export/json` | `pe.export.json` | `ResultadosController@exportJson` | query `?version=` | `application/json` attachment `resultados_pe_{sesion}_v{version}.json` (`datosPublicos()`) |
| GET `/sesiones/{sesion}/monitor` | `pe.monitor` | `ResultadosController@monitor` | — | Inertia `Pe/Monitor` (5.3) |

### 2.5 JSON de `pe.sesiones.estado`

```json
{
  "ok": true,
  "fase": "donde",
  "etiqueta_fase": "Dónde ganar",
  "sesion_id": 1,
  "datos_version": "2026-09-06 08:41:10",
  "participantes": [
    {
      "id": 7, "user_id": 21, "nombre": "…solo sin ?monitor=1…", "cargo": "…solo sin ?monitor=1…", "iniciales": "NF",
      "rol_en_sesion": "participante", "visto_hace_seg": 4,
      "aspiracion": {"tiene": true, "enviada": true},
      "voto_aspiracion": {"n": 2, "enviado": true},
      "donde": {"n": 2, "total": 3, "enviado": false},
      "capacidades": {"n": 0, "enviadas": false},
      "voto_capacidades": {"n": 0, "enviado": false},
      "renuncias": {"n": 0, "enviadas": false},
      "voto_renuncias": {"n": 0, "total": 9, "enviado": false}
    }
  ],
  "totales": {
    "inscritos": 16, "conectados": 14,
    "enviaron": {"aspiracion": 15, "voto_aspiracion": 14, "donde": 9, "capacidades": 0, "voto_capacidades": 0, "renuncias": 0, "voto_renuncias": 0},
    "frases": 15, "grupos": 3, "capacidades_visibles": 4, "renuncias_visibles": 9
  },
  "tablero": { "…ver 2.5.1…" },
  "actualizado_en": "2026-09-06T08:41:12-05:00"
}
```

`conectados` = participantes con `visto_hace_seg ≤ 60`. `inscritos` cuenta solo rol participante. `tablero` es siempre anónimo (nunca nombres ni iniciales) y depende de la fase; contiene **todo lo guardado**, con o sin marca de envío, y anuncia `en_vivo: true`.

#### 2.5.1 `tablero` por fase

- `configuracion`: `null`.
- `aspiracion_escribir` y `aspiracion_agrupar`: `{tipo: 'frases', en_vivo: true, n_enviadas: 12, n_inscritos: 16, frases: [{id, texto, grupo_id, enviada_en}]}` — solo frases con marca de envío, ordenadas por `updated_at` ascendente.
- `aspiracion_votar`: `{tipo: 'aspiracion', en_vivo: true, n_votantes: 11, max_votos: 2, grupos: [{id, letra, titulo, tema, n_frases, votos, pct, ganador: bool, empatado: bool}]}`.
- `donde`: `{tipo: 'donde', en_vivo: true, n_votantes: 9, margen_empate: 1, dimensiones: {segmento: {etiqueta, n_votantes, opciones: [{id, texto, votos, pct, ganadora, empatada}], ganadora_id, empatadas, empate}, producto: {…}, territorio: {…}}}`.
- `capacidades_proponer` y `capacidades_votar`: `{tipo: 'capacidades', en_vivo: true, n_propuestas: 7, n_votantes: 0, max_votos: 3, lista: [{id, texto, tipo, es_base, votos, pct, top: bool}]}` — solo visibles, ordenadas por votos desc y `orden`.
- `renuncias_proponer` y `renuncias_votar`: `{tipo: 'renuncias', en_vivo: true, n_propuestas: 11, n_votantes: 0, umbral: 70, lista: [{id, texto, evidencia, es_base, si, no, n, pct_si, aprobada}]}`.
- `cerrada`: `{tipo: 'cerrada', en_vivo: false, resultados_version: 1}` (el monitor pasa a leer `pe.resultados.json`).

### 2.6 Formato `sesion` (respuestas de panel y JSON)

```json
{"id": 1, "project_id": 1, "nombre": "Retiro 1 · Pensamiento estratégico", "fase": "aspiracion_escribir", "etiqueta_fase": "Aspiración: escribir",
 "dofa2_session_id": 32, "sesion_dofa": {"id": 32, "nombre": "Retiro 1 · DOFA", "fase": "cerrada", "tiene_resultado": true} ,
 "config": {"…configPublica()…"}, "notas": {"…"}, "abierta_en": null, "cerrada_en": null, "participantes_count": 16, "created_at": "…"}
```

### 2.7 Formato `participante` (panel, sincronización)

`{id, user_id, nombre, cargo, iniciales, rol_en_sesion, ultimo_visto_en, aspiracion_enviada, voto_aspiracion_enviado, donde_enviado, capacidades_enviadas, voto_capacidades_enviado, renuncias_enviadas, voto_renuncias_enviado}` (booleanos).

### 2.8 Formato `grupo`

`{id, letra: 'A', titulo, tema, orden, origen, n_frases, aspiracion_ids: int[], votos: int}`.

### 2.9 Formato `aspiracion` (facilitador; nunca con user_id ni nombre)

`{id, texto, grupo_id, enviada: bool, actualizado_en}`.

### 2.10 Formato `opcion` · 2.11 `capacidad` · 2.12 `renuncia` (facilitador)

- opción: `{id, dimension, texto, orden, es_personalizada, visible, votos}`.
- capacidad: `{id, texto, tipo, es_base, visible, orden, fusionada_en_id, es_de_participante: bool, autor_iniciales: string|null, votos}` (`autor_iniciales` solo para el panel; en el monitor y en resultados no viaja).
- renuncia: igual que capacidad sin `tipo` y con `evidencia`, `si`, `no`.

---

## 3. Rutas del participante (`routes/pe.php`, prefijo `pe/sesiones/{sesion}`, nombre `pe.`)

Todas: `participanteActivo` (403 si no está inscrito como participante o no tiene `participar_dofa`), fase exacta de la tabla (409), no enviado salvo config (409 `ya_enviado`). Controlador `ParticipacionController`.

| Método y URI | Nombre | Método | Fase | Entrada | Salida |
|---|---|---|---|---|---|
| PUT `/aspiracion` | `pe.aspiracion.guardar` | `aspiracion` | aspiracion_escribir | `{texto: string 0..300}` | `{ok, aspiracion: {id, texto, actualizado_en}, progreso}` |
| POST `/aspiracion/votos` | `pe.aspiracion.votar` | `votarAspiracion` | aspiracion_votar | `{grupo_ids: int[] 0..max}` reemplaza | `{ok, grupo_ids, progreso}` |
| POST `/donde/votos` | `pe.donde.votar` | `votarDonde` | donde | `{votos: {segmento?: int|null, producto?: int|null, territorio?: int|null}}` upsert parcial | `{ok, votos: {segmento: id|null, producto: id|null, territorio: id|null}, progreso}` |
| POST `/capacidades` | `pe.capacidades.store` | `crearCapacidad` | capacidades_proponer | `{texto: string 1..200}` | 201 `{ok, capacidad: {id, texto, es_mia: true}, progreso}`; 422 si supera el máximo |
| PATCH `/capacidades/{capacidad}` | `pe.capacidades.update` | `editarCapacidad` | capacidades_proponer | `{texto}` · solo autor (403) · no fusionada (409) | `{ok, capacidad}` |
| DELETE `/capacidades/{capacidad}` | `pe.capacidades.destroy` | `borrarCapacidad` | capacidades_proponer | — | `{ok, progreso}` |
| POST `/capacidades/votos` | `pe.capacidades.votar` | `votarCapacidades` | capacidades_votar | `{capacidad_ids: int[] 0..max}` reemplaza; solo visibles | `{ok, capacidad_ids, progreso}` |
| POST `/renuncias` | `pe.renuncias.store` | `crearRenuncia` | renuncias_proponer | `{texto: string 1..300, evidencia?: string|null ≤ 300}` | 201 `{ok, renuncia: {id, texto, evidencia, es_mia: true}, progreso}` |
| PATCH `/renuncias/{renuncia}` | `pe.renuncias.update` | `editarRenuncia` | renuncias_proponer | `{texto?, evidencia?}` | `{ok, renuncia}` |
| DELETE `/renuncias/{renuncia}` | `pe.renuncias.destroy` | `borrarRenuncia` | renuncias_proponer | — | `{ok, progreso}` |
| POST `/renuncias/votos` | `pe.renuncias.votar` | `votarRenuncias` | renuncias_votar | `{votos: [{renuncia_id: int, voto: bool}]}` upsert parcial | `{ok, votos: {"<renuncia_id>": bool}, progreso}` |
| POST `/enviar/{dinamica}` | `pe.enviar` | `enviar` | la de la dinámica | — · `whereIn('dinamica', PeParticipant::DINAMICAS)` | `{ok, faltantes: [], enviado_en}`; 409 `{ok:false, error, faltantes: [...]}` si incompleto |

`{capacidad}` y `{renuncia}` con `whereNumber`, y las rutas de votos declaradas ANTES que las de `{id}` en el archivo. `progreso` en cada respuesta es el objeto de 5.5 (la misma estructura que el prop), para que el celular actualice contadores sin recargar.

---

## 4. Acceso (`routes/acceso.php`, sin cambios de rutas; cambia el controlador)

| Método y URI | Nombre | Devuelve |
|---|---|---|
| GET `/participar` | `participar` | Si `resolverModulo()` devuelve `pe`: Inertia `Pe/Participar` (5.5). Si no: exactamente lo de hoy (`Dofa/Participar` o `Acceso/Espera`) |
| GET `/participar/estado` | `participar.estado` | `{ok, modulo: 'pe'|'dofa', fase: string|null, sesion_id: int|null, factores_version: string|null, datos_version: string|null, actualizado_en}` |

Ejemplos:

```json
{"ok": true, "modulo": "pe", "fase": "aspiracion_escribir", "sesion_id": 1, "factores_version": null, "datos_version": "2026-09-06 08:02:11", "actualizado_en": "2026-09-06T08:05:00-05:00"}
{"ok": true, "modulo": "dofa", "fase": "cruces", "sesion_id": 32, "factores_version": "2026-09-05 10:31:00", "datos_version": null, "actualizado_en": "2026-09-05T11:02:00-05:00"}
{"ok": true, "modulo": "dofa", "fase": null, "sesion_id": null, "factores_version": null, "datos_version": null, "actualizado_en": "…"}
```

Middleware `RedirigirParticipanteAlRetiro::PERMITIDAS` añade `'pe/*'`.

---

## 5. Páginas Inertia (props exactos)

Todas las del facilitador con `AppLayout`; `Pe/Participar` con `ParticipanteLayout`. Archivos en `resources/js/Pages/Pe/`.

### 5.1 `Pe/Panel.vue` (`pe.panel`)

| Prop | Tipo | Ejemplo / contenido |
|---|---|---|
| `project` | `{id, name}` | `{id: 1, name: "FEDEF 2027-2029"}` |
| `sesiones` | `Array<sesion 2.6>` | todas las del proyecto, más reciente primero |
| `sesion_activa` | `sesion 2.6 | null` | la elegida por `?sesion=` o `PeSession::activaPara` |
| `participantes` | `Array<participante 2.7>` | de `sesion_activa` |
| `usuarios_disponibles` | `Array<{id, nombre, cargo, roles: string[]}>` | usuarios con `participar_dofa` (misma consulta que DOFA) |
| `config` | objeto | `configCompleta()` sin `historial_fases` |
| `resumen` | `{inscritos, enviaron: {aspiracion, voto_aspiracion, donde, capacidades, voto_capacidades, renuncias, voto_renuncias}, frases, grupos, capacidades_visibles, renuncias_visibles, resultados_version: int|null}` | primer pintado; después manda el sondeo |
| `grupos` | `Array<grupo 2.8>` | para mostrar las formulaciones en el panel |
| `opciones_donde` | `{segmento: Array<opcion 2.10>, producto: [...], territorio: [...]}` | curaduría |
| `capacidades` | `Array<capacidad 2.11>` | todas (visibles y ocultas) |
| `renuncias` | `Array<renuncia 2.12>` | todas |
| `sesiones_dofa` | `Array<{id, nombre, fase, tiene_resultado: bool, cerrada_en}>` | del proyecto, para el selector de enlace |
| `url_acceso` | string | `route('acceso.codigo')` |
| `qr_svg` | string | SVG del QR general (mismo generador que DOFA) |
| `avisos` | `{acceso_local: bool, ia_configurada: bool, sesiones_abiertas: [{id, nombre, fase}], sesion_de_los_celulares: int|null, dofa_sin_resultado: bool, dofa_sin_enlace: bool, assets: {ok, compilado_en, fuentes_mas_nuevas}|null}` | avisos de víspera |
| `urls` | `{estado, fase, store, update, destroy, participantes, agrupar, monitor, resultados, opciones, curar_capacidades, curar_renuncias, calcular, panel_dofa}` | URLs ya resueltas para `sesion_activa` (null cuando no hay sesión) |

Comportamiento: sondeo `urls.estado` cada 5 s; si `fase` cambia, `router.reload({only: ['sesion_activa','sesiones','resumen','avisos','grupos','capacidades','renuncias','opciones_donde']})`. "Nueva sesión" con nombre sugerido "Retiro 1 · Pensamiento estratégico" y selector de sesión DOFA (por defecto la más reciente). Botón grande con `ETIQUETA_AVANCE` y "Volver a …", ambos con `Modal` de confirmación; `Cerrar y consolidar` en variante peligro con texto "¿Cerrar la sesión y consolidar la hoja de captura con lo enviado? Podrá recalcular después.". Accesos: Agrupar (visible si `indiceFase >= indice('aspiracion_agrupar')`), Monitor (nueva pestaña), Resultados, Panel DOFA. Curaduría en el panel: panel de opciones "dónde" visible hasta `donde`; lista de capacidades con editar/ocultar/fusionar/tipo visible de `capacidades_proponer` a `cerrada`; lista de renuncias visible de `renuncias_proponer` a `cerrada`.

### 5.2 `Pe/Agrupar.vue` (`pe.agrupar`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | |
| `aspiraciones` | `Array<aspiracion 2.9>` | solo con texto no vacío; sin autor |
| `grupos` | `Array<grupo 2.8>` | |
| `agrupaciones` | `Array<{id, estado, skill_version, modelo, tokens_entrada, tokens_salida, creado_en, propuesta}>` | más reciente primero |
| `temas` | `Array<{valor, texto}>` | `[{valor:'asociado', texto:'Asociado'}, {valor:'sector', texto:'Sector'}, {valor:'interno', texto:'Interno'}]` |
| `skill` | `{version, texto}` | para el panel plegable de solo lectura |
| `ia_configurada` | bool | |
| `max_grupos` | int | 4 |
| `urls` | `{ia, guardar, aplicar, panel, estado}` | |

Comportamiento: sondeo `urls.estado` cada 5 s mientras la fase sea `aspiracion_escribir` (llegan frases nuevas: `router.reload({only:['aspiraciones']})` cuando cambia `totales.frases`). Frases a la izquierda con estado (grupo o "sin agrupar"), grupos a la derecha (tarjeta con título editable, selector de tema, contador de frases, botón quitar). Asignar por clic (frase seleccionada → tocar grupo) y arrastrar. "Agrupar con IA" con campo de instrucciones y estado de progreso; "Aplicar propuesta"; "Guardar"; "Abrir votación de aspiración" (llama `pe.sesiones.fase`, con confirmación) cuando la fase es `aspiracion_agrupar`.

### 5.3 `Pe/Monitor.vue` (`pe.monitor`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | |
| `estado_url` | string | `route('pe.sesiones.estado', [project, sesion, 'monitor' => 1])` |
| `estado_inicial` | objeto 2.5 sin nombres | primer pintado |
| `resultados_url` | string | `route('pe.resultados.json', …)` |
| `resultados` | objeto público 6.2 o null | último, si la sesión está cerrada |
| `url_acceso` | string | |
| `qr_svg` | string | |
| `acceso_local` | bool | |

Comportamiento: fondo oscuro, tipografía grande, reloj y fase; QR en la esquina; sondeo `estado_url` cada 5 s y pintado del `tablero` según `tipo` (2.5.1): `frases` (contador `n_enviadas / n_inscritos` y tarjetas que aparecen con transición), `aspiracion` (barras con la formulación completa), `donde` (tres columnas), `capacidades` (barras ordenadas, top 3 resaltadas), `renuncias` (barras Sí/No con línea del umbral y etiqueta "Aprobada"), `cerrada` (resumen de resultados leído de `resultados_url`). En fases de propuesta muestra también "propuestas: n" y las iniciales de quienes ya enviaron (verde). Nunca nombres.

### 5.4 `Pe/Resultados.vue` (`pe.resultados`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | incluye `notas` |
| `resultados` | objeto 6.2 completo (con `por_participante`) o null | versión pedida o última |
| `versiones` | `Array<{id, version, calculado_en, n_participantes}>` | |
| `sesion_dofa` | `{id, nombre, fase, tiene_resultado, url_resultados}` o null | |
| `urls` | `{calcular, pdf, json, update, panel, monitor}` | `pdf`/`json` con `?version=` de la versión mostrada |

Comportamiento: cinco cajas en el mismo orden de la hoja (0 DOFA, 1 Aspiración, 2 Dónde, 3 Capacidades con selector tenemos/construimos en línea que llama `pe.curar.capacidades`, 4 Renuncias, 5 Tensiones y clima con el formulario de `notas` que llama `pe.sesiones.update {notas}`); selector de versión; botones "Recalcular", "Exportar PDF", "Exportar JSON"; pestaña "Por participante" (iniciales) solo aquí.

### 5.5 `Pe/Participar.vue` (`participar` cuando resuelve PE)

| Prop | Tipo | Ejemplo / contenido |
|---|---|---|
| `modulo` | `'pe'` | |
| `sesion` | `{id, nombre, fase, etiqueta_fase, abierta_en, config}` | `config` = `configPublica()` |
| `fase` | string | |
| `config` | objeto | `configPublica()` |
| `mi_aspiracion` | `{id, texto, actualizado_en} | null` | |
| `grupos` | `Array<{id, letra, titulo, tema, orden, n_frases}>` | solo en `aspiracion_votar` y `cerrada`; `[]` en las demás |
| `mis_votos_aspiracion` | `int[]` | ids de grupo |
| `opciones_donde` | `{segmento: [{id, texto, orden}], producto: [...], territorio: [...]}` | visibles; solo en `donde` y `cerrada`; `{}` en las demás |
| `dimensiones` | `Array<{valor, texto, pregunta}>` | `[{valor:'segmento', texto:'Segmento', pregunta:'¿A quién queremos servir mejor?'}, {valor:'producto', texto:'Producto', pregunta:'¿Con qué?'}, {valor:'territorio', texto:'Territorio', pregunta:'¿Dónde?'}]` |
| `mis_votos_donde` | `{segmento: int|null, producto: int|null, territorio: int|null}` | |
| `capacidades_base` | `Array<{id, texto}>` | las `es_base` visibles; solo en `capacidades_proponer` |
| `capacidades` | `Array<{id, texto, tipo, es_base, es_mia}>` | visibles; solo en `capacidades_votar` y `cerrada` |
| `mis_capacidades` | `Array<{id, texto}>` | propias visibles (no fusionadas) |
| `mis_votos_capacidades` | `int[]` | |
| `renuncias_base` | `Array<{id, texto, evidencia}>` | solo en `renuncias_proponer` |
| `renuncias` | `Array<{id, texto, evidencia, es_base, es_mia}>` | visibles; solo en `renuncias_votar` y `cerrada` |
| `mis_renuncias` | `Array<{id, texto, evidencia}>` | |
| `mis_votos_renuncias` | `{"<renuncia_id>": bool}` (objeto) | |
| `progreso` | `{aspiracion: {tiene: bool, enviado: bool}, voto_aspiracion: {n, max, enviado}, donde: {n, total: 3, enviado, faltantes: string[]}, capacidades: {n, max, enviado}, voto_capacidades: {n, max, enviado}, renuncias: {n, max, enviado}, voto_renuncias: {n, total, enviado, faltantes: int[]}}` | |
| `participante` | `{rol_en_sesion, aspiracion_enviada_en, voto_aspiracion_enviado_en, donde_enviado_en, capacidades_enviadas_en, voto_capacidades_enviado_en, renuncias_enviadas_en, voto_renuncias_enviado_en} | null` | |
| `es_facilitador` | bool | |
| `panel_url` | string|null | `route('pe.panel', project)` si es facilitador |
| `resultados` | objeto público 6.2 o null | solo en `cerrada` |
| `resumen_publico` | `{formulacion: {titulo, tema, votos, pct}|null, empatadas: [{titulo, votos}], donde: {segmento: string|null, producto: string|null, territorio: string|null}, capacidades: [{texto, tipo, votos}], renuncias_aprobadas: [{texto, pct_si}]} | null` | solo en `cerrada` |
| `datos_version` | string|null | para el sondeo |
| `estado_url` | string | `route('participar.estado')` |
| `urls` | `{aspiracion, votar_aspiracion, votar_donde, capacidades, capacidad: '…/__ID__', votar_capacidades, renuncias, renuncia: '…/__ID__', votar_renuncias, enviar: '…/enviar/__DINAMICA__', estado, participar, salir}` | resueltas con `rutaSiExiste`; null si la ruta no existe (el celular deshabilita la acción) |
| `usuario` | `{nombre, iniciales}` | |

Comportamiento: sondeo `estado_url` cada 5 s; recarga si cambia `modulo`, `sesion_id`, `fase` o `datos_version`. Selección de componente por fase según la especificación 3.4. `@enviado` de cualquier componente → `router.reload({preserveScroll: true})`.

---

## 6. Componentes Vue del participante (`resources/js/Components/Pe/`)

Todos con `<script setup>`, kit UI, `participante.css` de DOFA reutilizable, botones ≥ 44 px, probados a 360 px. Cada uno recibe `urls` y `enviado` y emite `enviado` tras un envío correcto. El botón "Enviar" abre un `Modal` de confirmación ("¿Enviar su …? Después no podrá cambiarlo."). Autoguardado con `IndicadorGuardado` (importado de `Components/Dofa`). Ante 409 `ya_enviado` o fase incorrecta: mostrar aviso y `router.reload()`.

| Componente | Props | Eventos | Qué muestra |
|---|---|---|---|
| `Espera.vue` | `fase: String`, `titulo?: String`, `mensaje?: String`, `sesionNombre?: String` | — | Textos por fase de la especificación 3.4 (por defecto según `fase`); tarjeta con icono y "Esta pantalla cambiará sola" |
| `AspiracionEscribir.vue` | `sesion`, `config`, `miAspiracion: Object|null`, `progreso`, `enviado: Boolean`, `urls` | `enviado` | Prefijo fijo "FEDEF gana cuando…", `CampoArea` con contador `n / 300`, autoguardado (PUT `urls.aspiracion`, retardo 400 ms), ayuda "Una frase; puede usar cifras. ¿Qué sería visible, para quién, en 2029?", botón "Enviar mi frase" (deshabilitado si vacío); tras enviar, la frase en solo lectura con "Enviada" |
| `AspiracionVotar.vue` | `sesion`, `config`, `grupos: Array`, `misVotos: Array`, `progreso`, `enviado`, `urls` | `enviado` | Tarjetas por formulación (letra, título completo, tema como `Etiqueta`, "n frases"), selección múltiple hasta `config.max_votos_aspiracion` (contador "1 de 2"), autoguardado (POST `urls.votar_aspiracion` con el conjunto), "Enviar mis votos" |
| `Donde.vue` | `sesion`, `config`, `dimensiones: Array`, `opciones: Object`, `misVotos: Object`, `progreso`, `enviado`, `urls` | `enviado` | Tres bloques (una dimensión cada uno, con su pregunta) con `Opcion` de selección única; autoguardado por dimensión (POST `urls.votar_donde` `{votos: {dimension: id}}`); barra "2 de 3"; "Enviar mi voto" deshabilitado hasta 3 de 3; ayuda del guion: "Elegir uno implica no elegir los otros por ahora. 'Todos' no es una opción." |
| `CapacidadesProponer.vue` | `sesion`, `config`, `capacidadesBase: Array`, `misCapacidades: Array`, `progreso`, `enviado`, `urls` | `enviado` | Definición corta ("Algo que hacemos mejor que banco, FNA, fintech o fondo vecino y que es difícil de copiar"), lista de base en solo lectura, "Mis propuestas" (máx. `config.max_capacidades_por_persona`) con `CampoTexto` (≤ 200) + "Agregar", editar/borrar en línea, "Enviar mis propuestas" (permite 0 con texto "Enviar sin proponer") |
| `CapacidadesVotar.vue` | `sesion`, `config`, `capacidades: Array`, `misVotos: Array`, `progreso`, `enviado`, `urls` | `enviado` | Lista de capacidades visibles (marca "propuesta por mí" si `es_mia`, etiqueta tenemos/construimos si viene), selección hasta `config.max_votos_capacidades` (contador "2 de 3"), autoguardado, "Enviar mis votos" |
| `RenunciasProponer.vue` | `sesion`, `config`, `renunciasBase: Array`, `misRenuncias: Array`, `progreso`, `enviado`, `urls` | `enviado` | Lista de las nueve base con evidencia plegable, "Mis propuestas" (máx. 2) con `CampoArea` texto (≤ 300) y `CampoTexto` evidencia opcional (≤ 300), "Enviar mis propuestas" |
| `RenunciasVotar.vue` | `sesion`, `config`, `renuncias: Array`, `misVotos: Object`, `progreso`, `enviado`, `urls` | `enviado` | Una tarjeta por renuncia visible con evidencia plegable y dos botones grandes "Sí, dejarla" / "No, conservarla" (selección única por tarjeta), autoguardado por toque (POST `urls.votar_renuncias` `{votos: [{renuncia_id, voto}]}`), barra "7 de 11", "Enviar mis votos" deshabilitado hasta completar |
| `Cierre.vue` | `resultados: Object|null`, `resumenPublico: Object|null`, `sesionNombre?: String` | — | "Gracias por participar", formulación ganadora (o empatadas), tres "dónde", top 3 capacidades con tipo, renuncias aprobadas con %; sin nombres |

Utilidades: `resources/js/Components/Pe/utilidades.js` exporta `FASES` (las 10), `etiquetaFase`, `ETIQUETA_AVANCE`, `DIMENSIONES`, `TEMAS`, `etiquetaTema`, `etiquetaTipo`, `letraGrupo(orden)`; reexporta `rutaSegura`, `mensajeDeError`, `horaCorta`, `fechaCorta`, `iniciales` desde `Components/Dofa/utilidades.js`.

---

## 7. Autorización

| Rutas | Regla |
|---|---|
| Todo `routes/pe.php` | `web` + `auth` + `acceso.vigente` |
| Facilitador (`projects/{project}/pe/*`) | `$user->can('facilitar_dofa')` y `sesion.project_id === project.id` (404 si no). `App\Policies\PeSessionPolicy` con `facilitar(User, PeSession, ?Project)`, `participar(User, PeSession)`, `ver(User, PeSession)`, registrada igual que `Dofa2SessionPolicy` |
| Participante (`pe/sesiones/{sesion}/*`) | `participar_dofa` + `PeParticipant` con rol participante (403); fase exacta (409); no enviado salvo config (409 `ya_enviado`) |
| `pe.capacidades.update/destroy`, `pe.renuncias.update/destroy` | además autor (`user_id === user.id`, 403) y no fusionada (409) |
| `pe.monitor`, `pe.resultados*`, `pe.export.*`, `pe.calcular` | `facilitar_dofa`; los participantes reciben resultados solo por `/participar` (`datosPublicos()` y `resumen_publico`) |
| `/participar`, `/participar/estado` | como hoy: `auth` + `acceso.vigente`; el facilitador ve aviso y enlace a `pe.panel` cuando el módulo es PE, y no se le inscribe |
| Nombres de personas | solo en `pe.panel`, `pe.sesiones.estado` sin `?monitor=1` y `Pe/Resultados` (pestaña por participante, solo iniciales). Nunca en `Pe/Agrupar`, monitor, `Pe/Participar`, PDF ni JSON |

---

## 8. Sesión activa y resolución del módulo

- Proyecto activo = `Project::activo()`.
- Sesión PE activa = `PeSession::activaPara($project->id)`: la de mayor id no cerrada; si no, la última cerrada; null si no hay.
- El panel muestra `sesion_activa` con esa regla y permite ver otra con `?sesion=`.
- `/participar` usa `ParticiparController::resolverModulo` (especificación, sección 5). Ejemplos el domingo: DOFA 32 en `cruces` y PE 1 en `configuracion` → DOFA; DOFA 32 en `cruces` y PE 1 en `aspiracion_escribir` → PE; DOFA 32 (abierta el sábado, cerrada o no) y PE 1 abierta el domingo y cerrada a las 9:40 → PE, aunque la DOFA se cierre a las 9:45 (con la PE cerrada manda la sesión abierta más tarde); PE 1 cerrada y PE 2 creada en `configuracion` → `activaPara` devuelve PE 2 (no cerrada) en `configuracion` → DOFA (el facilitador debe abrir la aspiración de PE 2 o borrarla con el botón «Borrar» de la lista de sesiones, `pe.sesiones.destroy`; el panel lo avisa con `sesiones_abiertas`).

---

## 9. 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 route:list --path=pe
docker compose -f docker/compose.yaml exec -T app php artisan test --filter Pe
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_pe.php --participantes=3
docker compose -f docker/compose.yaml exec -T app npm run build     # SOLO el integrador, una vez, al final
```

`docker/ensayo_pe.php` (Acceso/Integración) recorre por HTTP real: crear sesión PE, N participantes por código, las 10 fases con escrituras y envíos, agrupación a mano (y con `--ia` por API), cálculo, PDF y JSON; termina en "SIN FALLOS" y borra lo que creó salvo con `--conservar`.
