# mbProduccion — diseño

> Escrito el 24-sep-2026. Las decisiones de esta página las tomó **Christian**, que es
> el PM del módulo. Lo que está medido dice contra qué se midió; lo que es decisión
> dice que es decisión. Si algo de acá deja de ser cierto, se corrige acá primero.

## Qué es

Una **extensión del Back Office** para el módulo de producción: planificar cuánto
producir, emitir la orden de producción, y mover el inventario en los tres pasos que
eso implica. Vive en su propio repositorio porque mbinv corre PHP 5.6 y Symfony 2.7,
y este módulo nace en PHP 8.4 y Symfony 7.4 LTS.

No reemplaza a mbinv. **Comparte su base de datos** y escribe en sus tablas de
inventario.

## El flujo, tal como se pidió

```
Fase 1 — VENTAS            Fase 2 — ORDEN DE PRODUCCIÓN
┌──────────────┐           ┌─────────────────────────────────────────────┐
│  al ojo   ←──┼── esto    │  OP  →  Despacho MP  →  Ingreso producción  │
│  aritmético  │  hacemos  │             ↓                    ↓          │
│  ML          │  nosotros │      bodega MP → Prod      Prod → bodega PT │
└──────────────┘           └─────────────────────────────────────────────┘
      ↑
  los otros dos llegan después, por webhook/API
```

- **Al ojo** es lo único que construimos. La pantalla **parte del histórico de ventas
  del período anterior** (`hdettra`) y el usuario lo ajusta arriba o abajo.
- **Aritmético** y **ML** son otros sistemas. Nos conectamos a ellos por webhook o API
  más adelante; el pronóstico entra por la misma puerta, sólo cambia el origen.
- **PT aquí siempre significa Producto Terminado.** En el CLAUDE.md de mbinv «PT» es
  el Punto de Venta. En este repositorio no se abrevia: se escribe completo.

## Decisiones tomadas

| # | Decisión | Por qué |
|---|---|---|
| 1 | **Una copia por cliente**, como mbinv | Cada cliente ya tiene su checkout y su base; el módulo va al lado |
| 2 | **Escribe directo** en la base del cliente | Sin servicio intermedio ni dependencia de mbinv en caliente |
| 3 | Se porta **sólo el camino de traslado** (`tipodoc=4`) | Es el único movimiento que producción usa |
| 4 | Entra con **token emitido por el Back Office** | Cero usuarios nuevos que administrar |
| 5 | Frontend **React + Next.js + shadcn/ui** | Es lo que ya usan las otras dos apps satélite |
| 6 | **Varias bodegas por tipo, agrupadas por línea de producción** | El modelo de una-por-tipo ya se rompió en la realidad (ver abajo) |
| 7 | Docker **no se versiona** | En el servidor corre Apache + PHP nativo; estos archivos no viajan |

## Lo que se midió en mbinv antes de diseñar

Todo esto es contra `mbinvmasamadreartisan` en el servidor de **pruebas** (`3310`),
MySQL 8.0.46, el 24-sep-2026.

### Las existencias las mantiene la base, no el código

Hay triggers vivos en `detmov` — `detmov_insert`, `detmov_update`, `detmov_delete`:

```sql
INSERT INTO existencias_tienda SET tienda=new.tienda, plu=new.plu,
  existencia=IF(new.movimiento='E', new.cantidad, -new.cantidad)
ON DUPLICATE KEY UPDATE existencia = existencia + IF(new.movimiento='E', new.cantidad, -new.cantidad);
```

**Consecuencia de diseño:** mbProduccion nunca toca `existencias_tienda`. Inserta en
`detmov` con el `movimiento` y la `cantidad` correctos, y las existencias se mantienen
solas. La lógica de existencias sigue teniendo un dueño único, que es la base.

El mismo trigger escribe en `maeplu_cambios`, que es lo que alimenta las sincronías.
Tampoco se toca a mano.

### La forma exacta de un traslado

Verificada contra una fila real: OP 1699, documento `0020000102`, 21-sep-2026.

- **Dos filas en `maedoc`** con el mismo `documento`, el mismo `origen` y el mismo
  `destino`. Una tiene `tienda = origen`, la otra `tienda = destino`.
- **Dos filas en `detmov`** por producto: `movimiento='S'` en el origen,
  `movimiento='E'` en el destino.
- `maedoc` tiene **70 columnas y sólo 19 llevan valor** en un traslado de producción.
  Las demás tienen default en la tabla. `detmov` tiene 89 y usa 18.
- Por eso **no se replica** `crearMaedoc()`, que recibe ~70 parámetros posicionales
  (las llamadas reales le pasan 65 valores sueltos en fila). Se hace un `INSERT`
  explícito con las columnas que importan.

### El correlativo tiene una carrera

`Documento::getProximo()` en mbinv hace `MAX(documento)+1` sin bloqueo, con el número
de tienda como prefijo de 3 dígitos y 7 dígitos de secuencia (`002` + `0000102`).
Dos despachos simultáneos sacan el mismo número.

**Acá se serializa con `GET_LOCK('mbproduccion_correlativo_<bodega>_4', 10)`**, dentro
de la transacción y soltado siempre en un `finally`. Es el único punto donde
mbProduccion se aparta a propósito del comportamiento de mbinv, y es para arreglarlo.

No se usa `SELECT ... FOR UPDATE` porque **una bodega que todavía no emitió ningún
documento no tiene fila que bloquear** — y ése es justo el arranque de cada cliente
nuevo, que es cuando dos personas estrenando el módulo chocan. El lock por nombre
existe aunque la tabla esté vacía. Un `GET_LOCK` que no se suelta sobrevive a la
transacción y deja la bodega sin poder emitir documentos hasta que muera la conexión:
por eso el `RELEASE_LOCK` va en `finally` y no al final del camino feliz.

### El modelo de «una bodega por tipo» ya se rompió

`maetie.usa_produccion` marca `'M'` materia prima, `'P'` producción, `'T'` terminado.
`Tienda::getBodegaMateriaPrima()` hace:

```php
fetchScalar("SELECT tienda FROM maetie WHERE usa_produccion='M'")   // sin ORDER BY, sin LIMIT
```

En este cliente hay **tres** bodegas marcadas `'M'` — TIFFANY (2), SPAZIO (4) y
BODEGA DE MATERIA PRIMA (9) — así que se queda con la que MySQL devuelva primero.
El despacho del 21-sep-2026 salió con `origen = 2`, TIFFANY, no de la bodega de
materia prima.

**Por eso mbProduccion no lee `usa_produccion`.** Cada línea de producción declara
explícitamente de qué bodega saca y a cuál entrega, y la orden **congela** esas tres
bodegas al crearse: cambiar la configuración de la línea mañana no puede reescribir
lo que una orden de ayer ya despachó.

### El despacho de mbinv pierde los sub-ensambles

La consulta que arma el despacho (`ProduccionMaestroController::despacharAction()`)
trae los insumos de la fórmula con este filtro:

```sql
AND (SELECT COUNT(*) FROM detfor_produccion dp WHERE dp.plu = detfor_produccion.envplu) = 0
```

Es decir: **si un insumo es a su vez una fórmula, se excluye** — y no se explota en su
lugar. Simplemente no sale de la bodega.

No es un caso raro. Medido contra la misma base:

| | |
|---|---|
| insumos distintos en `detfor_produccion` | 711 |
| de esos, los que son a su vez fórmula | **149 (21%)** |
| relaciones fórmula→insumo | 9.724 |

El ejemplo más claro es **MM BATARD 1200G** (plu 1190):

```
MM BATARD 1200G
├── MASA MADRE POR GRAMO   127 g   ← es fórmula: mbinv la descarta
│   ├── AGUA PURA FILTRADA        0.53
│   └── POLSELLI HARINA INTEGRALE 0.47
├── HARINA DURA POR GRAMO  630 g
└── SAL KOSHER POR GRAMO    12 g
```

mbinv despacha harina y sal. **Los 127 g de masa madre desaparecen**: ni salen como
masa madre ni salen como sus componentes. La bodega descuadra contra lo que de verdad
se usó, y el descuadre es silencioso.

**mbProduccion explota hasta la hoja** (`Legado/ExplosionFormula.php`), con guarda de
ciclos por camino y `PROFUNDIDAD_MAXIMA = 20`. Para el mismo pan baja los 127 g a
67,31 g de agua + 59,69 g de harina integrale = 127,00 g exactos.

Esto es un hallazgo sobre mbinv, no un cambio pedido. **Necesita su propio tiquete**;
acá queda anotado porque es la razón por la que la explosión es recursiva.

### Se planifica en unidades, y mbinv cuenta en lotes

En `detfor_produccion.cantidad` está el insumo **por unidad** de producto terminado, y
en `maefor.cantidad` **cuántas unidades rinde un lote**. mbinv multiplica las tres
cosas:

```sql
cantidad_pedida * detfor_produccion.cantidad * IF(maefor.cantidad IN (NULL,0), 1, maefor.cantidad)
```

Eso cuadra porque **`cantidad_pedida` de mbinv está en lotes**, no en unidades. De las
1.551 fórmulas de esta base, **750 tienen un rendimiento distinto de 1**, así que la
diferencia entre las dos lecturas no es teórica.

Acá se planifica **en unidades** —fue la decisión— y la pantalla muestra a cuántos
lotes equivale. Por eso el insumo es `unidades × detfor.cantidad` y `maefor.cantidad`
**no entra en el cálculo**: es sólo el rendimiento que se muestra al lado. Es la misma
aritmética escrita en otra unidad, no otro criterio.

## Modelo de datos

### Tablas nuevas, en la base del cliente (`mbinv<cliente>`), prefijo `prod_`

| tabla | para qué |
|---|---|
| `prod_linea` | línea de producción: nombre + sus tres bodegas (MP, producción, terminado) |
| `prod_pronostico` / `prod_pronostico_detalle` | la fase «al ojo»: histórico + ajuste del usuario |
| `prod_orden` | la orden de producción, con sus tres bodegas congeladas y los documentos que generó |
| `prod_orden_detalle` | qué producto y cuánto: planificado vs. producido |
| `prod_orden_insumo` | **la explosión de fórmula congelada** al planificar |
| `prod_permiso` | quién puede ver, crear, editar, despachar, ingresar y trasladar, por línea |
| `prod_token` | el token con que entra la gente desde el Back Office |

**Por qué se congela la explosión de fórmula:** `detfor_produccion` se edita. Si una
orden guardara sólo un puntero a la fórmula, una edición de mañana cambiaría la
explicación de lo que ya se despachó ayer, y el despacho dejaría de cuadrar con su
propia orden sin que nadie tocara la orden.

### Tablas de mbinv que se leen

`maeplu` (productos), `maetie` (tiendas y bodegas), `maefor` + `detfor_produccion`
(fórmulas), `existencias_tienda` (existencia actual), `hdettra` (histórico de ventas
para el pronóstico), `maeusr` (usuarios y su nombre).

### Tablas de mbinv que se escriben

**Sólo `maedoc` y `detmov`, y sólo con `tipodoc=4`.** Nada más. Si algún día hace
falta escribir otra, se decide aparte y se anota acá.

### Por qué `prod_token` y no `dic_tokens`

El patrón de entrada es el de `pedidosprov`: el Back Office genera un token y abre
mbProduccion con `?token=...&api=...`. Pero `dic_tokens` es una tabla del Diccionario,
y **producción no tiene las tablas `dic_*`** — se instalan a mano, y por eso existe
`Dic::estaInstalado()` en mbinv.

Colgarse de `dic_tokens` convertiría «instalar el Diccionario» en prerequisito de
despliegue de cada cliente. Con tabla propia, lo único que mbinv necesita es la ruta
que la escribe.

## Estados de la orden

```
BORRADOR ──► PLANIFICADA ──► MP_DESPACHADA ──► PRODUCIDA ──► TERMINADA
    │             │                │                │
    └─────────────┴────────────────┴────────────────┴──────► ANULADA
```

| estado | qué pasó | qué se puede hacer |
|---|---|---|
| `BORRADOR` | se está armando | editar todo |
| `PLANIFICADA` | insumos congelados | despachar materia prima |
| `MP_DESPACHADA` | traslado bodega MP → producción hecho | registrar lo producido |
| `PRODUCIDA` | se registró la cantidad real | trasladar a producto terminado |
| `TERMINADA` | traslado producción → bodega PT hecho | nada, es final |
| `ANULADA` | se dio de baja | nada, es final |

Cada transición que mueve inventario es **idempotente**: si ya existe un `detmov`
con esa orden y ese paso, no se vuelve a crear. mbinv ya hace esta verificación
(`SELECT COUNT(*) FROM detmov WHERE ... AND ordenproduccion=...`) y se conserva,
porque es lo que evita duplicar un despacho al reintentar.

## Bitácora

Se copia el patrón de mbinv: **tablas mensuales en la base de temporales**
(`_temporales<cliente>`), creadas al vuelo con `CREATE TABLE IF NOT EXISTS`.

| tabla | qué guarda |
|---|---|
| `bitacoraprod<AAAAMM>` | toda escritura: quién, cuándo, desde qué IP, qué SQL, sobre qué tabla |
| `bitacoraproderror<AAAAMM>` | los errores, con su contexto |

Y se hereda **`BitacoraSinSecretos`**: `clave`, `password`, `token`, `secret` y
compañía se reemplazan por `***` antes de que nada entre a la bitácora. En mbinv eso
existe porque la contraseña de la API central se estaba guardando en texto plano, una
fila por llamada. Acá nace tapado.

Que la bitácora falle **nunca** tumba la operación: se registra el problema en el log
de la aplicación y la operación sigue. Es diagnóstico, no es el negocio.

## Cómo entra la gente

```
Back Office  ──GET /produccion/abrir──►  emite token en prod_token
                                          │
                                          ▼
                         redirige a  <app>/?token=...&api=<url del mbinv del cliente>
                                          │
                                          ▼
                              Next.js guarda el token y lo manda
                              como  Authorization: Bearer <token>
                                          │
                                          ▼
                          Symfony lo valida contra prod_token del cliente
```

- El token dura **8 horas**, igual que en el Diccionario.
- No hay usuarios propios. La identidad es `maeusr.usuario` del cliente.
- Los permisos son de `prod_permiso`, por línea de producción.
- Los tokens vencidos se borran al emitir uno nuevo.

## Las pantallas

```
/              tablero: las órdenes, su estado y qué falta hacer en cada una
/pronostico    la fase «al ojo»
/ordenes/nueva armar la orden
/ordenes/{id}  la orden: la barra de 4 pasos, los insumos y los documentos
/lineas        las líneas de producción y sus tres bodegas
```

- **El pronóstico parte del histórico** de `hdettra` del período anterior y se ajusta
  con −10% / Igual / +10% o escribiendo el número. Al guardar encadena directo a una
  orden nueva con esas cantidades.
- **La barra de 4 pasos la manda el servidor**, no el navegador: la orden responde con
  `transicionesPosibles` y la pantalla dibuja lo que venga ahí. El navegador no decide
  qué se puede hacer; si lo decidiera, bastaría con editar el estado en el cliente.
- **Los permisos del frontend son sólo para mostrar.** `usePuede()` esconde botones; el
  servidor vuelve a verificar cada uno. Esconder no es proteger.
- **El faltante avisa y no frena.** Al planificar se compara contra
  `existencias_tienda` y se muestra qué falta y cuánto; el despacho igual se puede
  hacer. Fue la decisión: la bodega física manda sobre el saldo del sistema.
- **La pantalla de líneas avisa** cuando encuentra más de una bodega marcada
  `usa_produccion='M'`, que es la condición que rompe a mbinv.

## Qué se probó, y contra qué

Base local con el **esquema real de mbinv, sus triggers y las ventas de 2026** de
`mbinvmasamadreartisan`. No es un juego de datos inventado.

- **El flujo completo por el navegador**, con clic real: token desde la URL, pronóstico
  sobre 685 productos / 45.976 unidades, +10% → 50.573,6, encadenar a la orden, y los
  cuatro pasos hasta `TERMINADA`.
- **Las existencias se movieron**: −1000/+1000 azúcar, −14/+14 frascos, −2400 fresas,
  por los triggers, sin que el módulo tocara `existencias_tienda`.
- **Producción parcial**: se planificaron 4 y se registraron 3; al producto terminado
  se trasladaron 3, no 4.
- **Los correlativos** salieron con su prefijo de bodega: `0090000002`, `0070000002`.
- **La bitácora** quedó escrita en `_temporaleslocal.bitacoraprod202609`, y
  `SinSecretos` tapó tanto el JSON anidado como las asignaciones de SQL.
- **El teléfono** (375 px): sin desplazamiento horizontal, `scrollWidth == clientWidth`.

Lo que **no** se probó: el servidor real, y la ruta del lado de mbinv que emite el
token — está escrita en `docs/INTEGRACION-MBINV.md` pero no aplicada.

## Lo que este diseño deja pendiente a propósito

- **El costo promedio no se toca.** En mbinv se recalcula por período con un stored
  procedure, aparte del movimiento. mbProduccion crea el movimiento y deja que ese
  proceso siga siendo el dueño del costo.
- **La trazabilidad por lote** (`produccion_trazabilidad`, el parámetro
  `produccion_despacho_trazabilidad`) no está en este corte. El diseño no la estorba.
- **Aritmético y ML** entran después por webhook. El pronóstico ya tiene el campo de
  origen para distinguirlos desde ahora.
