Ir al contenido

Plan de M2 — Stock de producto terminado

Objetivo: implementar M2 según la spec de diseño — libro de movimientos, lotes con vencimiento, saldo que no puede quedar negativo bajo concurrencia, FIFO, descarte y ajuste.

Dominio: Stock — RN-STK-01, 02, 03, 05, 06, 07 y 08.

Estado: ✅ Terminado (22-ago-2026) — las 9 tasks cerradas.


El orden no es cronológico: es por costo de revertir

Sección titulada «El orden no es cronológico: es por costo de revertir»

Este módulo lleva cuentas que tienen que cuadrar. Un error acá no se manifiesta como una pantalla rota — se manifiesta semanas después, como un inventario que no coincide con lo que hay en la bodega, y sin forma de saber cuándo empezó a desviarse.

Por eso las tasks van ordenadas por cuánto duele arreglarlas tarde, no por comodidad de construcción.

Riesgo Qué pasa si se descubre tarde Task
🔴 Concurrencia mal resuelta Vende más de lo que hay. Silencioso, intermitente, imposible de reproducir a mano. Se descubre contando cajas T3
🔴 Saldo derivado en vez de materializado Cambiarlo con movimientos ya cargados es una migración de datos con la app parada T1
🔴 fecha_vencimiento generada Cambiar la regla reescribe el vencimiento de todos los lotes viejos, incluidos los ya vendidos T1
🟠 Movimientos editables Para cuando alguien dependa de editarlos, volver atrás rompe código ajeno T1
🟠 El alcance BODEGA del pos El pos no puede ver stock: recibe un error, no una lista vacía T2
🟢 Deuda de RN-CAT-02 Aditivo, se cierra cuando se pueda T6

T3 llega antes que todo lo que se apoya en el saldo, y a propósito: si el modelo no aguanta concurrencia, hay que saberlo con dos tablas construidas, no con nueve tasks encima.

Lo que va a romperse en el camino, a propósito

Sección titulada «Lo que va a romperse en el camino, a propósito»

T2 rompe tests de M0. Cambiar el alcance del pos de BODEGA a todo hace fallar los que afirman lo contrario. Igual que en M1, eso está bien: esos tests dicen una verdad que dejó de serlo. Se actualizan entendiendo por qué fallaron, nunca se silencian.

T6 toca el servicio de M1. Agregar la verificación de stock a desactivarProducto() puede hacer fallar tests de productos que desactivan sin haber creado stock. Se ajustan las fixtures, no la regla.


T1 schema ──┬── T3 saldo atómico ──┬── T4 FIFO ──┬── T5 ajuste y descarte ──┬── T7 endpoints ── T8 web
│ + concurrencia │ │ │
│ └─────────────┴── T6 deuda RN-CAT-02 ────┘
T2 matriz ──────────────────────────────────────────────────────────────────┴── T9 bruno + docs
  • T1 y T2 son independientes.
  • T3 es la compuerta: nada que use el saldo arranca antes de que pase.
  • T9 cierra.
  • Convención del proyecto: 2 espacios, comillas simples, sin punto y coma.
  • Conventional commits, sin Co-Authored-By. Nunca buildear.
  • Cada commit deja el proyecto en verde: tests + typecheck.
  • Migraciones en SQL explícito, escritas a mano.
  • Toda tabla nueva necesita REVOKE explícito de lo que no deba tener: el ALTER DEFAULT PRIVILEGES de la migración 0001 concede DELETE sobre todo (ADR-0006).
  • /bin/rm para artefactos de drizzle-kit: rm está aliaseado a trash.

Archivos

  • Modificar: api/src/db/schema.ts, api/src/test/db.ts

  • Crear: api/src/db/migrations/0003_stock.sql

  • Crear: api/src/db/__tests__/stock-schema.test.ts

  • Paso 1 — lotes con el saldo encima

Sin tabla de saldo aparte: un lote ya es “producto + fecha”, agregarle la cantidad evita una tabla que solo repetiría su clave.

  • Paso 2 — fecha_vencimiento es una columna común, NO generada
fechaVencimiento: date('fecha_vencimiento').notNull(),

Es la trampa de este módulo. Parece el caso de litros en M1 —calculado por el sistema, nunca tipeado— pero litros es una definición y esto es un hecho de un momento. Con una columna generada, cambiar la regla a 45 días recalcularía el vencimiento de todos los lotes del pasado, incluidos los ya vendidos.

  • Paso 3 — Los CHECK, incluidos los condicionales
CHECK (cantidad_disponible >= 0)
CHECK (cantidad <> 0)
CHECK (tipo <> 'ajuste' OR motivo IS NOT NULL)
CHECK (tipo <> 'descarte' OR causa IS NOT NULL)

Los dos últimos son la forma de exigir motivo y causa solo donde corresponde, sin obligar a un motivo en una venta.

  • Paso 4 — Movimientos append-only, y el REVOKE
GRANT SELECT, INSERT ON movimientos_stock TO aquazaku_app;
REVOKE UPDATE, DELETE ON movimientos_stock FROM aquazaku_app;
REVOKE DELETE ON lotes FROM aquazaku_app;

Un libro que se puede editar no es un libro. lotes sí necesita UPDATE — es donde vive el saldo.

  • Paso 5 — Tests que verifican que la base rechace
Verifica Cómo
El saldo no puede ser negativo UPDATE directo a -1 → error de Postgres
Un ajuste sin motivo no entra INSERT sin motivo → violación de CHECK
Un descarte sin causa no entra Ídem
El libro no se edita UPDATE sobre un movimiento → insufficient_privilege
Un movimiento en cero no existe INSERT con cantidad = 0 → rechazado

Cierra cuando: los cinco pasan y psql rechaza un saldo negativo a mano.


Archivos

  • Modificar: api/src/modules/authz/matrix.ts, su test, y docs/…/dominio/roles-y-permisos.md

  • Paso 1 — Agregar stock:descartar

admin ✅ · pos ✅ · seller ❌ · contador ❌.

RN-STK-06 lleva tiempo confirmada sin permiso que la represente. El pos la tiene porque manipula el producto y ve el daño — mismo criterio que ya usa para botellones y bases.

  • Paso 2 — El alcance del pos sobre stock:ver pasa de BODEGA a todo

Sin columna ubicacion, el alcance BODEGA lanza ScopeNoAplicableError (ADR-0005). El pos no vería una lista vacía: recibiría un error.

Con una sola bodega, todo significa lo mismo y es más honesto. BODEGA queda en el modelo, sin usar, para cuando M8 traiga una segunda ubicación.

  • Paso 3 — Los tres archivos, un commit

El test transcribe el doc a mano para no ser circular, así que la celda se escribe dos veces. Y nadie verifica el documento: hay que cruzarlo a mano entre repos.

  • Paso 4 — Arreglar los tests que fallen, entendiendo por qué

Cierra cuando: matriz verde, doc y código cruzados, y ningún test silenciado.


Task 3 — El saldo, y la prueba de que aguanta

Sección titulada «Task 3 — El saldo, y la prueba de que aguanta»

Compuerta del módulo. Nada que use el saldo arranca hasta que esto pase.

Archivos

  • Crear: api/src/modules/stock/saldo.ts

  • Crear: api/src/modules/stock/__tests__/concurrencia.test.ts

  • Paso 1 — Descontar decide y resta en una sola operación

UPDATE lotes
SET cantidad_disponible = cantidad_disponible - $cantidad
WHERE id = $lote AND cantidad_disponible >= $cantidad
RETURNING cantidad_disponible;

Cero filas es una respuesta, no un error: significa “no alcanzaba”. El servicio la traduce a STOCK_INSUFICIENTE.

Lo que no se hace: leer el saldo, comparar en TypeScript y después restar. Entre la lectura y la resta hay una ventana, y por esa ventana se vende producto que no existe.

  • Paso 2 — Movimiento y saldo, en la misma transacción

Si el saldo baja y el movimiento no se registra, el libro deja de explicar el saldo. Es la primera forma de descuadre, y la más difícil de rastrear después.

  • Paso 3 — El test que decide si el modelo sirve
Dado un lote con 100 unidades
Cuando 20 procesos intentan descontar 10 cada uno, a la vez
Entonces exactamente 10 tienen éxito
Y el saldo final es 0
Y hay exactamente 10 movimientos
  • Paso 4 — Devolver también es atómico

Una devolución suma al mismo lote (RN-STK-05). Sin WHERE de saldo —sumar siempre puede— pero sí en la misma transacción que su movimiento.

Cierra cuando: el test de concurrencia pasa de forma reproducible, no una vez.


Archivos

  • Crear: api/src/modules/stock/asignacion.ts y sus tests

  • Paso 1 — Una sola consulta resuelve las dos reglas

WHERE cantidad_disponible > 0
AND fecha_vencimiento >= CURRENT_DATE -- vencidos fuera
ORDER BY fecha_vencimiento ASC -- FIFO

El bloqueo de vencidos no es un job nocturno: un lote vence solo y a la mañana no aparece.

  • Paso 2 — Una salida puede abarcar varios lotes

Pedir 30 con el lote más viejo en 20 genera dos movimientos: 20 y 10. Cada uno con su lote_id — es lo que hace posible responder a un recall.

  • Paso 3 — Vencido NO es descartado

Un lote vencido conserva su saldo: no se vende, pero está físicamente en la bodega ocupando lugar. Restarlo solo porque venció sería perder la cuenta de producto que existe.

  • Paso 4 — Tests con reloj controlado

Un lote que vence hoy todavía sirve; uno que venció ayer, no. El borde se testea con fechas explícitas, nunca con new Date().

Cierra cuando: tres lotes con vencimientos distintos se consumen en orden, y el vencido conserva su saldo.


Archivos

  • Crear: api/src/modules/stock/service.ts y sus tests

  • Paso 1 — El ajuste crea el lote la primera vez

M2 no depende de M4, así que el stock entra por ajuste: la carga inicial de inventario es un ajuste con motivo. Cuando llegue M4, el cierre de producción será otro documento que crea lotes.

  • Paso 2 — El descarte exige causa, y la causa tiene consecuencias

falla_produccion marca reposición pendiente; mal_manejo_cliente entra al historial del cliente. Ninguna castiga automáticamente — el admin decide (RN-STK-06, mismo patrón que RN-BAS-08).

M2 registra la causa. El efecto sobre el cliente es de M5, y la reposición de M6.

  • Paso 3 — Auditoría bloqueante

Ajustar y descartar son acciones sensibles: RN-ACC-04 las nombra. Si no se puede auditar, la operación falla (ADR-0007).

El payload lleva el saldo antes y después. Sin eso, la bitácora dice que hubo un ajuste pero no de cuánto a cuánto.

Cierra cuando: ajuste y descarte quedan en la bitácora con saldo antes y después, y un rechazo no escribe nada.


Archivos

  • Modificar: api/src/modules/productos/service.ts y sus tests

  • Paso 1 — desactivarProducto() verifica el saldo

const enStock = await saldoTotalDe(producto.id)
if (enStock > 0) {
throw new ErrorDeNegocio('PRODUCTO_CON_STOCK', 409,
`quedan ${enStock} unidades en stock: descartalas o vendelas antes de desactivar`)
}
  • Paso 2 — Sacar el comentario de deuda y dejar la cita de la regla

  • Paso 3 — Ajustar las fixtures de M1 que rompan

Los tests que desactivan un producto sin stock siguen pasando. Los que asumían que siempre se puede desactivar, no — y ahí la regla manda sobre la fixture.

Cierra cuando: desactivar con stock devuelve 409 y la suite de M1 sigue verde.


Archivos

  • Crear: api/src/modules/stock/{routes,validation}.ts y tests

  • Modificar: api/src/app.ts

  • Paso 1 — Cinco rutas, ninguna que edite el saldo

GET /stock · GET /stock/:productoId/lotes · GET /stock/movimientos · POST /stock/ajustes · POST /stock/descartes.

No hay PUT ni PATCH sobre el saldo. Que esas rutas no existan es parte del contrato (RN-STK-02), y hay un test que lo verifica.

  • Paso 2 — Zod valida forma; el servicio valida las reglas

Una regla, un código de error, un lugar. Si Zod también exigiera el motivo, el mismo problema devolvería 400 o 422 según quién lo atrapara primero.

  • Paso 3 — Tests E2E, incluidos los 403

seller y contador reciben 403 al ajustar y al descartar. pos puede ambas.

Cierra cuando: una petición HTTP real devuelve lo esperado en los cinco casos.


Archivos

  • Crear: web/src/app/(app)/modulos/stock/… y web/src/components/stock/

  • Paso 1 — Cargar la skill aquazaku-bff antes de escribir

  • Paso 2 — Stock por producto, con desglose por lote

Server Components vía apiServerFetch(). Un lote vencido se ve distinto de uno agotado: el primero tiene producto que hay que descartar.

  • Paso 3 — Ajustar y descartar por Server Action

  • Paso 4 — El aviso mira lo que importa

Lección de M1 T7: la pregunta no es “¿le falta algo?” sino “¿qué acción hace falta?”. Acá son dos distintas — hay vencido para descartar, o hay poco stock — y cada una pide algo diferente.

  • Paso 5 — Verificación manual en browser

Ajuste inicial → verlo en el libro → descartar unidades → confirmar que el saldo cuadra → encontrarlo en la auditoría con el antes y el después.

Cierra cuando: el recorrido se hizo en un browser real.


  • Paso 1 — Carpeta 5-Stock, antes de Sesion

El logout va último o todo lo posterior corre sin cookie. 6-Sesion.

Incluir los que deben fallar: ajuste sin motivo, descarte sin causa, salida mayor al saldo, y un seller recibiendo 403.

  • Paso 2 — Documentar lo implementado

backend/, base-de-datos/, frontend/, roadmap, modulos.

  • Paso 3 — Barrido de /docs — links con anclas, frontmatter, mojibake, y números de regla sin duplicar.

  • Paso 4 — Verificación final de punta a punta

Cierra cuando: Bruno verde contra servidor real y /docs refleja lo que existe.


  1. Un ajuste inicial crea el lote con vencimiento a 30 días y su saldo.
  2. Un UPDATE directo a saldo negativo es rechazado por Postgres.
  3. Ajuste sin motivo y descarte sin causa son rechazados por la base.
  4. 20 descuentos concurrentes de 10 sobre un lote de 100: exactamente 10 tienen éxito.
  5. La salida consume el lote más próximo a vencer y puede abarcar varios.
  6. Un lote vencido no se vende y conserva su saldo.
  7. El libro no se puede editar ni borrar, ni siquiera con el rol de la app.
  8. seller y contador reciben 403 al ajustar o descartar.
  9. Desactivar un producto con stock devuelve 409 — deuda de M1 cerrada.
  10. Bruno verde contra servidor real; /docs al día.
  11. Verificado en un browser real, con todos los servicios arriba.

(Se completan mientras se implementa. En M0 y M1 esta sección resultó ser la parte más útil del documento: es donde quedan los bugs que ninguna suite miraba.)

El recorrido lo hizo Aquazaku en su browser; la verificación se hizo contra la base, que es la parte que no se puede fingir.

El libro explica el saldo exacto:

lote 2026-08-21-L1 · BOT_20L
ajuste +120 Carga inicial para inventario
ajuste -8 Quedaron mal empacadas y se perdieron
descarte -2 falla_produccion
────────────────────────────────────
saldo 110 coincide con cantidad_disponible

La cadena de auditoría no tiene huecos: 0 → 120 → 112 → 110. Cada después es el antes del siguiente, así que ninguna operación se ejecutó sin quedar registrada.

El ajuste negativo quedó como ajuste, no como venta — el bug de T5, que habría inflado el reporte de ventas con unidades que nadie compró.

Los rechazos no llegaron a api/. No hay entradas denied de stock:* en la bitácora, y eso confirma que los guards del cliente funcionaron: un motivo corto y una causa otro sin explicar se frenaron antes de gastar un viaje. Los guards del servidor los cubre Bruno, con 43 requests contra un servidor real.

Un bug de UX que encontró Aquazaku y ningún test: los campos de motivo y observaciones no se limpiaban después de registrar. Además de la molestia, dejar -8 escrito invita a mandarlo otra vez sin querer — en un inventario, eso es un descuadre. Corregido derivando la limpieza de un token que la acción devuelve con cada éxito.

Cifras finales: api/ 539 tests · web/ 238 · Bruno 43 requests / 106 aserciones.