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.
Grafo de dependencias
Sección titulada «Grafo de dependencias»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.
Restricciones globales
Sección titulada «Restricciones globales»- 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
REVOKEexplícito de lo que no deba tener: elALTER DEFAULT PRIVILEGESde la migración0001concedeDELETEsobre todo (ADR-0006). /bin/rmpara artefactos de drizzle-kit:rmestá aliaseado atrash.
Task 1 — Schema, migración e invariantes
Sección titulada «Task 1 — Schema, migración e invariantes»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 —
lotescon 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_vencimientoes 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.
Task 2 — Matriz de permisos
Sección titulada «Task 2 — Matriz de permisos»Archivos
-
Modificar:
api/src/modules/authz/matrix.ts, su test, ydocs/…/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
possobrestock:verpasa deBODEGAatodo
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 >= $cantidadRETURNING 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 unidadesCuando 20 procesos intentan descontar 10 cada uno, a la vezEntonces 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.
Task 4 — FIFO y vencidos
Sección titulada «Task 4 — FIFO y vencidos»Archivos
-
Crear:
api/src/modules/stock/asignacion.tsy sus tests -
Paso 1 — Una sola consulta resuelve las dos reglas
WHERE cantidad_disponible > 0 AND fecha_vencimiento >= CURRENT_DATE -- vencidos fueraORDER BY fecha_vencimiento ASC -- FIFOEl 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.
Task 5 — Ajuste y descarte
Sección titulada «Task 5 — Ajuste y descarte»Archivos
-
Crear:
api/src/modules/stock/service.tsy 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.
Task 6 — Cerrar la deuda de M1
Sección titulada «Task 6 — Cerrar la deuda de M1»Archivos
-
Modificar:
api/src/modules/productos/service.tsy 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.
Task 7 — Endpoints
Sección titulada «Task 7 — Endpoints»Archivos
-
Crear:
api/src/modules/stock/{routes,validation}.tsy 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.
Task 8 — Pantallas en web/
Sección titulada «Task 8 — Pantallas en web/»Archivos
-
Crear:
web/src/app/(app)/modulos/stock/…yweb/src/components/stock/ -
Paso 1 — Cargar la skill
aquazaku-bffantes 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.
Task 9 — Bruno, documentación y cierre
Sección titulada «Task 9 — Bruno, documentación y cierre»- Paso 1 — Carpeta
5-Stock, antes deSesion
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.
Definition of Done de M2
Sección titulada «Definition of Done de M2»- Un ajuste inicial crea el lote con vencimiento a 30 días y su saldo.
- Un
UPDATEdirecto a saldo negativo es rechazado por Postgres. - Ajuste sin motivo y descarte sin causa son rechazados por la base.
- 20 descuentos concurrentes de 10 sobre un lote de 100: exactamente 10 tienen éxito.
- La salida consume el lote más próximo a vencer y puede abarcar varios.
- Un lote vencido no se vende y conserva su saldo.
- El libro no se puede editar ni borrar, ni siquiera con el rol de la app.
sellerycontadorreciben 403 al ajustar o descartar.- Desactivar un producto con stock devuelve 409 — deuda de M1 cerrada.
- Bruno verde contra servidor real;
/docsal día. - Verificado en un browser real, con todos los servicios arriba.
Notas de ejecución
Sección titulada «Notas de ejecución»(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.)
Verificación final — 22-ago-2026
Sección titulada «Verificación final — 22-ago-2026»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_disponibleLa 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.
