# Contrato técnico del módulo Apuestas y OKR (AP)

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

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

---

## 1. Registro y convenciones

- `routes/ap.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\ApSession` (binding implícito por el tipo de la firma del controlador, con `whereNumber('sesion')`; NO `Route::model('sesion', …)`, porque `routes/dofa.php` y `routes/pe.php` usan el mismo nombre para otros modelos); `{apuesta}` = `App\Models\ApBet` (`whereNumber`); `{kr}` = `App\Models\ApKeyResult` (`whereNumber`); `{dinamica}` ∈ `ApParticipant::DINAMICAS` (`propuestas`, `evaluacion`).
- Respuestas JSON: `{ ok: true, ... }` o `{ ok: false, error: '...', ...extra }` con 400 negocio (IA no configurada, esquema inválido), 403 autorización, 404 no encontrado o sesión de otro proyecto, 409 fase incorrecta / incompleto / ya enviado (`motivo: 'ya_enviado'`) / apuesta curada (`motivo: 'curada'`) / transición inválida, 422 validación (`errores: {campo: [..]}`). Se lanza `App\Services\Dofa\DofaException` (reutilizada, sin subclase).
- Controladores en `App\Http\Controllers\Ap\`: `ApController` (base abstracta, copia de `PeController`: `ok`, `validar`, `exigirFacilitador`, `sesionDelFacilitador(Request, Project, ApSession)`, `participanteActivo(Request, ApSession): ApParticipant`, `exigirFase`, `exigirFaseEntre(array)`, `exigirNoEnviado($sesion, $participante, $dinamica)`, `apuestaDeSesion(ApSession, ApBet)` (404 si no es de la sesión)), `PanelController`, `CuraduriaController`, `OkrController`, `ParticipacionController`, `ResultadosController`.
- Servicios en `App\Services\Ap\`: `ApSesionService` (crear, cambiarFase, sincronizarParticipantes, estado, marcarEnviosCompletos, deshacerEnvios, progreso, mensajeTarea, extenderVigencia, avisoVigencia), `ApContextoService` (contexto), `ApCalculadora` (pura: ranking), `ApResultadosService` (calcular, rankingEnVivo, tablero, resumenPublico), `ApCuraduriaService` (agregar, editar, fusionar, eliminar, enlazarCapacidad), `ApOkrService` (guardar, comentar, asumir, completitud), `ApSemillasIA`, `ApOkrIA`, `ApExportService` (pdf, json).
- Fechas en ISO 8601 con zona (`toIso8601String()`); `fecha` de un KR como `Y-m-d`; ids como enteros; booleanos como `true/false`; decimales como números (nunca cadenas).

---

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

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

### 2.1 Panel y sesión

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| GET `/` | `ap.panel` | `PanelController@panel` | query `?sesion=id` opcional | Inertia `Ap/Panel` (5.1) |
| POST `/sesiones` | `ap.sesiones.store` | `PanelController@store` | `{nombre: string 1..150, pe_session_id?: int|null, dofa2_session_id?: int|null, config?: {fecha_limite_tarea?: string|null}}` (si se omiten los enlaces se aplica esp. 2.3) | 201 `{ok, sesion}` (2.6) |
| PATCH `/sesiones/{sesion}` | `ap.sesiones.update` | `PanelController@update` | `{nombre?: string, pe_session_id?: int|null, dofa2_session_id?: int|null, config?: {fecha_limite_tarea?: string|null, fecha_sesion_virtual?: string|null ≤ 60, min_elegidas?: int 1..10, max_elegidas?: int 1..10 (≥ min), kr_por_apuesta?: int 1..5, horizonte_okr?: string Y-m-d, duracion_estimada_min?: int 5..120, permitir_editar_tras_enviar?: bool, peso_impacto?: number 0..1, peso_viabilidad?: number 0..1 (suma 1, 422)}}` | `{ok, sesion}` |
| DELETE `/sesiones/{sesion}` | `ap.sesiones.destroy` | `PanelController@destroy` | — · solo en `configuracion` y sin inscritos (409) | `{ok, sesion_id}` |
| POST `/sesiones/{sesion}/fase` | `ap.sesiones.fase` | `PanelController@fase` | `{fase: string ∈ ApSession::FASES}` | `{ok, sesion, avisos: string[]}`; 409 si la transición no es válida o falta una condición (esp. 3.1) |
| POST `/sesiones/{sesion}/participantes` | `ap.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[]}` |
| GET `/sesiones/{sesion}/estado` | `ap.sesiones.estado` | `PanelController@estado` | query `?monitor=1` opcional | JSON 2.5 (con `?monitor=1` sin `nombre`, `cargo` ni `autor_iniciales`) |
| POST `/sesiones/{sesion}/extender-vigencia` | `ap.sesiones.vigencia` | `PanelController@vigencia` | `{dias?: int 1..90}` (por defecto 30) · cualquier fase | `{ok, actualizados: int, sin_cambio: int, hasta: string, vigencia: 2.5.2}`; nunca cambia `codigo_acceso` ni `token_acceso` |

### 2.2 Curaduría de apuestas y semillas con IA

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| POST `/sesiones/{sesion}/curar/apuestas` | `ap.curar.apuestas` | `CuraduriaController@apuestas` | `{agregar?: [{titulo: string 1..80, accion: string 1..300, capacidad: string 1..200, capacidad_pe_id?: int|null, resultado: string 1..300, senal: string 1..200, renuncia_implica?: string|null ≤ 200, cuadrante?: 'FO'|'FA'|'DO'|'DA'|null}], editar?: [{id: int, titulo?, accion?, capacidad?, capacidad_pe_id?, resultado?, senal?, renuncia_implica?, cuadrante?, visible?: bool, elegida?: bool, estado?: 'borrador'|'enviada', orden?: int}], fusionar?: [{origen_id: int, destino_id: int}], eliminar?: int[]}` · fases por operación según esp. 4.6 (agregar y eliminar en `configuracion..proponer`; editar en `configuracion..okr`, en `cerrada` solo `orden` y `cuadrante`; `elegida` en `seleccionar..okr`; fusionar en `proponer..seleccionar`) | `{ok, apuestas: [2.8], datos_version}` |
| POST `/sesiones/{sesion}/semillas/ia` | `ap.semillas.ia` | `CuraduriaController@semillasIa` | `{instrucciones?: string ≤ 1000}` · síncrono ≤ 120 s · fases `configuracion..proponer` (409 fuera) | `{ok, propuesta_ia: 2.10, propuesta: {apuestas: [{titulo, accion, capacidad, capacidad_pe_id, resultado, senal, renuncia_implica, cuadrante, motivo}], notas_para_el_facilitador: string[]}, avisos: string[]}`; 400 si la IA no está configurada o la respuesta no cumple el esquema |
| POST `/sesiones/{sesion}/semillas/aplicar-propuesta` | `ap.semillas.aplicar` | `CuraduriaController@aplicarSemillas` | `{propuesta_id: int, indices?: int[]}` (índices de `propuesta.apuestas` a crear; por defecto todos) · fases `configuracion..proponer` | `{ok, apuestas: [2.8], creadas: int}` |

### 2.3 OKR

| Método y URI | Nombre | Controlador | Entrada | Salida |
|---|---|---|---|---|
| GET `/sesiones/{sesion}/okr` | `ap.okr` | `OkrController@okr` | — · disponible desde `seleccionar`; el panel muestra el acceso desde `okr` | Inertia `Ap/Okr` (5.2) |
| PUT `/sesiones/{sesion}/apuestas/{apuesta}/okr` | `ap.okr.guardar` | `OkrController@guardar` | `{objetivo: string 0..200, resultados_clave: [{id?: int, texto: string 1..200, metrica?: string|null ≤ 120, linea_base?: string|null ≤ 60, meta?: string|null ≤ 60, fecha?: string Y-m-d|null, dueno_user_id?: int|null (inscrito en la sesión), dueno_texto?: string|null ≤ 80, orden: int}] (0..5), eliminar?: int[], propuesta_id?: int}` · fases `okr..cerrada` · la apuesta debe estar `elegida` (409) | `{ok, okr: 2.9, avisos: string[], datos_version}` |
| POST `/sesiones/{sesion}/apuestas/{apuesta}/okr/ia` | `ap.okr.ia` | `OkrController@ia` | `{instrucciones?: string ≤ 1000}` · síncrono ≤ 120 s · fases `okr..cerrada` · apuesta elegida | `{ok, propuesta_ia: 2.10, propuesta: {objetivo, resultados_clave: [{texto, metrica, linea_base, meta, fecha, dueno_texto, dueno_user_id: null, motivo}], notas_para_el_facilitador: string[]}, avisos: string[]}`; 400 si la IA no está configurada |

### 2.4 Resultados, monitor y exportación

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

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

```json
{
  "ok": true,
  "fase": "evaluar",
  "etiqueta_fase": "Tarea: evaluar apuestas",
  "sesion_id": 1,
  "datos_version": "2026-09-09 10: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, "ultimo_visto_en": "2026-09-09T10:41:06-05:00",
      "acceso_vigente": true, "acceso_expira_en": "2026-09-10T08:00:00-05:00",
      "propuestas": {"n": 2, "max": 2, "completas": 2, "enviadas": true},
      "evaluacion": {"n": 9, "total": 12, "enviada": false},
      "comentarios_okr": 0, "postulaciones": 0
    }
  ],
  "totales": {
    "inscritos": 16, "conectados": 3, "vistos_hoy": 11,
    "enviaron": {"propuestas": 13, "evaluacion": 6},
    "apuestas": {"evaluables": 12, "semillas": 5, "de_participantes": 7, "borradores": 2, "ocultas": 1, "elegidas": 0},
    "evaluadores": 9, "comentarios_okr": 0, "postulaciones": 0,
    "okr": {"elegidas": 0, "completos": 0, "kr": 0, "kr_completos": 0}
  },
  "vigencia": { "…2.5.2…" },
  "fecha_limite_tarea": "2026-09-09T20:00:00-05:00",
  "tablero": { "…2.5.1…" },
  "actualizado_en": "2026-09-09T10:41:12-05:00"
}
```

`conectados` = participantes con `visto_hace_seg ≤ 60`; `vistos_hoy` = con `ultimo_visto_en` del día (útil en la tarea asíncrona). `inscritos` cuenta solo rol participante. `acceso_vigente` y `acceso_expira_en` solo sin `?monitor=1`. `tablero` es siempre anónimo y contiene **todo lo guardado**, con o sin marca de envío, con `en_vivo: true`.

#### 2.5.1 `tablero` por fase

- `configuracion`: `{tipo: 'semillas', en_vivo: true, n_semillas: 5, apuestas: [2.11]}`.
- `proponer`: `{tipo: 'apuestas', en_vivo: true, n_enviaron: 13, n_inscritos: 16, n_evaluables: 12, apuestas: [2.11]}` — solo evaluables (semillas y enviadas), ordenadas por `orden`, sin autor.
- `evaluar` y `seleccionar`: `{tipo: 'ranking', en_vivo: true, n_evaluadores: 9, n_evaluables: 12, max_elegidas: 6, ranking: [2.12], empatadas: [[3, 9]], sugeridas: [7, 2, 3, 9, 11, 5], empatadas_en_corte: []}`.
- `okr`: `{tipo: 'okr', en_vivo: true, elegidas: [{apuesta: 2.11, rango, puntaje, okr: 2.9 público (sin `dueno_user_id`, con `dueno`), n_comentarios, completo}]}`.
- `cerrada`: `{tipo: 'cerrada', en_vivo: false, resultados_version: 1}` (el monitor pasa a leer `ap.resultados.json`).

#### 2.5.2 `vigencia`

`{fecha_limite: string|null, total_inscritos: 16, vencidos: 3, vencen_antes_del_limite: 9, hasta_mas_lejana: string|null, referencia: string}` (`referencia` = la fecha contra la que se contó: `fecha_limite_tarea` o ahora + 48 h).

### 2.6 Formato `sesion`

```json
{"id": 1, "project_id": 1, "nombre": "Retiro 1 · Apuestas y OKR", "fase": "proponer", "etiqueta_fase": "Tarea: proponer apuestas",
 "pe_session_id": 1, "sesion_pe": {"id": 1, "nombre": "Retiro 1 · Pensamiento estratégico", "fase": "cerrada", "tiene_resultado": true},
 "dofa2_session_id": 32, "sesion_dofa": {"id": 32, "nombre": "Retiro 1 · DOFA", "fase": "cerrada", "tiene_resultado": true},
 "config": {"…configPublica()…"}, "abierta_en": "…", "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, acceso_vigente, acceso_expira_en, propuestas_enviadas, evaluacion_enviada}` (booleanos los dos últimos).

### 2.8 Formato `apuesta` (facilitador)

`{id, titulo, accion, capacidad, capacidad_pe_id, capacidad_pe_texto: string|null, capacidad_pe_tipo: 'tenemos'|'construimos'|null, resultado, senal, renuncia_implica, cuadrante, fuente, visible, fusionada_en_id, elegida, orden, estado, completa: bool, partes_faltantes: string[], es_de_participante: bool, autor_iniciales: string|null, n_evaluaciones: int, puntaje: number|null, tiene_okr: bool, actualizado_en}` (`autor_iniciales` solo en el panel; nunca en monitor, celular, PDF ni JSON).

### 2.9 Formato `okr`

`{id, apuesta_id, objetivo, completo: bool, resultados_clave: [{id, orden, texto, metrica, linea_base, meta, fecha, dueno_user_id, dueno_texto, dueno: string|null (duenoEtiqueta), completo: bool, faltantes: string[], n_postulaciones: int, postulantes_iniciales: string[]}], propuesta_ia_id: int|null, actualizado_en}`. La versión **pública** (celular, monitor, resultados, JSON) quita `dueno_user_id` y `postulantes_iniciales`.

### 2.10 Formato `propuesta_ia`

`{id, tipo: 'semillas'|'okr', apuesta_id, estado, skill_version, modelo, tokens_entrada, tokens_salida, creado_en}`.

### 2.11 Formato `apuesta` público (tablero, celular, resultados)

`{id, titulo, accion, capacidad, capacidad_pe_id, capacidad_pe_texto, capacidad_pe_tipo, resultado, senal, renuncia_implica, cuadrante, fuente: 'semilla'|'participante'|'ia', elegida, orden}` (sin `user_id`, sin iniciales, sin `estado`). En el celular se añade `es_mia: bool`.

### 2.12 Formato `fila de ranking` (tablero, `ap.ranking`, resultados)

`{rango, id, titulo, accion, capacidad, capacidad_pe_texto, capacidad_pe_tipo, resultado, senal, renuncia_implica, cuadrante, fuente, n, prom_impacto, prom_viabilidad, puntaje, desacuerdo, empatada, elegida, sugerida, sin_evaluar, comentarios: string[]}`. En `ap.ranking` (celular) `comentarios` se omite; en el celular se añade `es_mia`.

---

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

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

| Método y URI | Nombre | Método | Fase | Entrada | Salida |
|---|---|---|---|---|---|
| POST `/apuestas` | `ap.apuestas.store` | `crearApuesta` | proponer | `{titulo?: string ≤ 80, accion?: ≤ 300, capacidad?: ≤ 200, capacidad_pe_id?: int|null, resultado?: ≤ 300, senal?: ≤ 200, renuncia_implica?: ≤ 200, cuadrante?: enum|null}` (todo opcional: crea un borrador) | 201 `{ok, apuesta: 2.11 + {estado, completa, partes_faltantes, es_mia: true}, progreso}`; 422 si supera el máximo |
| PATCH `/apuestas/{apuesta}` | `ap.apuestas.update` | `editarApuesta` | proponer | mismos campos, parciales (autoguardado) · solo autor (403) · no curada (409 `curada`) | `{ok, apuesta, progreso}` |
| DELETE `/apuestas/{apuesta}` | `ap.apuestas.destroy` | `borrarApuesta` | proponer | — · solo autor · no curada | `{ok, progreso}` |
| POST `/evaluaciones` | `ap.evaluaciones.guardar` | `evaluar` | evaluar | `{evaluaciones: [{apuesta_id: int, impacto?: int 1..escala|null, viabilidad?: int 1..escala|null, comentario?: string|null ≤ 200}]}` upsert parcial; 422 si la apuesta no es evaluable | `{ok, evaluaciones: {"<apuesta_id>": {impacto, viabilidad, comentario}}, progreso}` |
| GET `/ranking` | `ap.ranking` | `ranking` | seleccionar, okr, cerrada (409 antes) | — · JSON anónimo, sondeo cada 5 s | `{ok, fase, n_evaluadores, n_evaluables, max_elegidas, ranking: [2.12 sin comentarios, con es_mia], empatadas, sugeridas, actualizado_en}` |
| PUT `/apuestas/{apuesta}/comentario-okr` | `ap.okr.comentar` | `comentarOkr` | okr | `{comentario: string 0..200}` upsert por (apuesta, usuario); vacío borra · apuesta elegida (422) | `{ok, comentario: {apuesta_id, comentario, actualizado_en}|null}` |
| POST `/resultados-clave/{kr}/asumir` | `ap.okr.asumir` | `asumirKr` | okr | — · alterna la postulación · KR de una apuesta elegida de la sesión (404 si no) | `{ok, postulado: bool, resultado_clave_id}` |
| POST `/enviar/{dinamica}` | `ap.enviar` | `enviar` | la de la dinámica | — · `whereIn('dinamica', ApParticipant::DINAMICAS)` | `{ok, faltantes: [], enviado_en, progreso}`; 409 `{ok:false, error, faltantes: [...]}` si incompleto (`propuestas`: `[{apuesta_id, partes: string[]}]`; `evaluacion`: `int[]`) |

`{apuesta}` y `{kr}` con `whereNumber`; `POST /evaluaciones` y `GET /ranking` declaradas ANTES que las rutas con `{apuesta}`. `progreso` en cada respuesta es el objeto de 5.5.

---

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

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

Ejemplo:

```json
{"ok": true, "modulo": "ap", "fase": "proponer", "sesion_id": 1, "factores_version": null, "datos_version": "2026-09-08 07:02:11", "actualizado_en": "2026-09-08T07:05:00-05:00"}
```

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

---

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

Todas las del facilitador con `AppLayout`; `Ap/Participar` con `ParticipanteLayout`. Archivos en `resources/js/Pages/Ap/`. Todas reciben `contexto` (esp. 5.2) y lo pintan con `Components/Ap/Contexto.vue`.

### 5.1 `Ap/Panel.vue` (`ap.panel`)

| Prop | Tipo | Ejemplo / contenido |
|---|---|---|
| `project` | `{id, name}` | |
| `sesiones` | `Array<sesion 2.6>` | todas las del proyecto, más reciente primero |
| `sesion_activa` | `sesion 2.6 | null` | la elegida por `?sesion=` o `ApSession::activaPara` |
| `participantes` | `Array<participante 2.7>` | de `sesion_activa` |
| `usuarios_disponibles` | `Array<{id, nombre, cargo, roles: string[]}>` | usuarios con `participar_dofa` (misma consulta que PE) |
| `config` | objeto | `configCompleta()` sin `historial_fases` |
| `resumen` | `{inscritos, enviaron: {propuestas, evaluacion}, apuestas: {evaluables, semillas, de_participantes, borradores, ocultas, elegidas}, evaluadores, okr: {elegidas, completos, kr, kr_completos}, resultados_version: int|null}` | primer pintado; después manda el sondeo |
| `apuestas` | `Array<apuesta 2.8>` | todas (visibles, ocultas, borradores) ordenadas por `orden` |
| `ranking` | `Array<fila 2.12> | null` | en vivo, desde `evaluar`; null antes |
| `capacidades_pe` | `Array<{id, texto, tipo, votos}>` | visibles de la PE enlazada, para "enlazar capacidad" |
| `contexto` | objeto esp. 5.2 | |
| `propuestas_ia` | `Array<propuesta_ia 2.10 + {propuesta}>` | de tipo `semillas`, más reciente primero |
| `skill_semillas` | `{version, texto}` | panel plegable de solo lectura |
| `sesiones_pe` | `Array<{id, nombre, fase, tiene_resultado, cerrada_en}>` | del proyecto, para el selector de enlace |
| `sesiones_dofa` | `Array<{id, nombre, fase, tiene_resultado, cerrada_en}>` | |
| `mensaje_tarea` | string | esp. 8 (null si no hay sesión) |
| `vigencia` | objeto 2.5.2 | null si no hay sesión |
| `url_acceso` | string | `route('acceso.codigo')` |
| `qr_svg` | string | SVG del QR general |
| `avisos` | `{acceso_local: bool, ia_configurada: bool, sesiones_abiertas: [{id, nombre, fase}], sesion_de_los_celulares: {modulo, id}|null, pe_sin_enlace: bool, pe_sin_resultado: bool, dofa_sin_resultado: bool, sin_semillas: bool, fecha_limite_vencida: bool, elegidas_fuera_de_rango: bool, okr_incompletos: int, assets: {ok, compilado_en, fuentes_mas_nuevas}|null}` | avisos de víspera y de fase |
| `urls` | `{estado, fase, store, update, destroy, participantes, vigencia, curar, semillas_ia, semillas_aplicar, okr, monitor, resultados, calcular, panel_pe, panel_dofa}` | resueltas para `sesion_activa` (null si no hay) |

Comportamiento: sondeo `urls.estado` cada 5 s; si `fase` cambia, `router.reload({only: ['sesion_activa','sesiones','resumen','avisos','apuestas','ranking','vigencia','mensaje_tarea']})`; en `evaluar` y `seleccionar` el ranking del panel se pinta desde `tablero.ranking` del sondeo. "Nueva sesión" con nombre sugerido "Retiro 1 · Apuestas y OKR", selector de sesión PE (por defecto la cerrada más reciente) y DOFA, y campo de fecha límite. Línea de 6 fases. Botón grande con `ETIQUETA_AVANCE` y "Volver a …", ambos con `Modal`; los `avisos` del diálogo (esp. 3.1) vienen en la respuesta de `ap.sesiones.fase` o se calculan del `resumen`. Bloques: "Fecha límite y vigencia" (fecha, contador de vencidos, botón "Extender vigencia 30 días" con confirmación, botón "Copiar mensaje de tarea"), "Participantes" (estado por dinámica, "Inscribir o retirar"), "Apuestas" (`FacApuestas`: lista con casillas, "Agregar apuesta" (formulario de cuatro partes en `Modal`), editar en línea, "Fusionar (2)", ojo tachado, casilla "Elegida" desde `seleccionar`, selector "Enlazar capacidad", etiqueta de cuadrante, "Borrador incompleto", puntaje y n desde `evaluar`, "Redactar semillas con IA" con instrucciones y "Aplicar" con casillas por apuesta propuesta), accesos Monitor (nueva pestaña), OKR (visible desde `seleccionar`), Resultados, Panel PE, Panel DOFA.

### 5.2 `Ap/Okr.vue` (`ap.okr`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | |
| `contexto` | objeto esp. 5.2 | |
| `elegidas` | `Array<{apuesta: 2.8, rango, puntaje, prom_impacto, prom_viabilidad, n, comentarios_evaluacion: string[], okr: 2.9|null, comentarios_okr: [{texto, actualizado_en}]}>` | en orden de rango; comentarios sin autor |
| `participantes` | `Array<{user_id, nombre, cargo, iniciales}>` | inscritos, para el selector de dueño (solo aquí) |
| `cargos_sugeridos` | `string[]` | la lista de `cargos_disponibles` de la skill |
| `propuestas_ia` | `Array<propuesta_ia 2.10 + {propuesta}>` | de tipo `okr`, por apuesta |
| `skill_okr` | `{version, texto}` | |
| `ia_configurada` | bool | |
| `kr_por_apuesta` | int | 3 |
| `max_kr` | int | 5 |
| `horizonte_okr` | string | `2027-12-31` |
| `resumen` | `{n_elegidas, n_okr_completos, n_kr, n_kr_completos, n_kr_por_confirmar}` | |
| `urls` | `{guardar: '…/apuestas/__ID__/okr', ia: '…/apuestas/__ID__/okr/ia', panel, estado, monitor}` | |

Comportamiento: sondeo `urls.estado` cada 5 s para refrescar `n_comentarios` y `n_postulaciones` (`router.reload({only: ['elegidas','resumen']})` cuando cambian `totales.comentarios_okr` o `totales.postulaciones`). Una `Tarjeta` por apuesta con las cuatro partes plegables, `CampoTexto` objetivo, tabla de KR editable (texto, métrica, línea base con botón "por confirmar", meta, fecha, dueño: `CampoSelect` de participantes + `CampoTexto` cargo), "Agregar resultado clave", quitar, autoguardado 1500 ms con `IndicadorGuardado`, estado "Completo"/"k incompletos", "Proponer OKR con IA" con instrucciones (vuelca en el formulario sin guardar), comentarios de participantes en lista anónima, postulaciones por KR ("2 se postulan" con iniciales en `Tooltip`). Botón "Abrir OKR" / "Cerrar y consolidar" no está aquí: se vuelve al panel.

### 5.3 `Ap/Monitor.vue` (`ap.monitor`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | |
| `contexto` | objeto esp. 5.2 | |
| `estado_url` | string | `route('ap.sesiones.estado', [project, sesion, 'monitor' => 1])` |
| `estado_inicial` | objeto 2.5 sin nombres | |
| `resultados_url` | string | `route('ap.resultados.json', …)` |
| `resultados` | objeto público 10.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`: `semillas` y `apuestas` (contador `n_enviaron / n_inscritos` y tarjetas con las cuatro partes en párrafo), `ranking` (barras por apuesta: puntaje con la barra principal, impacto y viabilidad finas, `n`, rótulo "Empate", resaltado "Elegida", línea de corte en `max_elegidas`, comentarios anónimos plegables al tocar), `okr` (una columna por apuesta elegida con objetivo y KR, completitud en color), `cerrada` (resumen leído de `resultados_url`). Botón "Lo que decidimos" que abre `Contexto` a pantalla completa (para compartir en la sesión virtual). Nunca nombres.

### 5.4 `Ap/Resultados.vue` (`ap.resultados`)

| Prop | Tipo | Ejemplo |
|---|---|---|
| `project` | `{id, name}` | |
| `sesion` | `sesion 2.6` | |
| `contexto` | objeto esp. 5.2 | |
| `resultados` | objeto 10.2 completo (con `por_participante`) o null | versión pedida o última |
| `versiones` | `Array<{id, version, calculado_en, n_participantes}>` | |
| `sesion_pe` | `{id, nombre, fase, tiene_resultado, url_resultados}` o null | |
| `sesion_dofa` | `{id, nombre, fase, tiene_resultado, url_resultados}` o null | |
| `urls` | `{calcular, pdf, json, okr, panel, monitor}` | `pdf`/`json` con `?version=` |

Comportamiento: cinco secciones en el orden del PDF (1 Lo que decidimos, 2 Ranking con `Tabla`, 3 Elegidas, 4 OKR v1.0 con enlace "Editar en OKR", 5 Pendientes para el refinamiento); selector de versión; "Recalcular", "Exportar PDF", "Exportar JSON"; pestaña "Por participante" (iniciales) solo aquí.

### 5.5 `Ap/Participar.vue` (`participar` cuando resuelve AP)

| Prop | Tipo | Ejemplo / contenido |
|---|---|---|
| `modulo` | `'ap'` | |
| `sesion` | `{id, nombre, fase, etiqueta_fase, abierta_en, config}` | `config` = `configPublica()` |
| `fase` | string | |
| `config` | objeto | `configPublica()` |
| `contexto` | objeto esp. 5.2 | en todas las fases |
| `textos` | objeto `ap_textos.json` sin `mensaje_tarea` | rótulos del formato, ayudas y escala |
| `fecha_limite` | `{iso: string|null, texto: string|null, vencida: bool}` | "miércoles 9 de septiembre, 8:00 p. m." |
| `capacidades_pe` | `Array<{id, texto, tipo}>` | visibles de la PE enlazada; solo en `proponer`; `[]` en las demás |
| `apuestas` | `Array<apuesta 2.11 + {es_mia}>` | evaluables ajenas y propias enviadas en `proponer`; evaluables en `evaluar`; `[]` en las demás (en `seleccionar` van en `ranking`) |
| `mis_apuestas` | `Array<apuesta 2.11 + {estado, completa, partes_faltantes, es_mia: true}>` | propias no fusionadas (visibles u ocultas por el facilitador, con `visible`) |
| `mis_evaluaciones` | `{"<apuesta_id>": {impacto, viabilidad, comentario}}` (objeto) | |
| `ranking` | `Array<fila 2.12 sin comentarios, con es_mia> | null` | solo en `seleccionar`, `okr` y `cerrada` |
| `elegidas` | `Array<{apuesta: 2.11, rango, puntaje, okr: 2.9 público|null, mi_comentario: string|null, mis_postulaciones: int[]}> | []` | solo en `okr` y `cerrada` |
| `progreso` | `{propuestas: {n, max, completas, enviado, faltantes: [{apuesta_id, partes}]}, evaluacion: {n, total, enviado, faltantes: int[]}}` | |
| `participante` | `{rol_en_sesion, propuestas_enviadas_en, evaluacion_enviada_en} | null` | |
| `es_facilitador` | bool | |
| `panel_url` | string|null | `route('ap.panel', project)` si es facilitador |
| `resultados` | objeto público 10.2 o null | solo en `cerrada` |
| `resumen_publico` | `{aspiracion: {titulo}|null, elegidas: [{rango, titulo, accion, capacidad, resultado, senal, cuadrante, okr: {objetivo, resultados_clave: [{texto, meta, fecha, dueno}]}|null}], okr_resumen: {…}} | null` | solo en `cerrada` |
| `datos_version` | string|null | para el sondeo |
| `estado_url` | string | `route('participar.estado')` |
| `urls` | `{apuestas, apuesta: '…/__ID__', evaluaciones, ranking, comentario_okr: '…/apuestas/__ID__/comentario-okr', asumir: '…/resultados-clave/__ID__/asumir', enviar: '…/enviar/__DINAMICA__', estado, participar, salir}` | resueltas con `rutaSiExiste`; null si la ruta no existe |
| `usuario` | `{nombre, iniciales}` | |

Comportamiento: sondeo `estado_url` cada 5 s; recarga (`preserveState: true, preserveScroll: true`) 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})`. Al montar en `evaluar`, desplaza a `#apuesta-{primera sin evaluar}`.

---

## 6. Componentes Vue (`resources/js/Components/Ap/`)

Todos con `<script setup>`, kit UI, `participante.css` y `facilitador.css` de PE reutilizables, botones ≥ 44 px, probados a 360 px. Los del participante reciben `urls` y `enviado` y emiten `enviado` tras un envío correcto; "Enviar" abre un `Modal` de confirmación ("¿Enviar …? Después no podrá cambiarlo."). Autoguardado con `IndicadorGuardado` (de `Components/Dofa`). Ante 409 `ya_enviado`, `curada` o fase incorrecta: aviso y `router.reload()`.

| Componente | Props | Eventos | Qué muestra |
|---|---|---|---|
| `Contexto.vue` | `contexto: Object`, `abierto?: Boolean`, `compacto?: Boolean` | — | Panel plegable "Lo que decidimos" con los cinco bloques de la esp. 5.3; `compacto` para el celular (una línea por dimensión); recuerda el estado en `localStorage` (`ap_contexto_abierto`) |
| `Espera.vue` | `fase: String`, `titulo?: String`, `mensaje?: String`, `sesionNombre?: String` | — | Textos de la esp. 3.4; "Esta pantalla cambiará sola" |
| `Proponer.vue` | `sesion`, `config`, `textos`, `fechaLimite`, `capacidadesPe: Array`, `apuestas: Array`, `misApuestas: Array`, `progreso`, `enviado: Boolean`, `urls` | `enviado` | Fecha límite; "Apuestas ya propuestas" (tarjetas anónimas plegables con las cuatro partes, cuadrante, capacidad enlazada); "Mis apuestas" (máx. `config.max_apuestas_por_persona`): por apuesta `FormularioApuesta` (título + cuatro `CampoArea` con el rótulo fijo del formato y contador, selector de capacidad con texto libre, renuncia opcional, cuadrante opcional) con autoguardado (PATCH `urls.apuesta`), "Agregar apuesta" (POST `urls.apuestas`), quitar; "Enviar mis apuestas" (con 0: "Enviar sin proponer"); tras enviar, lo propio en solo lectura con "Enviada" |
| `FormularioApuesta.vue` | `apuesta: Object`, `textos`, `capacidadesPe: Array`, `soloLectura?: Boolean`, `guardando?: Boolean` | `update:apuesta`, `quitar` | El formulario de cuatro partes; lo usa `Proponer` y también `FacApuestas` (facilitador) |
| `Evaluar.vue` | `sesion`, `config`, `textos`, `fechaLimite`, `apuestas: Array`, `misEvaluaciones: Object`, `progreso`, `enviado`, `urls` | `enviado` | Barra fija "n de N"; una `Tarjeta` por apuesta (`id="apuesta-{id}"`) con las cuatro partes plegables, dos filas de `Opcion` tipo chip 1..`config.escala` con rótulos de `textos.escala`, `CampoArea` comentario; autoguardado por toque (POST `urls.evaluaciones` con una fila); "Enviar mi evaluación" deshabilitado hasta N de N; la primera sin evaluar se resalta |
| `Seleccion.vue` | `sesion`, `config`, `ranking: Array`, `urls` | — | Ranking de solo lectura (rango, título, `BarraProgreso` de puntaje sobre `escala`, impacto, viabilidad, n, "Empate", "Elegida" resaltada, "propuesta por mí"); sondeo propio a `urls.ranking` cada 5 s; "El facilitador está eligiendo en la sesión virtual" |
| `Okr.vue` | `sesion`, `elegidas: Array`, `urls` | — | Por apuesta elegida: cuatro partes plegables, objetivo, lista de KR (texto, métrica, línea base, meta, fecha, dueño) con botón "Lo asumo como dueño" / "Ya me postulé" (POST `urls.asumir`), `CampoArea` "Su comentario" con autoguardado (PUT `urls.comentario_okr`); estado "por completar" en KR incompletos |
| `Cierre.vue` | `resultados: Object|null`, `resumenPublico: Object|null`, `sesionNombre?: String` | — | "Estrategia v1.0: apuestas y OKR": aspiración, las elegidas en orden con sus cuatro partes y sus OKR (objetivo, KR con meta, fecha y dueño); sin nombres de evaluadores |
| `FacApuestas.vue` (facilitador) | `apuestas: Array`, `capacidadesPe: Array`, `fase: String`, `ranking: Array|null`, `config`, `urls`, `iaConfigurada: Boolean`, `propuestasIa: Array` | `cambio` | Curaduría: casillas, "Agregar apuesta" (`Modal` con `FormularioApuesta`), editar en línea, "Fusionar (2)", ojo tachado, "Elegida", "Enlazar capacidad", cuadrante, "Borrador incompleto", puntaje/n, "Redactar semillas con IA" y "Aplicar"; todo contra `urls.curar`, `urls.semillas_ia`, `urls.semillas_aplicar` |
| `FacOkrEditor.vue` (facilitador) | `elegida: Object`, `participantes: Array`, `cargosSugeridos: Array`, `krPorApuesta`, `maxKr`, `horizonte`, `iaConfigurada`, `urls` | `guardado` | El editor de una apuesta descrito en 5.2 |
| `Ranking.vue` (compartido) | `filas: Array`, `escala: Number`, `maxElegidas: Number`, `conComentarios?: Boolean`, `grande?: Boolean` | — | Barras del ranking; lo usan Monitor, Panel, Resultados y `Seleccion` |

Utilidades: `resources/js/Components/Ap/utilidades.js` exporta `FASES` (las 6), `etiquetaFase`, `ETIQUETA_AVANCE`, `CUADRANTES`, `etiquetaCuadrante`, `FUENTES`, `etiquetaFuente`, `PARTES` (con rótulo fijo de cada parte), `fraseApuesta(apuesta)` ("Si hacemos …, con la capacidad …, entonces …; lo sabremos por …"), `formatoPuntaje(n)` (2 decimales, coma decimal); reexporta `rutaSegura`, `mensajeDeError`, `horaCorta`, `fechaCorta`, `iniciales` desde `Components/Dofa/utilidades.js` y `etiquetaTipo` desde `Components/Pe/utilidades.js`.

---

## 7. Autorización

| Rutas | Regla |
|---|---|
| Todo `routes/ap.php` | `web` + `auth` + `acceso.vigente` |
| Facilitador (`projects/{project}/ap/*`) | `$user->can('facilitar_dofa')` y `sesion.project_id === project.id` (404 si no). `App\Policies\ApSessionPolicy` con `facilitar(User, ApSession, ?Project)`, `participar(User, ApSession)`, `ver(User, ApSession)`, registrada igual que `PeSessionPolicy` |
| Participante (`ap/sesiones/{sesion}/*`) | `participar_dofa` + `ApParticipant` con rol participante (403); fase de la tabla 3 (409); no enviado salvo config (409 `ya_enviado`) en `apuestas.*` y `evaluaciones.guardar` |
| `ap.apuestas.update/destroy` | además autor (`user_id === user.id`, 403) y no curada: no fusionada ni oculta por el facilitador (409 `curada`) |
| `ap.okr.comentar`, `ap.okr.asumir` | apuesta/KR de una apuesta `elegida` de la sesión (422/404); sin marca de envío |
| `ap.ranking` | participante inscrito; fases `seleccionar..cerrada` (409 antes) |
| `ap.sesiones.vigencia` | `facilitar_dofa`; solo modifica `acceso_expira_en` de usuarios inscritos en la sesión con `es_participante_retiro` |
| `ap.monitor`, `ap.resultados*`, `ap.export.*`, `ap.calcular`, `ap.okr*` (facilitador) | `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 `ap.panel` cuando el módulo es AP, y no se le inscribe |
| Nombres de personas | solo en `ap.panel`, `ap.sesiones.estado` sin `?monitor=1`, `Ap/Okr` (selector de dueño y postulantes) y `Ap/Resultados` (pestaña por participante, iniciales). Los dueños de KR aparecen con `duenoEtiqueta()` en todo lo público (esp. 4.5 y decisión 12.8). Nunca autores de apuestas, evaluadores, comentaristas ni postulantes en monitor, celular, PDF ni JSON |

---

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

- Proyecto activo = `Project::activo()`.
- Sesión AP activa = `ApSession::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 9). Ejemplos esta semana: lunes, PE 1 cerrada y AP 1 en `configuracion` → PE (los celulares siguen en el cierre de PE); martes, AP 1 en `proponer` → AP; jueves, AP 1 cerrada (abierta el martes, después que PE 1 y DOFA 32) → AP; AP 1 cerrada y AP 2 creada en `configuracion` → `activaPara` devuelve AP 2 → PE (el facilitador debe abrir AP 2 o borrarla; 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=ap
docker compose -f docker/compose.yaml exec -T app php artisan test --filter Ap
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_ap.php --participantes=3
docker compose -f docker/compose.yaml exec -T app npm run build     # SOLO el integrador, una vez, al final
```

`docker/ensayo_ap.php` (Acceso/Integración) recorre por HTTP real: crear sesión AP sobre la PE cerrada del escenario (o la crea y cierra con `ensayo_pe.php --conservar` si no hay), semillas a mano (y con `--ia` por API), N participantes por código, las 6 fases con escrituras, reentradas (cierra y vuelve a abrir la sesión HTTP de un participante en `proponer` y `evaluar` y comprueba que ve lo guardado), envíos, curaduría (fusión con evaluaciones), extensión de vigencia (comprueba que `codigo_acceso` no cambió), elegidas, OKR (y `--ia`), comentario y postulación, cierre, PDF y JSON; termina en "SIN FALLOS" y borra lo que creó salvo con `--conservar`.
