# Costo de la orden por componentes — diseño

> Tiquete **#24981** (Envaisa Producción P1). Escrito el 12-oct-2026 por CLAUDE. **La parte independiente
> ya está implementada y probada** (ver «Estado de la implementación» al final); el enganche con
> `Produccion.php` NO.
> **Christian respondió #24978 el 13-oct-2026**: no hay fase 2 sin subir; el costo por componentes va en
> **tablas aparte** (no en `prod_orden_insumo` ni `detfor_produccion`) y esta propuesta «va en la dirección
> correcta». El costo promedio NO se resuelve acá: es de mbinv y necesita tiquete propio. Lo medido dice
> contra qué; lo supuesto está marcado **⚠️ supuesto**.

## Qué pide Envaisa, medido en su orden 2757 (18-abr-2026)

Producto PT-001, 3,155 garrafones, «Tipo de costeo: Real». La orden separa el costo en:

| bloque | ejemplo en la orden | forma |
|---|---|---|
| **Materiales** | IN-PRO012 banda termoencogible, IN-PRO011 tapa | cantidad × costo, salen del almacén 11 |
| **Mano de obra** | MO001 «Mano de Obra Garrafon», MO053 «Mano de Obra General» | tarifa × una base |
| **Otros** (gastos de fabricación) | GF001 depreciación, GF005 energía, GF016 gas, GF009 alquileres, MP001 agua del pozo… | tarifa × una base |
| **Indirectos** | (en 0 en esta orden, pero el campo existe) | — |

y presenta **Estimado**, **Real** y **Diferencia** (10,047.66 vs 10,089.42 → −41.75).

Dos hechos que cambian el diseño:

- **La mano de obra y los gastos no son existencias.** Son «ítems» con una tarifa. Si se metieran como
  materia prima en la fórmula (`detfor_produccion`), la explosión los sacaría de una bodega y dejarían
  existencias negativas sin sentido.
- **La base no siempre son las unidades producidas.** MO001 usa 3,155 (= unidades). Los otros usan
  59,629.50, que es 3,155 × 18.9 — **⚠️ supuesto**: litros de agua procesada por garrafón. O sea que
  hay al menos dos bases (unidades y otra cantidad que alguien captura).

## Lo que hay hoy (medido en el código)

- `Produccion::registrar()` costea el terminado SOLO con `detuni.costo`/`sicosto` de lo consumido
  (`Produccion.php:108-145`). No existe mano de obra, gastos ni «estándar» en mbProduccion ni en el
  módulo viejo de mbinv (búsqueda en ambos).
- No hay costo **estimado**: `prod_orden_insumo` guarda cantidades, no costos.
- El costo promedio lo calcula `sp_calcular_promedios_movil_v18` **por período** (no al registrar).
  Medido en su fuente (`Instalacion.php`): toma como «afecta costo» los documentos de los tipos con
  `maemov.promedio='S'` **o** `maetdc.escompra='S'` **o** `maetdc.es_produccion='C'`, y usa
  `detmov.sicosto`. Recalcular una fecha anterior = volver a correr el período.

> ⚠️ **Hallazgo para Christian (pregunta 3):** mbProduccion elige el tipo de documento de producción por
> `maetdc.es_produccion = 'S'`, pero el procedimiento de promedios cuenta como «producción que afecta
> costo» a `es_produccion = 'C'`. Si en un cliente el tipo de producción está marcado `'S'` y su
> movimiento no tiene `promedio='S'`, **el costo de lo producido no entra al promedio**. No se pudo
> comprobar en datos reales (el volcado es solo estructura). Hay que mirar `maetdc` del cliente antes de
> prometer «costo promedio en tiempo real».
>
> **Comprobado por Christian el 13-oct-2026** en las 57 bases del servidor de pruebas que tienen tipo de
> producción: 55 tienen `es_produccion='S'` y el costo NO entra al promedio (48 con `promedio` en blanco, 6 en
> `'N'`, 1 `NULL`, entre ellas masamadreartisan); solo 3 (`'C'`) sí entran. Se corrige en mbinv, no acá: este
> módulo conserva la misma bandera que el Back Office. **Este PR no toca el costo promedio.**

## Propuesta

### Dos tablas nuevas (prefijo `prod_`, migraciones propias, ninguna existente cambia)

`prod_costo_estandar` — el catálogo de tarifas (la carga masiva en Excel es P2):

| columna | para qué |
|---|---|
| `codigo` (único), `descripcion` | MO001, GF005… como en Envaisa |
| `tipo` | `MANO_OBRA` · `GASTO_FABRICACION` · `INDIRECTO` |
| `tarifa` `DECIMAL(18,6)` | por unidad de la base |
| `base` | `UNIDAD_PRODUCIDA` (se calcula sola) · `MANUAL` (se captura en la orden) |
| `linea_id` y `plu_producto` (nulos) | a qué línea / producto aplica; nulo = a todos |
| `vigente_desde`, `vigente_hasta` | para que cambiar una tarifa no reescriba el pasado |

`prod_orden_costo` — lo que la orden aplicó:

| columna | para qué |
|---|---|
| `orden_id`, `plu_producto` | a qué producto se carga |
| `codigo`, `descripcion`, `tipo` | copiados del catálogo (**congelados**, igual que `prod_orden_insumo`) |
| `tarifa_aplicada` | congelada al planificar |
| `base_estimada`, `monto_estimado` | al planificar |
| `base_real`, `monto_real` | al registrar la producción |

### Flujo

1. **Planificar:** se generan los renglones estimados con las tarifas vigentes que aplican a la línea y
   al producto (base `UNIDAD_PRODUCIDA` = unidades planificadas). Se congela también el costo
   unitario de cada insumo (`prod_orden_insumo` necesita una columna `costo_unitario_al_planificar`)
   para poder mostrar el material **estimado**.
2. **Registrar producción:** `base_real` = unidades producidas (o lo capturado, si es `MANUAL`);
   `monto_real = base_real × tarifa_aplicada`. El costo unitario del terminado pasa a ser
   `(materiales reales + mano de obra + gastos + indirectos) / unidades producidas`.
3. **La orden muestra** `estimado / real / diferencia` por bloque, como la orden 2757.

### Dónde se enchufa (y por qué depende de Christian)

El cálculo de materiales y el de componentes se pueden hacer en una clase NUEVA
(`CostoOrden`, sin tocar nada). Pero para que el costo llegue al terminado hay que pasarle a
`Produccion::registrar()` un parámetro nuevo (`costosAdicionales`, por producto, aditivo y opcional) que
se suma a `costoPorProducto`. **Eso es una edición de `Produccion.php`**: archivo de Christian, y justo
el que su fase 2 podría estar reescribiendo (pregunta 1).

## Invariantes

1. Sin renglones de componentes, el costo del terminado queda **idéntico** al de hoy (prueba de regresión).
2. Cambiar una tarifa en el catálogo no cambia una orden ya planificada (tarifa congelada).
3. `monto = base × tarifa`, redondeado a 6 decimales y sumado antes de dividir por las unidades.
4. Un componente cuyo producto produjo cero no se aplica (no hay a quién cargarlo), con aviso.
5. La mano de obra y los gastos **nunca** mueven existencias.
6. Un producto sin tarifa aplicable se calcula sin componentes **y se avisa**: no se inventa una tarifa.

## Independiente vs. depende de Christian

| | |
|---|---|
| **Independiente (archivos nuevos)** | las dos tablas y sus migraciones, el catálogo (entidad, servicio, endpoints), `CostoOrden` (la aritmética pura, con sus pruebas), el reporte estimado/real como endpoint nuevo |
| **Puede chocar** | `Produccion.php` (parámetro nuevo), `FlujoOrden.php` (generar renglones al planificar, aplicarlos al registrar), `OrdenInsumo` (columna de costo estimado), la pantalla de la orden |
| **Necesita al cliente** | las tarifas reales (MO001, GF…), qué base usa cada una (unidades u otra cantidad), si las tarifas varían por producto |
| **Necesita a Christian** | pregunta 1 (¿fase 2 ya toca el costeo?), pregunta 3 (el `'S'` vs `'C'` de arriba y el recálculo retroactivo) |

## Lo que NO se resuelve acá

- El costo promedio «en tiempo real» y el recálculo con fechas anteriores (línea de 30 h de la cotización):
  hoy es por período. Se mide primero con datos reales (¿el tipo de producción afecta costo?) antes de diseñar.
- Cuentas contables y pólizas de producción (los define el cliente; la póliza 0004020 de abril es la referencia).

## Estado de la implementación (12-oct-2026)

Rama `claude/2026-10-12-24981-costo-componentes` (sale de la del P0, sin PR). **Solo archivos nuevos**:
`Entity/{TipoCosto,BaseCosto,CostoEstandar,OrdenCosto}`, `Repository/{CostoEstandar,OrdenCosto}Repository`,
`Servicio/{CalculoCostos,CatalogoCostosServicio,CostoOrdenServicio}`, `Controller/{CostoEstandar,OrdenCosto}Controller`,
`migrations/Version20261012190000`, y sus pruebas (76 en total con las del P0; pasan contra el esquema mínimo y
contra el real de mbinv).

| endpoint | para qué | permiso |
|---|---|---|
| `GET/POST /api/costos-estandar`, `…/{id}/cerrar-vigencia`, `…/{id}/desactivar` | el catálogo | `ver` / `administrar` |
| `GET /api/ordenes/{id}/costos` | estimado vs real por bloque (diferencia = estimado − real, negativo = costó más), y por producto el estimado/real/diferencia de COMPONENTES (el estimado de materiales es de la orden: los insumos están consolidados), con avisos | `ver` |
| `POST …/costos/estimar` | genera los renglones estimados con la tarifa congelada | `editar` |
| `POST …/costos/bases` | captura la base (litros, horas…) de un componente MANUAL | `registrar_produccion` |
| `POST …/costos/real` | aplica el costo real (todo o nada) | `registrar_produccion` |

**Enganche con el inventario (14-oct-2026, #24981):**

- `planificar()` estima solo, en la MISMA transacción: la tarifa de cada componente queda congelada al planificar. Si
  estimar falla, la planificación se deshace y el error se ve (la orden sigue en borrador); no hay un estado
  «planificada sin estimar». La bitácora de la estimación se escribe después de confirmar.
- `Produccion::registrar()` recibe `costosAdicionales` (plu => monto total, opcional y aditivo: sin él el costo es
  idéntico al de siempre). El monto se suma antes de repartir entre las unidades que salieron, y entra igual a `costo` y a
  `sicosto` (mano de obra y gastos no llevan impuesto). Nunca crea movimientos ni existencias.
- Qué monto entra (`CostoOrdenServicio::costosParaInventario`): base automática = tarifa × unidades **realmente producidas**;
  base manual = la base **estimada** que se capturó (la real no puede existir todavía: se captura con la producción
  registrada); base manual sin capturar = no se carga nada y la bitácora lo dice en `sinBase`. Una orden sin constancia de
  estimación (planificada antes de este cambio) queda como hoy.
- ⚠️ **Es costo estándar, no real.** Aplicar el real después NO reescribe `detmov`; la diferencia se ve en el resumen.
  Reescribir el costo del terminado a posteriori es otra decisión (afecta el costo promedio de mbinv) y no está hecha.
- `componentesEnInventario` del resumen ahora es verdad cuando algún componente entró (columna
  `prod_orden_costo_estimacion.en_inventario_en`).

**Lo que todavía NO hace (a propósito):**

1. ~~El costo no llega al inventario~~ — resuelto arriba.
2. ~~No se llama sola desde `planificar()`~~ — resuelto arriba. Registrar la producción sigue sin aplicar el real.
3. ~~Los materiales estimados usan el costo unitario de hoy~~ — resuelto (14-oct-2026): `prod_orden_insumo` tiene
   `costo_unitario_al_planificar` (NUMERIC 18,6, nulo) y `costo_congelado` (0/1). Se llenan en `planificar()` con
   `detuni.costo` (fila de menor id, la misma regla que `Produccion`). Es una migración ADITIVA sobre la tabla de
   Christian: no cambia ni lee ninguna columna suya, y sus inserciones siguen valiendo (la columna nueva es nula/0 por
   omisión). Solo congela lo ESTIMADO: el real sale de `detmov` y no se toca. Un insumo sin costo ese día queda
   «congelado sin costo» (cuenta cero y avisa) aunque luego tenga uno. Las órdenes anteriores quedan sin congelar y su
   estimado sigue usando el costo de hoy, con aviso.
4. ~~Sin carga masiva~~ — resuelta (14-oct-2026) como CSV, ver abajo. Sigue sin pantalla.

## Carga masiva de tarifas (CSV)

`POST /api/costos-estandar/carga-masiva` (permiso `administrar`). El cuerpo ES el archivo (`text/csv`). Por omisión es una
**prueba** que valida y no escribe; con `?aplicar=1` guarda, y solo si **ninguna** fila tiene error (todo o nada). Con errores
responde 422 con TODAS las filas malas (`fila` como la ve Excel: la cabecera es la 1) para corregir en una pasada.

| columna | | |
|---|---|---|
| `codigo`, `descripcion`, `tipo`, `tarifa` | obligatorias | tipo: MANO_OBRA · GASTO_FABRICACION · INDIRECTO |
| `base` | opcional | UNIDAD_PRODUCIDA (omisión) · MANUAL |
| `vigente_desde` / `vigente_hasta` | opcionales | AAAA-MM-DD o DD/MM/AAAA; sin desde = hoy |
| `plu_producto`, `linea` | opcionales | ámbito de la tarifa (código de línea) |

Acepta `,` o `;` (el Excel en español guarda con `;` y coma decimal), BOM y Windows-1252. La tarifa no admite separador de
miles (`1,234.50` se rechaza por ambigua). Máximo 500 filas y 1 MB por archivo. Las vigencias que se pisan se detectan contra
lo guardado y **dentro del mismo archivo**.

⚠️ **Es CSV y no .xlsx a propósito**: leer .xlsx pide una librería nueva en el servidor y esa decisión no se tomó. En Excel:
Guardar como → CSV. ⚠️ Los valores de las pruebas son de REFERENCIA INVENTADOS con los códigos de la orden 2757; las tarifas
reales de Envaisa no han llegado.
