CHEFMANAGER IA · V3.13.0 · VALIDACIÓN
COSTEO DE EMPAQUES, ALERTAS DE PRECIO, INGENIERÍA DE MENÚ Y CONTEO FÍSICO
================================================================================

METODOLOGÍA

Igual que en entregas anteriores: servidor PHP real (`php -S
127.0.0.1:8098`) sobre una copia aislada del paquete (/tmp/httptest/maestro,
nunca el paquete real que se entrega), con `force_https` desactivado solo en
esa copia, contra el mismo SQLite que usa el servidor web
(/tmp/httptest/chefmanger_private/chefmanger.sqlite). Datos sembrados con un
script de fixture PHP que fija `$_SERVER['DOCUMENT_ROOT']` para escribir en
la misma base que usa el servidor. Todo el flujo se probó con peticiones
HTTP reales (curl con cookies de sesión y cabecera `X-CSRF-Token`, tomado de
la respuesta real de `auth/login`). Se verificó sintaxis de los archivos PHP
modificados con `php -l` y del JavaScript modificado (`app.js`) con `node
--check` antes de considerar cualquier cambio completo. Tras la corrida
completa se verificó `PRAGMA integrity_check` y `PRAGMA foreign_key_check`
sobre el SQLite de prueba.

LIMITACIÓN DE ENTORNO PARA PRUEBA DE NAVEGADOR REAL (documentada, no un
defecto del código): esta sesión de trabajo se ejecuta detrás de un proxy de
egreso que bloquea el acceso general a Internet (confirmado con `curl` a
`cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `registry.npmjs.org`,
`raw.githubusercontent.com`, `unpkg.com` y hasta `example.com`: los cinco
devuelven 403 en el `CONNECT` del proxy). El panel administrativo de
ChefManager IA carga React/ReactDOM 18 desde esas mismas CDN en tiempo de
ejecución (`bootstrap-loader.js`, sin cambios en esta entrega); sin acceso a
esas CDN, Chromium vía Playwright solo puede llegar hasta el mensaje "No se
pudo cargar React desde los proveedores de respaldo" (capturado en
`shot_sandbox_cdn_blocked.png`) y nunca llega a renderizar ninguna página de
React, incluidas las cuatro nuevas de esta entrega. Esto reproduce
exactamente el mismo límite que ya aplicaba a todas las entregas anteriores
para el panel administrativo (las validaciones con Chromium de 3.12.0 se
hicieron sobre `reserva.php`, la única página pública que NO depende de
React). Por esta razón, la validación de las cuatro páginas nuevas del panel
(Conteos físicos, Ingeniería de menú, Órdenes de compra, Mermas y pérdidas)
se hizo con el mismo método real y verificable que ya usa el proyecto para
todo el panel administrativo en cada entrega: peticiones HTTP reales
end-to-end contra el backend real (exactamente el mismo backend que
consumen esas páginas de React) más lectura directa del SQLite resultante,
y se confirmó por separado que el JavaScript de las cuatro páginas nuevas
pasa `node --check` sin errores de sintaxis y quedó correctamente cableado
en `pagePlanModules`, `businessNavGroups` y el switch de enrutamiento de
`app.js` (verificado por inspección de código, ver punto 7).

Datos de la corrida principal: restaurante "V313B Test" (id=10), sucursal
principal (id=135, código MAIN), ingrediente "Harina" (id=1304, unit=kg,
cost_per_unit inicial=5.0), usuario dueño v313b-owner@test.local con permiso
`recipes.manage`/`inventory.adjust`.

1) CONVERSIÓN DE UNIDAD EN COMPRAS Y COSTO RECALCULADO

- Se configuró `unit_conversions`: `POST unit-conversions`
  {"from_unit":"caja","to_unit":"kg","factor":10} → 201, id=3.
- `POST purchases` con una línea {ingredient_id:1304, quantity:2,
  unit_cost:60, unit:"caja"} → 201, id=3, `total:120` (2 × 60). Confirmado
  en `GET purchases/3` que la línea quedó con `purchase_unit:"caja"`,
  `ingredient_unit:"kg"`.
- `POST purchases/3/receive` con lote/vencimiento → 200 "Compra recibida,
  lotes registrados e inventario actualizado.".
- Verificado en `GET inventory`: `quantity:20` para Harina — exactamente 2
  cajas × 10 kg/caja = 20 kg, NO 2 (lo que habría pasado sin la conversión).
- Verificado en `GET ingredients/1304`: `cost_per_unit` pasó de 5.0 a EXACTO
  6.0 — 120 (costo total de la línea) ÷ 20 (cantidad YA convertida a kg), no
  120 ÷ 2 (lo que habría dado 60/kg, un costo absurdo si no se hubiera
  convertido la cantidad antes de dividir).

2) COSTEO DE EMPAQUES SUMADO A LA RECETA

- `POST packaging-items` {"name":"Bolsa","unit":"unit","cost_per_unit":0.5}
  → 201, id=2.
- `POST recipes` "Pan V313": `yield_quantity:10`, `overhead_cost:1`, 2 kg de
  Harina (a 6.0/kg tras el punto 1) → id=12. Costo de ingredientes del lote:
  2 × 6.0 = 12.
- `PUT recipe-packaging/12` {"items":[{"packaging_item_id":2,"quantity":1}]}
  → 200 "Empaque de la receta actualizado.".
- `POST products` {"name":"Pan V313","price":5,"recipe_id":12} → 201,
  id=2451.
- `GET costing/products` → el producto muestra `recipe_cost:1.35`,
  `packaging_cost:0.05`. Verificación aritmética exacta: costo total del
  lote = 12 (ingredientes) + 1 (overhead) + 0.5 (1 bolsa × 0.5) = 13.5;
  13.5 ÷ 10 porciones = 1.35 por porción; empaque por porción = 0.5 ÷ 10 =
  0.05. `gross_margin:3.65` (5 − 1.35), `gross_margin_percent:73`,
  `food_cost_percent:27`, `cost_status:"healthy"` — coincide exactamente con
  el cálculo esperado con empaque incluido.

3) ALERTA DE FLUCTUACIÓN DE PRECIO

- Umbral por defecto confirmado en `GET costing/products`:
  `price_fluctuation_threshold_percent:15`.
- El cambio de costo del punto 1 (5.0 → 6.0 = +20.0%) superó el 15% por
  defecto. Verificado en `GET ingredient-cost-history/1304`: fila con
  `old_cost:5`, `new_cost:6`, `change_percent:20`, `source:"purchase"`,
  `reference_id:3` (la compra que lo originó).
- Verificado en `GET notifications`: notificación `type:"price_fluctuation"`
  con título "Fluctuación de precio: Harina" y cuerpo "Harina subió 20.0%
  (de 5.0000 a 6.0000).", generada para el usuario dueño (único usuario
  activo del restaurante de prueba, rol `owner` incluido en la lista de
  roles notificados).

4) INGENIERÍA DE MENÚ

- `GET costing/menu-engineering?days=30` → 200. Con un solo producto
  clasificable (el "Pan V313" del punto 2, 0 unidades vendidas en el
  período): `median_margin_percent:73`, `median_units_sold:0`,
  `summary:{"stars":1,"plowhorses":0,"puzzles":0,"dogs":0,"unclassified":0}`,
  el producto aparece con `quadrant:"star"` — correcto: con un solo elemento
  en el catálogo, su propio margen y volumen SON la mediana, por lo que
  siempre queda en "alto/alto" (⭐ Estrella) salvo que tenga error de costo.
  `unclassified_items:[]` confirma que no hubo productos sin receta/precio/
  conversión en esta corrida.
- Se verificó por separado con el endpoint de costeo (mismo restaurante de
  prueba usado en rondas anteriores de esta sesión, id=7, con más de un
  producto) que la ruta responde 200 con la URL correctamente formada como
  `path=costing/menu-engineering&days=30` (un parámetro adicional en la
  query, no concatenado dentro de `path`); devuelve los cuatro cuadrantes en
  `summary` sin error 404 ni excepción PHP.

5) CONTEOS FÍSICOS DE INVENTARIO — CICLO COMPLETO CON DESVÍO REAL

Escenario numérico exacto:

a) Antes del conteo, `GET inventory` → `quantity:19` para Harina (20 del
   punto 1, menos 1 kg registrado como merma aprovechable en el punto 6).
b) `POST inventory-counts` {"branch_id":135,"ingredient_ids":[1304],
   "notes":"Conteo mensual V313B"} → 201, id=2, mensaje "Conteo físico
   iniciado. La existencia teórica quedó congelada para cada ingrediente
   incluido.". Verificado en `GET inventory-counts/2`: `expected_quantity:
   19` para Harina — coincide exactamente con el stock del sistema en el
   instante de creación (congelado, no recalculado después).
c) `PUT inventory-counts/2` {"items":[{"ingredient_id":1304,
   "counted_quantity":15}]} → 200 "Conteo actualizado.".
d) `POST inventory-counts/2/complete` → 200 "Conteo completado. Inventario
   ajustado a la cantidad contada.", con
   `deviations:[{"ingredient_id":1304,"ingredient_name":"Harina",
   "variance":-4,"variance_cost":-24,"deviation_percent":21.05}]` y
   `total_variance_cost:-24`. Aritmética exacta: 15 (contado) − 19
   (teórico) = −4 kg; −4 × 6.0 (cost_per_unit) = −24; −4 ÷ 19 × 100 =
   21.05% — EXACTAMENTE el desvío real, y por encima del 10% que dispara la
   clasificación como desvío relevante.
e) Verificado en `GET inventory` tras completar: `quantity:15` — el stock se
   ajustó exactamente a la cantidad contada, no a una resta manual.
f) Verificado en `GET inventory-waste`: se creó automáticamente la fila
   id=4, `quantity:4`, `loss_type:"loss"`, `reason_code:
   "inventory_difference"`, `destination:"non_usable"`,
   `estimated_cost:24`, `alert_required:1`, `notes:"Diferencia detectada en
   conteo físico #2 (21.1% de desvío)."` — generada solo por el faltante,
   sin intervención manual.
g) Verificado en `GET notifications`: notificación `type:
   "inventory_deviation"`, título "Desvíos de inventario detectados en
   conteo físico", cuerpo "1 insumo(s) con faltante ≥10% respecto al
   teórico. Costo estimado del desvío: 24.00." — coincide con el desvío
   real calculado en (d).

6) CLASIFICACIÓN APROVECHABLE / NO APROVECHABLE DE MERMAS

- `POST inventory-waste` {"branch_id":135,"ingredient_id":1304,
  "quantity":1,"loss_type":"waste","reason_code":"damage",
  "destination":"usable","notes":"Sobrante aprovechable para otra
  preparación"} → 201, id=3, `estimated_cost:6` (1 × 6.0). Verificado en
  `GET inventory-waste`: la fila id=3 quedó con `destination:"usable"`,
  distinta de la fila id=4 del punto 5f (`destination:"non_usable"`,
  generada automáticamente) — confirma que la clasificación aprovechable/no
  aprovechable se guarda y se distingue correctamente entre alta manual y
  alta automática por conteo.

7) SINCRONIZACIÓN DE LAS PÁGINAS NUEVAS DEL PANEL (inspección de código,
   complementaria al punto de limitación de entorno)

Confirmado en `public/assets/app.js`: las cuatro páginas nuevas
(`MenuEngineeringPage`, `RecipePackagingPage`, `IngredientCostHistoryPage`,
`InventoryCountsPage`) están definidas como componentes React completos,
registradas en el switch de enrutamiento (`case
'inventory_counts':return h(InventoryCountsPage);` y análogos para las
otras tres), agregadas a `businessNavGroups` bajo "Costos y recetas" e
"Inventario" con su ícono y permiso correspondiente
(`ingredients.manage`/`recipes.manage`/`inventory.adjust`), y mapeadas en la
tabla de módulos de plan (`pagePlanModules`) junto al resto de páginas de
costeo/inventario existentes. `PurchasesPage` y `WastePage` existentes
siguen las mismas convenciones de componente que el resto del archivo; sus
cambios (campo "Unidad de compra"/nota de conversión y campo/columna
"Destino") se verificaron línea por línea contra el backend probado en los
puntos 1 y 6.

8) INTEGRIDAD DE LA BASE DE DATOS

Tras toda la corrida (conversión de compras, empaque de recetas, alertas de
precio, ingeniería de menú, conteo físico con desvío, mermas manuales y
automáticas): `PRAGMA integrity_check` → `ok`. `PRAGMA foreign_key_check` →
sin filas (sin violaciones de llave foránea).

9) SINTAXIS

`php -l` sin errores en: `app/Support/Database.php`, `api/index.php`,
`app/Services/ExtendedModules.php`. `node --check` sin errores en
`public/assets/app.js` (incluye las cuatro páginas nuevas y los cambios en
Órdenes de compra, Mermas y pérdidas, y Costeo de productos).

ALCANCE NO PROBADO EN ESTA RONDA (documentado, no un defecto)

- Renderizado real en navegador (Chromium) de las cuatro páginas nuevas del
  panel administrativo: bloqueado por la restricción de egreso del entorno
  de esta sesión de trabajo hacia las CDN de React/ReactDOM, no por un
  defecto del código — ver "LIMITACIÓN DE ENTORNO" al inicio de este
  documento. El backend que esas páginas consumen SÍ se validó de forma
  completa y real (puntos 1 a 6).
- Propagación de costo de empaque a través de subrecetas (ver ALCANCE NO
  CUBIERTO en docs/RELEASE-3.13.0.txt).
- La condición de carrera de ventas ocurriendo durante la ventana abierta de
  un conteo físico (`expected_quantity` se congela al crear el conteo; el
  stock real puede seguir moviéndose mientras el conteo sigue en `draft`).
- Un catálogo con más de un producto y ventas reales para ver los cuatro
  cuadrantes de ingeniería de menú simultáneamente poblados con datos
  distintos entre sí (la corrida principal usó un solo producto de prueba,
  que por definición matemática cae siempre en el cuadrante "Estrella"; el
  cálculo de la mediana y la fórmula de clasificación se revisaron por
  separado en el código, punto 4).
