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

RESUMEN

Esta entrega cierra cinco brechas reales de costeo e inventario detectadas en
el uso diario de ChefManager IA: (1) la compra de un insumo en una unidad
distinta a la del ingrediente no se convertía automáticamente; (2) el costo
de una receta no incluía el empaque/consumible que la acompaña; (3) un
cambio brusco en el costo de un insumo no generaba ninguna alerta; (4) no
existía ninguna vista que cruzara margen real contra volumen de venta para
decidir qué promocionar o retirar del menú; y (5) la conciliación entre el
inventario teórico del sistema y lo contado físicamente no tenía un
mecanismo dedicado. Todo lo agregado es aditivo: ninguna tabla, endpoint ni
página existente se eliminó o cambió de comportamiento por defecto.

1) CONVERSIÓN DE UNIDAD EN EL LADO DE COMPRAS

Antes de esta entrega, `purchase_items` no registraba en qué unidad se
compró cada línea: si un ingrediente estaba definido en `kg` pero el
proveedor vendía por `caja`, la cantidad recibida se sumaba tal cual al
inventario, inflando o reduciendo el stock real según el tamaño de la caja.

- Columna nueva `purchase_items.unit` (TEXT NULL): la unidad en la que se
  compró esa línea, tal como la escribe quien registra la orden. Si se deja
  vacía, se asume que es la misma unidad del ingrediente (comportamiento
  idéntico al de antes de esta entrega).
- Al recibir la compra (`POST /purchases/{id}/receive`), si `purchase_items.
  unit` es distinta a la unidad del ingrediente, se usa `resolveUnitFactor()`
  (la misma función que ya usa el costeo de recetas desde 3.11.0, sobre la
  tabla `unit_conversions` del propio negocio) para convertir la cantidad
  comprada a la unidad del ingrediente antes de sumarla al stock y de
  recalcular el costo por unidad. Si no hay conversión configurada, la
  recepción falla con un mensaje explícito en vez de asumir una equivalencia
  incorrecta.
- `recordIngredientCostChange()` calcula el nuevo `cost_per_unit` con la
  cantidad YA convertida (costo total de la línea ÷ cantidad en la unidad
  del ingrediente), no con la cantidad cruda de compra — de lo contrario el
  costo por kg quedaría calculado como si cada "caja" costara lo mismo que
  un kg.
- Interfaz: la página Órdenes de compra agrega el campo "Unidad de compra"
  en cada línea (con nota explicando cuándo hace falta llenarlo) y, en el
  modal de recepción por lotes, muestra "cantidad comprada (se convertirá a
  <unidad> al recibir)" cuando la unidad declarada difiere de la del
  ingrediente.

2) COSTEO DE EMPAQUES Y CONSUMIBLES

Hasta esta entrega, el costo de una receta solo consideraba ingredientes
comestibles; el costo real de servir un producto (caja para llevar, vaso,
tapa, servilletero desechable) no se registraba en ningún lado.

- Tabla nueva `packaging_items(id, restaurant_id, name, sku NULL, unit,
  cost_per_unit, status, created_at)`: catálogo de empaques y consumibles,
  administrado igual que el catálogo de ingredientes (alta/edición/baja).
- Tabla nueva `recipe_packaging(id, recipe_id, packaging_item_id, quantity)`:
  qué empaques y en qué cantidad lleva cada receta.
- `calculateRecipeCost()` ahora devuelve el costo dividido en tres partes:
  `ingredient_total` (comida), `packaging_total` (empaque) y el `overhead_
  cost` ya existente de la receta; el `total` y el `per_portion` siguen
  siendo la suma de las tres, de modo que ningún cálculo de precio sugerido
  o food cost % existente cambia su fórmula, solo gana precisión al incluir
  el empaque.
- Nuevas páginas: "Empaques y consumibles" (catálogo) y "Empaque por
  receta" (asigna empaques a una receta existente, con su cantidad).
- La tabla de Costeo de productos agrega la columna "Costo empaque" junto al
  costo de ingredientes, para que el negocio vea de un vistazo cuánto pesa
  el empaque en cada producto.

ALCANCE DE ESTA FUNCIONALIDAD: el empaque se suma únicamente al costo de la
receta de nivel superior que lo tiene asignado directamente en `recipe_
packaging`. Si esa receta usa a su vez una subreceta como ingrediente, el
empaque de la subreceta (si tuviera uno asignado) NO se propaga hacia
arriba — solo se cuenta el empaque asignado a la receta final del producto.
Ver ALCANCE NO CUBIERTO.

3) ALERTAS DE FLUCTUACIÓN DE PRECIO DE INSUMOS

- Nuevo endpoint `PUT costing/price-alert-settings`
  ({"price_fluctuation_threshold_percent": N}, 1-200, guardado en
  `restaurant_settings` bajo `costing.price_fluctuation_threshold_percent`,
  15% por defecto) y su control correspondiente en la página Costeo de
  productos ("Alerta de fluctuación de precio %"), junto al control de
  objetivo de food cost que ya existía desde 3.11.0.
- `recordIngredientCostChange()` — el mismo punto único por el que ya pasa
  todo cambio de `cost_per_unit` (recepción de compra con conversión de
  unidad incluida, o edición manual del ingrediente) — compara el cambio
  porcentual contra el umbral configurado y, si lo supera, inserta una
  notificación `type:'price_fluctuation'` para cada usuario activo con rol
  `owner`, `admin`, `manager` o `inventory` del restaurante, con el nombre
  del insumo, el costo anterior, el nuevo costo y el porcentaje de cambio ya
  calculado en el texto.
- Toda esta trazabilidad queda además consultable en la nueva página
  "Historial de costo de insumos" (`GET ingredient-cost-history/{id}`, tabla
  nueva `ingredient_cost_history`), con el origen de cada cambio (compra
  recibida o edición manual) y el % de variación de cada uno.

4) INGENIERÍA DE MENÚ

Nuevo endpoint `GET costing/menu-engineering?days=N` (7/30/90, 30 por
defecto) y página "Ingeniería de menú": cruza, para cada producto con
receta, precio y costo válidos, su margen bruto real (mismo cálculo de
`calculateRecipeCost()` que Costeo de productos, con empaque incluido) con
sus unidades vendidas en pedidos pagados de los últimos N días, y clasifica
cada producto en uno de los cuatro cuadrantes clásicos de ingeniería de
menú:

- ★ Estrella: margen alto, volumen alto.
- 🐴 Caballo de batalla: margen bajo, volumen alto.
- 🧩 Rompecabezas: margen alto, volumen bajo.
- 🐶 Perro: margen bajo, volumen bajo.

El corte de "alto/bajo" en cada eje es la MEDIANA de margen % y de unidades
vendidas del propio catálogo del negocio en ese período, no un umbral fijo
arbitrario — así la clasificación se adapta a cualquier tamaño y tipo de
menú en vez de compararlo contra un número inventado. Los productos sin
receta, sin precio o con error de conversión de unidad se listan aparte como
"sin clasificar" en vez de forzarlos a un cuadrante con datos incompletos o
poco confiables.

5) CONTEOS FÍSICOS DE INVENTARIO (unifica desvíos y conciliación teórico vs.
   real en un solo mecanismo)

Antes de esta entrega, "alertas de desvío de inventario" y "conciliación
teórico vs. real" eran dos necesidades sin una sola herramienta que las
resolviera juntas. Se implementaron como UN solo mecanismo de sesión de
conteo físico:

- Tablas nuevas `inventory_counts(id, restaurant_id, branch_id, status
  draft|completed, created_by, created_at, completed_at, notes)` e
  `inventory_count_items(id, count_id, ingredient_id, expected_quantity,
  counted_quantity NULL, variance NULL, variance_cost NULL)`.
- `POST inventory-counts` ({branch_id, ingredient_ids[] opcional, notes}):
  crea la sesión y CONGELA de inmediato `expected_quantity` para cada
  ingrediente incluido (todos los activos de la sucursal si no se especifica
  una lista), leyendo el stock del sistema en ese instante — el conteo no se
  ve afectado por movimientos de stock que ocurran después de iniciarlo
  (salvo la limitación de carrera descrita en ALCANCE NO CUBIERTO).
- `PUT inventory-counts/{id}` ({items:[{ingredient_id, counted_quantity}]}):
  registra las cantidades contadas físicamente; puede llamarse varias veces
  mientras el conteo siga en `draft` (para ir guardando el avance del
  conteo).
- `POST inventory-counts/{id}/complete`: calcula `variance = counted -
  expected` y `variance_cost = variance × cost_per_unit` por cada ítem
  contado, AJUSTA el stock real a la cantidad contada (movimiento de
  inventario con motivo `inventory_count`), y para cada ingrediente cuyo
  desvío relevante sea un FALTANTE (≥10% respecto al teórico, umbral fijo
  documentado en el propio mensaje de notificación) crea automáticamente una
  fila en `inventory_waste` con `reason_code:'inventory_difference'` y
  `destination:'non_usable'` (el sistema no puede saber si un faltante es
  aprovechable) y dispara una notificación `type:'inventory_deviation'` a
  los mismos roles que reciben alertas de fluctuación de precio. Un
  sobrante no genera una fila de merma (no hay pérdida que registrar), pero
  igual queda reflejado el ajuste del stock a lo contado.
- Página "Conteos físicos": lista de conteos con su estado, un modal de
  creación (sucursal + selección opcional de ingredientes a incluir) y un
  modal de conteo que muestra, por ingrediente, el teórico congelado, un
  campo para la cantidad contada, y — una vez completado — la variación con
  color de alerta si es negativa.

6) CLASIFICACIÓN APROVECHABLE / NO APROVECHABLE DE MERMAS

- Columna nueva `inventory_waste.destination` (TEXT NOT NULL DEFAULT
  'non_usable'): `'usable'` (aprovechable, por ejemplo un recorte que se
  puede reutilizar en otra preparación) o `'non_usable'` (pérdida real). Se
  agrega tanto al alta manual de mermas/pérdidas (`POST inventory-waste`,
  campo obligatorio del formulario) como a la generada automáticamente por
  un conteo físico con desvío (punto 5, siempre `'non_usable'`). La página
  Mermas y pérdidas agrega el campo al formulario y una columna "Destino" en
  la tabla.

7) BASE DE DATOS (cambios aditivos)

- Tablas nuevas: `packaging_items`, `recipe_packaging`,
  `ingredient_cost_history`, `inventory_counts`, `inventory_count_items`.
- Columnas nuevas: `purchase_items.unit`, `inventory_waste.destination`.
- Ningún esquema existente se modificó ni se volvió obligatorio; los datos
  ya existentes (compras, recetas, mermas) no se ven afectados y siguen
  funcionando exactamente igual que antes si el negocio no usa las
  funciones nuevas.

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

- El costo de empaque NO se propaga a través de subrecetas: solo se suman
  las filas de `recipe_packaging` asignadas directamente a la receta de
  nivel superior del producto. Si el negocio arma un producto a partir de
  una subreceta que a su vez tiene su propio empaque asignado, ese empaque
  de la subreceta no se refleja en el costo del producto final. Extender
  `calculateRecipeCost()` para acumular empaque de forma recursiva a través
  de subrecetas queda como mejora futura.
- El CRUD administrativo genérico de reservas y de recetas (ABM básico ya
  existente en `ExtendedModules.php`, sin relación con las 5 áreas de esta
  entrega) no se tocó en ningún punto.
- La reconciliación de conteo físico NO resuelve la condición de carrera de
  ventas ocurriendo durante la ventana del conteo: `expected_quantity` se
  congela en el momento de CREAR el conteo, pero el stock real del sistema
  puede seguir moviéndose (ventas, otras recepciones) mientras el conteo
  sigue abierto en `draft`. Si el negocio tarda varias horas en contar
  físicamente mientras sigue vendiendo, la variación calculada al completar
  el conteo mezclará el desvío real de inventario con el movimiento de
  stock ocurrido durante la ventana de conteo. La recomendación operativa
  (no forzada por el sistema) es completar el conteo lo antes posible tras
  crearlo, idealmente con el punto de venta pausado para los ingredientes
  incluidos.
- Ningún bot ni webhook genera conteos físicos ni órdenes de compra de forma
  automática (por ejemplo, al detectar stock bajo): ambos flujos siguen
  siendo iniciados manualmente desde el panel por el propio negocio.
