# Consumos adicionales de materia prima — diseño

> Tiquete **#24979** (Envaisa Producción P0). Escrito el 12-oct-2026 por CLAUDE, a pedido de
> MANUEL. **No es una decisión de Christian**: es una propuesta para que la revise. Lo que está
> medido dice contra qué; lo que es decisión de diseño lo dice. Las preguntas abiertas para
> Christian están en **#24978** y acá se marcan con «⏳ depende de la pregunta N».

## El problema, medido en el código actual

`FlujoOrden::registrarProduccion()` gasta **sólo la fórmula de lo producido**
(`ExplosionFormula::explotar([$plu => $cantidad])`). `Produccion::registrar()` recibe esa lista
y escribe el documento de explosión. No hay ninguna otra puerta por la que entre una cantidad
de materia prima:

- si la planta necesitó más harina para cerrar el lote, **no hay dónde decirlo**;
- el despacho (`despacharMateriaPrima`) se hace **una sola vez** (guarda `despachoDocumento`),
  así que tampoco se puede traer más desde la bodega de materia prima;
- el costo del producto terminado sale **subvaluado**: esa harina no entra en ninguna cuenta.

La referencia de Envaisa es su orden 2757 (18-abr-2026): costo «estimado» vs «real», con
diferencia de −41.75 sobre 10,047.66, porque lo real gastado no coincide con la fórmula.

**El módulo viejo de mbinv tampoco lo resuelve**: `agregarPluAction` agrega un *producto
terminado* a una producción, no materia prima. No hay comportamiento previo que portar.

## Qué se agrega

Una tabla nueva y aparte, **`prod_orden_consumo_adicional`**, y un servicio que la maneja.
**No se toca `prod_orden_insumo`**: ésa es la explosión congelada y `planificar()` la rehace
desde cero. Un consumo adicional es un hecho de la planta —alguien lo gastó— y no puede
desaparecer porque se replanifique.

| columna | para qué |
|---|---|
| `orden_id` | la orden (borrado en cascada) |
| `plu_producto` | **a qué producto se le carga el costo** (`detmov.formulaplu`). Sin esto, una orden con dos productos no sabe quién lo absorbe |
| `plu_insumo`, `descripcion_insumo` | la materia prima |
| `cantidad` | `DECIMAL(18,4)`, siempre > 0 |
| `motivo` | obligatorio: «completar lote», «merma de arranque»… Es lo que explica después por qué el costo salió sobre la fórmula |
| `requiere_despacho` | `false` = la materia prima ya estaba en la bodega de producción (sobrante): no hay traslado |
| `estado` | `PENDIENTE → DESPACHADO → CONSUMIDO`, o `PENDIENTE → ANULADO` |
| `despacho_documento`, `consumo_documento` | los documentos de mbinv que lo prueban |
| `existencia_al_agregar` | informativo. **El faltante avisa y no frena** (decisión de Christian del 24-sep) |

## Flujo

La orden **no gana ningún estado nuevo**. Todo ocurre mientras está en `MP_DESPACHADA`.

```
MP_DESPACHADA
   │  agregar(...)                       → fila PENDIENTE (o DESPACHADO si es sobrante). No mueve inventario
   │  despacharPendientes()              → 1 traslado MP → producción con TODO lo pendiente; filas → DESPACHADO
   │  registrarProduccion(producidas)    → la explosión de fórmula + los DESPACHADO; filas → CONSUMIDO
   ▼
PRODUCIDA → TERMINADA   (sin cambios)
```

`Produccion::registrar()` **no cambia**: ya reparte el costo de cada renglón consumido entre las
unidades del producto que dice `formulaPlu`. Los consumos adicionales sólo agregan renglones a
esa lista. Costo unitario = (fórmula + adicionales) / unidades producidas.

## Invariantes (cada uno tiene prueba)

1. Pedir, despachar y anular sólo se hace con la orden en `MP_DESPACHADA`.
2. `cantidad > 0` (a 4 decimales: 0.00001 se rechaza), `motivo` no vacío, el producto está en la
   orden, el insumo existe en `maeplu` y **no es un producto de la misma orden**.
3. Pedir **no mueve inventario**. El único movimiento propio es el traslado complementario.
4. El despacho complementario es **un traslado por llamada** y atómico con el cambio de estado de
   las filas. Reintentar no duplica: sin pendientes tira error y no escribe nada.
5. No se registra producción con consumos **PENDIENTE** (saldría materia prima de una bodega
   donde nunca entró).
6. Un consumo cuyo producto produjo **cero** no se puede consumir (no hay a quién repartirle el
   costo): hay que reasignarlo a otro producto de la orden. Reasignar no mueve inventario.
7. Cada consumo despachado se gasta **exactamente una vez**, en el documento de explosión de la
   misma transacción que cambia la orden a `PRODUCIDA`.
8. Sólo se anula lo que **no movió inventario** (PENDIENTE). Lo despachado se corrige en el Back
   Office, igual que `FlujoOrden::anular()` con el despacho original.
9. Nunca se escribe `existencias_tienda`; sólo `maedoc`/`detmov` por `Traslado` y `Produccion`.

## Casos borde decididos (revisables)

- **Mismo insumo pedido dos veces** → dos filas (cada una con su motivo), un solo renglón en el
  traslado (suma).
- **El insumo es a su vez una fórmula (sub-ensamble)** → se consume tal cual, **no se explota**.
  Es un consumo explícito de la planta, no una consecuencia de la fórmula. ⏳ confirmar con
  Christian si prefiere explotarlo (pregunta 2).
- **Producción parcial** (se planificaron 100, salieron 90) → los adicionales se gastan completos:
  son lo que la planta *dijo* que gastó.
- **Orden anulada con adicionales despachados** → igual que el despacho original: el inventario
  no se revierte solo. ⚠️ `anular()` hoy lista en la bitácora sólo `despachoDocumento` y
  `trasladoDocumento`; falta agregar los despachos complementarios (cambio en `FlujoOrden`).
- **Unidades**: la cantidad va en la unidad base (`unidad=1`, `contenido=1`), igual que la
  explosión. La presentación (fardos) **no está resuelta**: es una particularidad de Envaisa que
  todavía no se investigó.

## Qué choca con el trabajo de Christian, y qué no

**Independiente (archivos nuevos, sin conflicto posible):**
`Entity/ConsumoAdicional.php`, `Entity/EstadoConsumoAdicional.php`,
`Repository/ConsumoAdicionalRepository.php`, `Servicio/ConsumoAdicionalServicio.php`,
`Controller/ConsumoAdicionalController.php`, `migrations/Version20261012180000.php`, `tests/`.
La migración nueva se ejecuta aunque Christian agregue otras: no toca sus tablas.

**Puede chocar (editan archivos de Christian):**

| archivo | cambio | tamaño |
|---|---|---|
| `Servicio/FlujoOrden.php` | inyectar el servicio, sumar sus renglones en `registrarProduccion()`, marcarlos consumidos | ~12 líneas |
| `Servicio/FlujoOrden.php` (`anular`) | listar los despachos complementarios en la bitácora | ~3 líneas |
| `web/src/app/ordenes/[id]/page.tsx` | pantalla de consumos adicionales | componente nuevo + 1 línea |
| `composer.json` | agregar `phpunit` a `require-dev` (hoy no hay pruebas en el repo) | 2 líneas + lock |

**Necesita una respuesta de Christian:**

- ⏳ **Pregunta 1** (¿hay una fase 2 sin subir que cambie el costeo de la orden?). Si la fase 2
  reescribe `Produccion::registrar()` o la forma de `LineaConsumo`, este diseño hay que
  reacomodarlo. No bloquea la parte independiente.
- ⏳ **Pregunta 2** (¿lo prevé después del despacho?). Si ya tiene un diseño propio, se prefiere el
  suyo y esto se reduce a las pruebas y los casos borde.
- Las preguntas 3 y 4 (costo promedio, trazabilidad) **no afectan** este diseño.
- La pregunta 5 (rama de enlace con mbinv) sólo afecta la prueba *de punta a punta por navegador*.

## Cómo se prueba

`api/tests/` corre contra una **base MySQL real y descartable**, sin dobles:

| carpeta | qué prueba |
|---|---|
| `Unidad/` | reglas de estado de la entidad (sin base) |
| `Integracion/` | el servicio, los traslados reales, el trigger de existencias, los costos y los **endpoints por HTTP** (token, permisos, errores en JSON) |
| `Concurrencia/` | **procesos PHP distintos**, cada uno con su conexión, compitiendo por la misma orden |

Dos esquemas, y las pruebas pasan en los dos:

1. **Mínimo** (`tests/fixtures/esquema-mbinv-minimo.sql`): lo que tocan `Traslado`, `Produccion` y
   la explosión, más el trigger de existencias de `docs/DISENO.md`.
2. **Real**: un `mysqldump --no-data` (SOLO estructura, cero filas) de `mbinvmasamadreartisan` en el
   servidor de **pruebas** — 532 tablas, 19 vistas, 9 triggers, MySQL 8.0.46 — cargado en una base
   local. Ahí la migración nueva se ejecuta con `doctrine:migrations:execute` (esa base ya tiene las
   tablas `prod_*` de la instalación de Christian) y `doctrine:schema:update --dump-sql` confirma
   que **las entidades de Christian están sincronizadas con esa base** y que lo único que faltaba
   era `prod_orden_consumo_adicional`. La migración se probó `up` → `down` → `up`.
   El volcado NO se versiona (700 KB que nadie necesita en el repositorio).

```
# MySQL local descartable; la base se llama `mbinv_test` (el entorno test le agrega `_test`)
mysql -e "CREATE DATABASE mbinv_test" && mysql mbinv_test < tests/fixtures/esquema-mbinv-minimo.sql
MBPROD_TEST_DB_PORT=3399 php bin/console doctrine:migrations:migrate --no-interaction   # con APP_ENV=test
PHPUNIT=/ruta/phpunit.phar MBPROD_TEST_DB_PORT=3399 api/tests/correr.sh
```

⚠️ Las pruebas hacen `TRUNCATE` de tablas de mbinv. **Nunca** apuntarlas a una base con datos.

### Lo que la concurrencia demostró

El padre toma el lock de la fila de la orden, lanza los procesos hijos y comprueba que se
**quedan esperando** antes de soltarlo. Con el lock de `despacharPendientes()` puesto: dos y tres
despachos simultáneos producen **exactamente un traslado**, y los demás reciben el rechazo de
dominio. **Sin el lock** (se quitó a propósito) el mismo escenario revienta con
`Duplicate entry '9-4-0090000002' for key 'maedoc.tiendatipodocdocumento'`: el correlativo de
`MAX(documento)+1` chocando. O sea que el lock no es decoración.

### Se verificó que las pruebas fallan cuando deben

Se rompió el código a propósito (cada una hizo fallar las pruebas que le corresponden): quitar el
cambio de estado del despacho, quitar el aporte de renglones, quitar el chequeo de pendientes,
quitar el lock de la orden.

**No probado todavía:** despacho vs. registro de producción en paralelo (necesita el cableado en
`FlujoOrden`, rama aparte), el navegador, y la pantalla web (no existe).
