# Fermenty — Informe Fase 0: El mentor en el Panel

> Entregable de la fase 0 del briefing `docs/Mentor_en_Panel.md` (§2).
> Análisis de solo lectura sobre `main` @ `60f4563` · 2026-08-03.
> Cada afirmación con `ruta:línea`. Lo que no existe se declara ausente, no se supone.

---

## Veredicto: Camino A, con tres matices

**El formulador ya es un servicio con entrada y salida limpias, y ya tiene un solo
dueño.** `Batch::create` aparece en un único punto de todo `app/`
(`app/Services/BatchService.php:86`), y todos los caminos —Panel, API/PWA, borrador—
desembocan en `BatchService::formular()` + `arrancar()`. No hace falta extracción
(fase 1). El trabajo real es la fase 2: la pantalla.

Más aún: **el `store` actual del Panel ya pasa por el motor.**
`app/Http/Controllers/BatchController.php:219` llama a `BatchService::create()`, que
es literalmente `formular()` + `arrancar()` sin pausa (`BatchService.php:44-56`, con
el porqué escrito en el comentario `:46-50`). `formular()` invoca siempre
`formularConMotor()` (`BatchService.php:109`), que congela `formulacion` si la guía
tiene ficha (`:167-192`).

Los tres matices que impiden decir «ya está hecho»:

1. **El Panel no hace las preguntas.** Su `store` no acepta `perfil` ni `contexto`
   (`BatchController.php:184-191`), así que el motor cae al `perfil_defecto` de la
   receta y a `ficha.tempRef ?? 23` con confianza media (`BatchService.php:184-187`).
   La PWA sí los manda (`Api/V1/BatchController.php:120-136`). Misma fila de origen,
   distinta entrada: hoy formular «lo mismo» desde las dos superficies **no** produce
   filas idénticas — no porque haya dos dueños, sino porque el Panel no pregunta.
2. **El Panel no tiene el ciclo de borrador.** `formular`/`arrancar` como gestos
   separados solo existen en la API (`routes/api.php:81-82`). Y los borradores que la
   PWA crea son **invisibles** en el Panel: su listado solo filtra
   `active|paused|completed` (`BatchController.php:27`).
3. **El Panel tiene un extra que la PWA no tiene:** si el usuario es premium y manda
   temperatura o duración, tras crear se llama a `reschedule()`
   (`BatchController.php:223-229`), que reescala los pasos **leyendo la guía viva**
   (`BatchService.php:958-960`) y escribe `fermentation_temp` + evento. Es una fuente
   de divergencia entre superficies que la fase 2 debe resolver (la temperatura ya
   entra por la formulación; el ajuste posterior sobra en el camino con ficha).

La capa que **sí** hay que tocar antes de la pantalla no es el formulador: es que
parte del *gating* vive en el controlador de la API y habría que duplicarlo en un
controlador web (ver apartado d y «Trabajo que abre este informe»).

---

## Nota previa: la documentación citada no existe

El briefing y el propio código citan documentos que no están en el repo:

| Documento citado | Citado desde | Estado |
|---|---|---|
| `docs/Planes.md` | briefing | no existía al redactar el informe; **añadido 2026-08-03** |
| `docs/Encaminamiento.md` | briefing | no existía al redactar el informe; **añadido 2026-08-03** |
| `docs/Afinador_Web.md` | briefing; `AfinadorPublico.php:12`, `AfinadorController.php:18`, `afinador.js:3`, `AfinadorPropuesta.php:10`, `AfinadorFeedback.php:8` | no existía al redactar el informe; **añadido 2026-08-03** |
| `docs/Lexico.md` | briefing | existe como `docs/01_LEXICO.md` (secciones coinciden con lo citado) |
| `docs/Dominios_Navegacion.md` | `routes/panel.php:4`, `routes/publico.php:5`, `app/Support/Superficie.php:17` | no existía al redactar el informe; **añadido 2026-08-03** (reconstruido, no recuperado — lo dice su cabecera) |
| `docs/Precios.md` | `config/billing.php:47,72,103`, `BillingService.php:30,78` | **no existe** |
| `docs/paginas/pro.md` | `config/billing.php:117`, `suscripcion/index.blade.php:1` | **no existe** |

*(El informe se redactó contra el árbol del 2026-08-03 por la mañana, sin estos
documentos; el análisis de código no cambia por su llegada.)*

Todo lo que sigue se responde contra el código, no contra esos documentos.

---

## a) Dónde vive hoy la formulación en la PWA

**Qué se encontró.** Es un servicio, en tres capas con un solo punto de entrada:

- `App\Mentor\Formulador::paraReceta(Recipe $recipe, ?array $override, float $temp, float $volumen, string $confianza = 'media'): array`
  — `app/Mentor/Formulador.php:26`. Devuelve `{entrada, formulacion, salida}` (`:71`).
- `App\Mentor\Motor::calcular(ModeloFicha $modelo, array $ficha, array $eleccion): array`
  — `app/Mentor/Motor.php:26`. Función estática pura, sin BD. `Motor::VERSION = '1.0.0'` (`:20`).
  Salida: `cantidades · ventanas · lo_que_pasa · avisos · trueques · version` (`:66-76`).
- La aritmética real: `ModeloSolucionAzucarada::formular(array $f, array $e): array`
  — `app/Mentor/Modelos/ModeloSolucionAzucarada.php:263` (núcleo crudo de 36 claves, `:376-388`).

El **único** punto de todo el repo que invoca al Formulador es
`app/Services/BatchService.php:191`, dentro de `formularConMotor()`. Las llamadas a
`Motor::calcular()` están todas en `BatchService` (`:237, 254, 347, 369, 484, 551`),
ninguna en controlador ni vista.

La PWA no llama a PHP: `routes/mobile.php` es carcasa Alpine (`:4-7`), la ruta de
formular es `Route::view` (`:42`) y el dato viaja por la API v1:

| Endpoint | Ruta | Controlador |
|---|---|---|
| `GET /v1/recetas/{slug}/formular` | `routes/api.php:52` | `Api/V1/RecipeController.php:122` |
| `POST /v1/batches/formular` | `routes/api.php:81` | `Api/V1/BatchController.php:116` |
| `POST /v1/batches/{batch}/arrancar` | `routes/api.php:82` | `Api/V1/BatchController.php:173` |
| `GET /v1/batches/{batch}/formula` | `routes/api.php:84` | `Api/V1/BatchController.php:200` |

**Cálculo del motor en JavaScript de cliente: no hay.** `formular.blade.php:226-375`
es presentación (agrupar, formatear coma, redactar ventana desde `[lo,hi]` ya
calculados); `public/mobile/api.js` es cliente HTTP puro. Salvedad fuera del motor:
las calculadoras del Laboratorio **sí** duplican fórmulas y coeficientes en JS
(`lab-alcohol.blade.php:87-101`, `lab-salmuera.blade.php:86-102`, y en escritorio
`laboratorio/salmuera.blade.php:169` baja `@json(BrineCalculator::GUSTO_FACTORS)`).

**El ciclo de borrador existe** y no es tabla aparte: es `batches.status='draft'`
con `start_date` null (`2026_07_23_100010_restructure_batches.php:47-52`, CHECK en
`2026_07_23_100011:25-28`). Caducidad a 14 días (`Batch::DRAFT_STALE_DAYS`,
`app/Models/Batch.php:19,104-110`).

**Qué falta.**
- No hay DTOs: todo el contrato Formulador↔Motor↔BatchService es `array` sin tipar.
- La interfaz `ModeloFicha` (`app/Mentor/Modelos/ModeloFicha.php:19-63`) **no declara
  `formular()`**, que es justo el método que `Motor::calcular()` invoca (`Motor.php:28`).
- `App\Mentor\Mentor` es vestigio: su único método (`factorDuracionTemperatura`,
  `Mentor.php:25`) solo lo usa el camino legacy de `reschedule()`; cero invocaciones
  desde controladores.
- La reformulación de un borrador desfasado («otra como aquella») está prometida en
  el docblock (`Batch.php:100-101`) y **no existe**.

---

## b) Qué ocurre exactamente al «empezar tanda»

**Qué se encontró.** Un solo motor de creación, dos fases:

**`formular()`** (`BatchService.php:75-113`, transacción):
1. INSERT `batches` con `status='draft'`, sin fecha (`:86-97`).
2. INSERT `batch_steps` desde los pasos de la **guía** (obligatorios + opcionales que
   la receta encendió) — `copySteps()` `:832-846`.
3. INSERT `batch_ingredients` desde la **receta**, escalados por volumen
   (`snapshotIngredients()` `:806`, `ScalingService.php:63-77`).
4. Si la guía tiene ficha: UPDATE `batches.formulacion` = `{entrada, ficha completa,
   version{motor, sha1(ficha)[0:8]}}` (`:192`, construido en `Formulador.php:65-69`);
   UPDATE de cantidades F1 en las líneas casadas por `ingredient_id` (`:195-204`);
   DELETE de líneas 2F (`:207`, la 2F es intención hasta el corte); trueques →
   `insights` (`:209-217`).

**`arrancar()`** (`BatchService.php:124-160`, transacción):
5. UPDATE `batches`: `batch_code` (regenerado si cambia el día), `status='active'`,
   `start_date`, `expected_end_date` (= start + `guide.default_duration_days`,
   **leído de la guía viva**), `forecast` json del motor (`:136-144`).
6. INSERT `fermenty_notifications` según `notification_rules` de la guía (`:146-151`).
7. INSERT `batch_events` tipo `start` (`:155`); `recipes.times_brewed` +1 (`:156`).

**El snapshot es casi autónomo.** Congelado de verdad: pasos con título, días
relativos y acciones (`batch_steps`, sin fallback a la guía —
`BatchStep.php:36-48`), ingredientes escalados, formulación entera (entrada + ficha
+ versión) y pronóstico. Pero hay lecturas de lo vivo después de arrancar:

| # | Se resuelve leyendo lo vivo | Dónde |
|---|---|---|
| 1 | Nombre/slug/categoría del fermento | `BatchResource.php:43-52`, `batches/index.blade.php:201,224`, `show.blade.php:768` |
| 2 | `guide.default_duration_days` (fin previsto, `totalDays`, respaldo de progreso) | `BatchService.php:140`, `BatchController.php:298`, `show.blade.php:718` |
| 3 | Hints de paso premium (no hay columna en `batch_steps`) | `BatchController.php:287`, `show.blade.php:326-330` |
| 4 | `reschedule()` reescala desde `guideStep` vivo, con preferencia sobre el snapshot | `BatchService.php:958-960` |
| 5 | Aviso de paso vencido lee `guideStep->max_day` vivo, ignorando el congelado | `NotificationService.php:174-180` |
| 6 | El set de `notification_rules` se consulta vivo en cada activación de paso | `NotificationService.php:108-113` |
| 7 | Ficha con fallback a la viva en tandas sin `formulacion` | `BatchService.php:229,248,297,339,362,468`, `BatchResource.php:103,162` |

Además:
- **No existe columna `motor_version`**: la versión vive solo dentro de los JSON
  `formulacion.version` y `forecast.version`, y queda **NULL** en tandas de guías sin
  ficha (`BatchService.php:170-172`) — el `store` legacy no exige ficha.
- La temperatura que usó el motor **no** se persiste en `batches.fermentation_temp`
  (esa columna solo la escribe `reschedule()`); vive en `formulacion.entrada.temp`.
- Si la receta no tiene `base_yield_*`, `snapshotIngredients()` retorna sin escribir
  **nada** (`BatchService.php:788-790`): tanda sin ingredientes congelados.
- Si el motor propone un ingrediente sin línea previa en la receta, el UPDATE por
  `ingredient_id` no crea filas (`:199-203`): esa cantidad F1 no se congela en líneas
  (queda solo en `formulacion` y se reconstruye en `formulaExpuesta()`).

**Qué falta.** Columna o criterio único de versión consultable; snapshot de hints;
congelar `default_duration_days`; decidir si los puntos 4-6 son deuda o diseño.

---

## c) Si el Panel ya tiene camino para crear tandas

**Qué se encontró. Sí, completo y propio:** `GET /panel/batches/crear` +
`POST /panel/batches` (`routes/panel.php:75-76`), formulario de dos pasos en
`resources/views/batches/create.blade.php`, y `store` en
`app/Http/Controllers/BatchController.php:178-234`. **No es un tercer dueño**: llama
a `BatchService::create()` (`:219`), el mismo embudo. Grep negativo confirmado: no
hay `new Batch`, `Batch::insert` ni inserts crudos en ningún controlador; Admin no
crea tandas.

Puntos de entrada «Nuevo lote» ya cableados (todos a `batches.create`, ninguno a la
PWA): barra superior (`layouts/app.blade.php:96`), dashboard (`:107,164`), listado
(`index.blade.php:168,189`), planificador (`planner/index.blade.php:28-29`),
notificaciones, y **ficha de receta con `?recipe={id}` preseleccionado**
(`recetas/show.blade.php:54,167`) — éste es el enganche natural de la fase 2 (§7c
del briefing).

Estructura de pantallas del Panel: Dashboard · Mis lotes (listado + ficha + crear) ·
Planificador · Fermentos/Recetas · Laboratorio · Comunidad · Perfil · Suscripción ·
Notificaciones (`routes/panel.php:41-161`).

**Qué falta.**
- El ciclo de borrador no existe en el Panel (solo `create()` de un tirón).
- El listado no admite `draft` ni `discarded` (`BatchController.php:27` vs enum real
  en `2026_07_23_100010:49`): los borradores de la PWA no se ven desde el Panel.
- El extra premium de `reschedule()` tras crear (`BatchController.php:223-229`) es
  exclusivo del Panel y diverge del camino PWA (ver veredicto, matiz 3).

---

## d) Dónde se comprueban los candados

**Qué se encontró.** La **lectura** del límite está centralizada y ya se lee del
plan — el briefing temía un `3` cableado; no existe, **y el número es 2**:

```
config/billing.php:52-65      free.tandas_activas = 2 · pro.tandas_activas = null
BillingService.php:87-90      tandasActivas(User)
User.php:239-254              activeBatchLimit() / hasReachedActiveBatchLimit()
```

La **aplicación**, en cambio, está repetida por controlador, sin capa común — no hay
middleware, ni gate; `BatchPolicy::create()` devuelve `true` incondicional
(`app/Policies/BatchPolicy.php:30-33`):

| Sitio | Superficie |
|---|---|
| `BatchController.php:160-162` (`create`) y `:180-182` (`store`) | Panel |
| `Api/V1/BatchController.php:67-72` (`store`) y `:181-186` (`arrancar`) | PWA |
| `FermentController.php:39` | **código muerto** (controlador sin ruta) |

Matices:
- `POST /v1/batches/formular` **no** comprueba el límite — deliberado y documentado
  (`Api/V1/BatchController.php:107-110`): el borrador no cuenta. Consecuencia: los
  borradores no tienen techo.
- **Definición de «tanda activa»: estricta, `status='active'` y nada más**
  (`Batch.php:49-52`, contada en `User.php:231-234`). La 2F no es estado — una tanda
  en 2F sigue `active` y **cuenta**; `draft`, `paused`, `completed` (valorada o no) y
  `discarded` **no cuentan**. Responde a la decisión abierta §7a del briefing: el
  número ya es defendible en pantalla («las que están fermentando ahora»), con la
  rendija de que pausar libera hueco.
- **«Recetas especiales» no existe como dato.** Sin columna, sin criterio
  (migraciones de `recipes`: `visibility`, `status`, `is_official`…), y bloqueado a
  conciencia en `pro.blade.php:109-111`. Lo más parecido es el candado por fermento:
  `fermentation_guides.plan_required` ∈ `{free, premium}`.
- Desalineación de nombres: el plan de pago es `pro` en `config/billing.php` pero
  `users.plan` guarda `premium` (`billing.php:13`, `User.php:142`); el puente lo hace
  `BillingService` y un lookup directo `config('billing.plans.'.$user->plan)` caería
  a `free` en silencio (`BillingService.php:83`).
- Restos: `User::FREE_ACTIVE_BATCH_LIMIT = 2` deprecado sin consumidores
  (`User.php:20-21`); las claves `calibracion` y `ejes` de `config/billing.php:56-63`
  no las lee nadie (candados declarados, no implementados).
- El resto de candados (fermentos premium, quick-log con medidas, fotos de reseña,
  reprogramar, pistas) van todos por `isPremium()` disperso por controlador.

**Qué falta.** La capa común que el briefing exige (§6). Hoy un controlador web
nuevo del Panel tendría que repetir a mano: límite en `arrancar`, puerta
`recipe_sin_ficha`, `canAccess` del fermento, receta publicada. Eso, o mover esas
puertas a `BatchService`/FormRequest compartido — es el único trabajo previo real a
la pantalla.

---

## e) Qué viaja al cliente hoy

**Qué se encontró.** El embudo de exposición es único y de lista blanca:
`App\Mentor\Presentacion::exponer()` (`Presentacion.php:20,29-43`) — emite
`cantidades` (con nombre resuelto), `ventanas`, `lo_que_pasa` (6 valores
redondeados), `avisos` y `trueques` (código + severidad + mensaje, **sin params**),
`version`. Las 36 claves del núcleo crudo no salen nunca. El test
`tests/Unit/FormulacionNoFiltraTest.php:17-35` blinda que `BatchResource` no emite
`formulacion`, `ficha`, `q10`, `kGasAcidez`, `expS0`. El afinador público es aún más
estricto (`AfinadorPublico::recortar()`, `AfinadorPublico.php:90-125`) y cumple su
contrato declarado.

**Grietas concretas (de menor a mayor):**
1. **Coeficientes de ficha en la prosa de los avisos.** `CatalogoAvisos` mete
   `diasF1.min` y `tempIdeal.min/max` en params (`CatalogoAvisos.php:53,88`);
   `Presentacion` descarta los params **después** de renderizarlos en el mensaje
   (`Presentacion.php:36` → `lang/es/motor.php:14,21`). Contradice literalmente el
   docblock de `Motor.php:14-16`. Valores didácticos, pero el contrato no admite
   matices: o se reescribe el contrato, o esas plantillas.
2. **`formula.version.ficha` (hash sha1[0:8]) baja al cliente** sin uso en la UI
   (`Presentacion.php:42`; `formular.blade.php` no lo lee).
3. **Los 4 ejes de cada carta bajan como datos** en `GET /v1/recetas/{slug}/formular`
   (`Cartas.php:37-44`), declarado a propósito (`Cartas.php:13-14`: «los ejes no son
   secreto»). La web pública en cambio no los baja (`Web/AfinadorController.php:41-60`).
   Dado que el briefing declara «los cuatro ejes» vocabulario prohibido en interfaz,
   conviene ratificar o revocar esa decisión — pero es dato de elección del usuario,
   no coeficiente del motor.
4. **Riesgo latente, no fuga:** ni `Batch` ni `FermentationGuide` declaran `$hidden`
   (`Batch.php:21-46`, `FermentationGuide.php:34,43`); `batches.formulacion` contiene
   la ficha **entera**. La no-fuga descansa solo en la disciplina de los Resources:
   un `return $batch` directo lo soltaría todo. Barato de blindar con
   `$hidden = ['formulacion']` + `ficha` en la guía.

**Qué falta.** Nada bloquea la fase 2 — la pantalla del Panel consumiría las mismas
salidas presentables. Las grietas 1-2 y el blindaje 4 son arreglos de la capa común,
válidos para las dos superficies a la vez.

---

## f) Autenticación y sesión

**Qué se encontró.** Un solo guard (`config/auth.php:19,40-42`; Sanctum apunta al
mismo, `config/sanctum.php:40`) y una sola tabla de usuarios, pero **dos mecanismos
y sesiones que no se comparten**:

| | Panel | PWA |
|---|---|---|
| Host | dominio raíz, `/panel` | `app.fermenty.*` |
| Middleware | `web` + `auth` (`routes/panel.php:34`) | solo `web`, guest-first (`routes/mobile.php:5-7`) |
| Credencial | cookie de sesión | token Sanctum en `localStorage` (`layouts/mobile.blade.php:46-51`) |
| Datos | Eloquent directo | `fetch` a `api.fermenty.*` (`routes/api.php:61`, `auth:sanctum`) |

El despacho es por host (`bootstrap/app.php:19-32`). `SESSION_DOMAIN=null` → cookie
host-only: la sesión del Panel **no vale** en `app.` ni en `api.`. Se comparte la
cuenta, no la sesión (tal como declara `routes/panel.php:8`).

**Consecuencia directa para la fase 2:** la pantalla del Panel **no puede llamar a
`/v1/batches/formular` con su sesión** — necesitaría emitir un token o abrir Sanctum
stateful. Lo coherente con lo que ya hay: rutas web en `routes/panel.php` cuyo
controlador llama a `BatchService::formular()/arrancar()` directamente, igual que
hace el controlador de la API. El «un solo camino» del briefing (§4) vive en el
servicio, no en el transporte.

Qué disposición ve el usuario: preferencia explícita (cookie `fm_superficie`) >
heurística de dispositivo, y **nunca** redirección forzada
(`app/Support/Superficie.php:33-41`); solo banner que sugiere
(`partials/superficie-banner.blade.php:11-19`).

**Qué falta / hallazgos colaterales.**
- `routes/mobile.php:25` construye el enlace a la suscripción con `config('app.url')`
  (= `api.fermenty.*` según `.env:5`): el redirect apunta a un host que despacha a la
  API y esa ruta no existe ahí. El helper correcto ya existe (`config('app.web_url')`,
  `config/app.php:67`).
- `app/Providers/RouteServiceProvider.php:15-28` carga `routes/erp.php`, que no
  existe — código muerto (el provider no está registrado), pero rompería si alguien
  lo registrara.
- Cuatro vistas leen `config('app.mobile_url')` saltándose `Superficie`
  (`layouts/app.blade.php:291`, `layouts/web.blade.php:13`,
  `fermentos/show.blade.php:10`, `admin/layouts/admin.blade.php:24`).

---

## Trabajo que abre este informe (propuesta para la parada entre fases)

La fase 1 del briefing (extracción del formulador) **no aplica**: ya está extraído.
Lo que sí queda, en orden de dependencia:

1. **Capa común de candados y puertas** (pequeño, previo a la pantalla): mover a
   `BatchService`/FormRequest lo que hoy repite cada controlador — límite en
   `arrancar`, puerta `recipe_sin_ficha`, `canAccess`, receta publicada. Sin esto, el
   controlador web de formulación nace duplicando cuatro `if`.
2. **La pantalla de formular del Panel** (fase 2): rutas web sobre
   `BatchService::formular()/arrancar()`, mismas preguntas y cartas que la PWA
   (los datos de cartas ya los sirve `Cartas::paraFicha()`), disposición de
   escritorio. Enganche natural: los botones «Nuevo lote» existentes y
   `recetas/show` con `?recipe=`.
3. **Retirar la divergencia del camino viejo del Panel** en guías con ficha: el
   formulario actual de `batches/create` queda para guías sin ficha (misma
   bifurcación que ya hace la PWA en cliente, `batch-create.blade.php:104-107`), y el
   `reschedule()` post-creación premium se retira de ese camino.
4. **Visibilidad de borradores en el Panel** (listado admite `draft`), o la fase 2
   queda coja: formular-hoy-arrancar-el-jueves es el gesto que da sentido al ciclo.
5. **Verificación §9**: formular lo mismo desde ambas superficies y comparar filas
   (`batches.formulacion`, `batch_steps`, `batch_ingredients`, `forecast`) — ahora
   con la garantía de que ambas entran por `BatchService::formular()`.

Aparte, deudas señaladas que no bloquean pero conviene decidir: fugas e1-e2, blindaje
`$hidden` (e4), lecturas de lo vivo (b, puntos 4-6), redirect roto de suscripción en
la PWA (f), y los tres cadáveres (`FermentController` huérfano,
`User::FREE_ACTIVE_BATCH_LIMIT`, `RouteServiceProvider`).

---

*Fase 0 cerrada. Según el briefing (§8), aquí hay una parada: este informe se lee y
se decide antes de seguir.*
