CHEFMANAGER IA · V3.11.0
COSTEO CON CONVERSIÓN DE UNIDADES + PRECIO SUGERIDO
CORRECCIÓN DE BUGS DE TERNARIO (INGREDIENTES Y MANTENIMIENTO)
================================================================================

RESUMEN

Esta entrega corrige un error de exactitud en el cálculo de costo de
recetas (la tabla `unit_conversions` existía pero nunca se aplicaba al
calcular el costo real), agrega un precio de venta sugerido automático
en el módulo de costeo, y corrige cinco instancias adicionales del mismo
patrón de bug de ternario que ya se había encontrado y corregido antes en
Patio de Comidas y Franquicias (ver "CORRECCIONES DE ESTA ENTREGA").

1) COSTEO: CONVERSIÓN DE UNIDADES EN EL CÁLCULO DE COSTO DE RECETAS

Antes de esta entrega, una receta podía declarar una línea de ingrediente
en una unidad distinta a la unidad del ingrediente (por ejemplo, un
ingrediente cargado en `kg` pero usado en la receta en `g`), y el sistema
lo aceptaba sin avisar — pero el cálculo de costo de la receta trataba
esa cantidad como si estuviera en la MISMA unidad del ingrediente, sin
aplicar ninguna conversión. Esto significa que una receta mal cargada
podía mostrar un costo por porción hasta 1000 veces distinto del real
(kg vs. g), y ese número incorrecto alimentaba el food cost %, el margen
bruto y (en la ronda anterior) el precio de venta manual — todos cálculos
en los que un cliente real ya confiaría para tomar decisiones de precio.

La tabla `unit_conversions` (restaurant_id, from_unit, to_unit, factor)
ya existía en el sistema desde una entrega anterior, pero ningún cálculo
de costo la consultaba jamás.

CAMBIO DE ESQUEMA (aditivo, no rompe nada):

- `recipe_ingredients.unit` (TEXT NULL, nueva columna). NULL significa
  "misma unidad que el ingrediente" — que es el estado de TODAS las
  líneas de receta existentes tras la migración, así que ninguna receta
  ya cargada cambia de costo por este cambio. Solo cuando se carga o edita
  una línea con una unidad explícitamente distinta a la del ingrediente
  entra en juego la conversión.

LÓGICA DE CÁLCULO:

- `resolveUnitFactor($pdo,$rid,$fromUnit,$toUnit)`: si `$fromUnit ===
  $toUnit` devuelve 1.0 sin consultar nada. Si no, busca en
  `unit_conversions` (en cualquiera de los dos sentidos: from→to
  directo, o to→from invertido con 1/factor) una fila para ese
  restaurante. Si no encuentra ninguna, lanza un `RuntimeException` con
  un mensaje accionable ("Falta configurar la conversión de unidades de
  X a Y para calcular el costo de esta receta.") en vez de asumir 1:1
  en silencio.
- `recipeRequirements()` y `calculateRecipeCost()` ahora usan ese factor
  para convertir la cantidad de cada línea a la unidad del ingrediente
  antes de multiplicar por su costo unitario.

VALIDACIÓN EN GUARDADO (fail-fast): al crear o editar una receta
(POST/PUT /recipes), si una línea declara una unidad distinta a la del
ingrediente y no existe conversión configurada para ese par, la petición
se rechaza con 409 (transacción revertida) ANTES de guardar nada — el
usuario se entera de inmediato, no cuando alguien intente cobrar o
calcular costos después.

DEGRADACIÓN CONTROLADA EN LECTURA: si una conversión que una receta ya
guardada necesitaba se borra después (caso raro pero posible), los
puntos donde se LEE el costo no fallan con un error 500 genérico:

- Listado de recetas (GET /recipes) y detalle de costeo
  (GET /costing/products): capturan el error y devuelven el campo
  `cost_error` (y, en costeo, `cost_status: "unit_error"`) en vez del
  costo — la interfaz muestra "⚠ Falta conversión" con el mensaje exacto
  como tooltip, en vez de romperse o mostrar un número falso.
- Flujo de pedido/checkout (que ya usaba `recipeRequirements()` para
  verificar disponibilidad y descontar inventario): la excepción se deja
  propagar hasta el manejador global existente de `RuntimeException`,
  que responde 409 con el mensaje. Es decir, un pedido que dependa de una
  receta con una conversión rota falla de forma clara y explícita, en
  vez de descontar inventario con una cantidad incorrecta. Se prefirió
  este fallo honesto (raro, y corregible por el propio negocio
  reconfigurando la conversión) sobre asumir 1:1 en silencio y arriesgar
  un descuadre de inventario o costo indetectable.

2) COSTEO: PRECIO DE VENTA SUGERIDO

`GET /costing/products` ahora calcula, para cada producto con costo
conocido, un `suggested_price = costo / (food_cost_objetivo / 100)` — el
precio que llevaría el producto exactamente al % de food cost objetivo
configurado. Se muestra como columna nueva "Precio sugerido" en el panel;
si el costo no se pudo calcular (por ejemplo, `unit_error`), se muestra
"—" en vez de un número basado en datos incompletos. El resumen del
listado también expone `unit_conversion_errors`: cuántos productos tienen
el costo bloqueado por falta de conversión, mostrado como aviso en el
panel para que el negocio sepa cuántas recetas necesitan atención antes
de confiar en los números agregados.

3) INTERFAZ (app.js)

- Página de Recetas: cada línea de ingrediente tiene ahora un selector
  "Unidad de la receta" (en blanco = igual al ingrediente, o g/kg/ml/l/
  unidad), con un texto de ayuda explicando que el sistema convierte
  automáticamente si hay una conversión configurada para ese par de
  unidades. La columna "Costo unitario" del listado de recetas muestra
  el badge "⚠ Falta conversión" (con el mensaje de error como tooltip)
  en vez del costo, cuando corresponde.
- Página de Costeo: nueva columna "Precio sugerido"; nuevo estado "Falta
  conversión" en el filtro y en la etiqueta de estado; las columnas de
  costo/food cost %/margen muestran "—" cuando el costo no se pudo
  calcular; nuevo aviso de resumen cuando hay productos con conversión
  faltante.

4) CORRECCIONES DE ESTA ENTREGA (bug de ternario recurrente)

Se encontró, en el endpoint PUT de ingredientes, la misma clase de bug ya
corregida en entregas anteriores (Patio de Comidas 3.10.0, Franquicias):

    $unit = in_array($b['unit']??'unit', [...], true) ? $b['unit'] : 'unit';

Cuando la petición PUT no incluye `unit` (por ejemplo, un cliente que solo
actualiza el precio), `$b['unit']??'unit'` se evalúa a `'unit'`, que SÍ
está en la lista permitida — así que la condición `in_array` es
verdadera. Pero la rama verdadera del ternario vuelve a leer `$b['unit']`
directamente (no la variable ya resuelta), que en PHP 8 es `null` por ser
una clave inexistente. Resultado: la columna se sobrescribe con `null` en
vez de conservar su valor. La misma línea tenía el bug idéntico para el
campo `status`, con el agravante de que `status` es `NOT NULL` en el
esquema — así que CUALQUIER actualización de un ingrediente que no
incluyera explícitamente `status` fallaba directamente con un error 500
("Error de base de datos.", `NOT NULL constraint failed:
ingredients.status`) en vez de corromper el dato en silencio.

Corregido capturando el valor coalescido en una variable intermedia antes
de la comprobación, de modo que ambas ramas del ternario usen esa misma
variable:

    $requestedUnit = $b['unit'] ?? 'unit';
    $unit = in_array($requestedUnit, [...], true) ? $requestedUnit : 'unit';

Se encontraron y corrigieron, con el mismo patrón, cuatro instancias más
del mismo bug en `app/Services/OperationsExtra.php` (módulo de
mantenimiento de equipos), esta vez con el valor existente (`$old[...]`)
como respaldo en lugar de un literal — la misma corrupción de dato
ocurre igual, porque el mecanismo del bug es el mismo (la rama verdadera
del ternario vuelve a leer `$b[...]` directamente en vez de la variable
ya coalescida):

- PUT /maintenance-work-orders/{id}: `status`, `priority` y
  `maintenance_type` se perdían (quedaban `null`) si la petición no
  enviaba esos tres campos exactamente.
- PUT /maintenance-plans/{id}: `maintenance_type` se perdía igual.
- PUT /maintenance-providers/{id}: `status` se perdía igual.
- PUT /maintenance-parts/{id} (repuestos): `status` se perdía igual.

Estas cuatro no fallaban con un error visible (a diferencia de
`ingredients.status`, ninguna de estas columnas es NOT NULL sin default),
así que el dato se corrompía en silencio en cada actualización parcial —
por ejemplo, cerrar una orden de trabajo desde una pantalla que solo
envía `summary` podía dejar su `status` en null, sacándola de los
filtros y contadores de "abiertas"/"críticas" sin ningún aviso.

Se hizo un barrido de todo el código en busca del mismo patrón; se
encontraron, además de estas cinco instancias confirmadas como
corruptoras de datos, un número mayor de "variantes con literal por
defecto" (mismo patrón, pero el valor de respaldo es un literal que
también está en la lista permitida) en otros archivos del sistema. Esas
variantes quedan identificadas pero fuera del alcance de esta entrega;
se evaluarán y corregirán en una entrega posterior dedicada al
saneamiento general de este patrón.

ALCANCE NO CUBIERTO EN ESTA ENTREGA (decisión consciente)

- Conversión de unidades del lado de compras (por ejemplo, comprar por
  caja/paquete y consumir por unidad individual): `purchase_items` no
  tiene un campo de unidad propio hoy — comparte la unidad canónica del
  ingrediente. Este cambio se limitó al lado de recetas/costeo.
- Costos indirectos de empaque/consumibles, alertas de fluctuación de
  precio, e ingeniería de menú (clasificación de platos "estrella"/
  "perro" cruzando costo y volumen de venta): quedan pendientes para una
  entrega posterior.
- El resto de brechas de Mermas (alertas de desvío por %, conciliación
  de inventario teórico vs. real) y el sistema de clasificación de
  destino de mermas (aprovechable/no aprovechable) siguen pendientes.
