WarehouseProject
ERP ligero para una tienda o un almacén: pedidos que descuentan stock de forma atómica, facturas con IVA en PDF y tres roles con permisos.
En 30 segundos
- Categoría
- Full stack · Inventario y ventas
- Estado
- Completo · sin demo pública
- Stack
- Node.js · Express 5 · Prisma · PostgreSQL 16 · React 19
- Tests
- 36 de integración contra PostgreSQL real, incluidos de concurrencia
- Seguridad
- JWT con refresh rotado · 3 roles validados en el backend
- Entrega
- Docker Compose · CI en GitHub Actions · despliegue preparado para Render y Vercel
- Demo online
- Sin demo pública
Visión general
- Qué es
- Una aplicación web completa (API REST y SPA) para gestionar el catálogo, el inventario, los clientes, los pedidos y la facturación de una tienda o un almacén pequeño.
- Qué problema resuelve
- En un negocio pequeño, el stock y la facturación suelen vivir en hojas de cálculo que no aguantan dos personas trabajando a la vez: se vende lo que ya no hay y no se sabe por qué cambió una cifra.
- Para quién
- Un equipo pequeño con tres perfiles: administración, comerciales que crean pedidos y almacén que registra entradas y salidas.
- Objetivo
- Que el stock sea siempre explicable y correcto aunque varias personas trabajen a la vez, y que cada rol vea y haga solo lo que le corresponde.
Vista previa


Funcionalidades
Pedidos con descuento de stock transaccional
Un pedido pasa por borrador, confirmado, pagado o cancelado. Al confirmarlo se descuenta el stock de todas sus líneas en una sola transacción; al cancelarlo se devuelve.
Para qué sirve: Si a una línea le falta stock no se confirma nada, así que nunca queda un pedido a medias.

Libro de movimientos de stock
Cada entrada o salida queda registrada: reposiciones, mermas, ajustes y las salidas que generan los pedidos.
Para qué sirve: El stock actual siempre se puede explicar mirando su historial.

Facturas con IVA, PDF y vencimientos
Las facturas se emiten desde un pedido, guardan base, IVA y total, se descargan en PDF y pasan solas a vencidas cuando se supera la fecha de cobro.
Para qué sirve: Los importes de una factura no cambian aunque luego cambien los precios o el IVA.

Tres roles con permisos por recurso
ADMIN, COMERCIAL y ALMACEN pueden consultar todo, pero cada rol solo escribe en lo que le toca. El backend lo valida siempre; el frontend oculta lo que no puedes hacer.
Para qué sirve: Nadie puede saltarse un permiso llamando a la API directamente.

Diseño adaptado al móvil
En pantallas estrechas las columnas secundarias se ocultan y lo esencial pasa debajo del dato principal. Revisado a mano en 360, 768, 1024, 1280 y 1440 px.
Para qué sirve: El almacén puede registrar movimientos desde el móvil.

Panel con métricas del negocio
Ventas e ingresos del mes, pendiente de cobro, ventas de los últimos seis meses, productos más vendidos, stock bajo y pedidos por estado.
Para qué sirve: Una vista rápida de cómo va el negocio sin exportar nada.
Galería
Recurso pendienteVídeo pendiente: confirmar un pedido y ver cómo baja el stock
Stack
Backend
- Node.js + Express 5
- API REST
- TypeScript (strict)
- Tipado en todo el proyecto
- Zod
- Validación de peticiones y de variables de entorno
- jsonwebtoken + bcryptjs
- Autenticación
- helmet · express-rate-limit · cors
- Cabeceras seguras y límite de intentos de login
- pdfkit
- PDF de facturas
Base de datos
- PostgreSQL 16
- Datos relacionales, importes en NUMERIC
- Prisma 6
- Esquema, migraciones y acceso a datos
Frontend
- React 19 + Vite
- SPA con carga de páginas bajo demanda
- TanStack Query
- Datos del servidor y caché
- React Hook Form + Zod
- Formularios
- Tailwind CSS 4
- Estilos
- Recharts
- Gráficas del resumen
Testing
- Jest + Supertest
- Tests de integración contra PostgreSQL
- Vitest
- Lógica pura del frontend
Infraestructura
- Docker + Docker Compose
- PostgreSQL, API y frontend con un comando
- nginx
- Sirve la SPA y reenvía /api
- GitHub Actions
- Lint, formato, tipos, tests, build e imágenes
- Render + Vercel
- Despliegue preparado (blueprint y configuración)
Arquitectura
Monorepo con dos proyectos independientes. La API sigue una arquitectura por capas en la que cada capa tiene una sola responsabilidad, y el service decide qué operaciones van juntas en una transacción.
- Las rutas solo encadenan middlewares (autenticación, rol, validación); el controller traduce HTTP; el service tiene la lógica de negocio y abre las transacciones; el repository es el único que habla con Prisma.
- Los repositories reciben el cliente de la transacción, así que el mismo método funciona dentro o fuera de una transacción sin saberlo.
- Todos los errores acaban en un único middleware: Zod → 400, negocio → 404/409, errores de Prisma traducidos y nunca un stacktrace.
- En Docker, nginx sirve la SPA y reenvía /api a la API, que aplica las migraciones al arrancar.
Decisiones técnicas
Cada decisión con el problema, la solución, el motivo y el lugar exacto del código donde está.
Stock sin condiciones de carrera
- Problema
- Dos comerciales confirman a la vez un pedido con la última unidad de un producto. Si se lee el stock y luego se resta, los dos ven una unidad disponible y el stock acaba en −1.
- Solución
- Un único
UPDATEcondicional que solo descuenta sistock >= cantidad. Si no afecta a ninguna fila, la API responde 409 y se deshace toda la transacción del pedido. - Por qué
- La base de datos decide de forma atómica, sin leer antes ni bloquear a mano. Hay un test que lanza las dos peticiones a la vez.
- Código
- pedido-estado.repository.ts:32 (se abre en una pestaña nueva)backend/src/modules/pedido/pedido-estado.repository.ts
- Test
“dos pedidos a la vez por el último stock: solo uno se confirma”
pedidos.test.ts:114 (se abre en una pestaña nueva)
Sin interbloqueos entre pedidos
- Problema
- Dos pedidos que comparten productos pueden bloquear filas en orden distinto (A luego B, y B luego A) y esperarse mutuamente para siempre.
- Solución
- Las líneas de un pedido se procesan siempre ordenadas por
productoId. - Por qué
- Si todas las transacciones bloquean en el mismo orden, no puede formarse un ciclo de espera. Cuesta una línea de código.
- Código
- pedido-estado.repository.ts:18 (se abre en una pestaña nueva)backend/src/modules/pedido/pedido-estado.repository.ts
Numeración correlativa sin duplicados
- Problema
- Pedidos y facturas llevan números correlativos (
PED-2026-0001). Dos altas simultáneas pueden leer el mismo último número y generar el mismo siguiente. - Solución
- El cálculo del siguiente número se hace bajo un advisory lock de PostgreSQL (
pg_advisory_xact_lock) que se libera solo al terminar la transacción. - Por qué
- Serializa solo la numeración, no toda la tabla, y no deja bloqueos olvidados.
- Código
- numeracion.ts:26 (se abre en una pestaña nueva)backend/src/common/numeracion.ts
- Test
“pedidos creados a la vez reciben números distintos”
pedidos.test.ts:71 (se abre en una pestaña nueva)
Dinero exacto e histórico inmutable
- Problema
- Los importes en coma flotante acumulan errores de redondeo, y si una factura lee el precio actual del producto, su total cambia cuando el precio cambia.
- Solución
- Importes en
NUMERIC(10,2)con redondeo explícito del IVA. Cada línea guarda el precio del momento y cada factura sus importes (snapshots). - Por qué
- Un documento contable no puede cambiar después de emitido.
- Código
- schema.prisma:185 (se abre en una pestaña nueva)backend/prisma/schema.prisma
- Test
“guarda el precio del momento aunque luego cambie el del producto”
pedidos.test.ts:36 (se abre en una pestaña nueva)
Refresh token rotado y renovación compartida
- Problema
- Un refresh token robado sirve para siempre si no se invalida, y en el frontend varias peticiones que caducan a la vez lanzarían varias renovaciones.
- Solución
- Cada refresh token sirve una sola vez y se guarda como hash SHA-256. En el frontend, las peticiones que caducan a la vez esperan a una única renovación en curso.
- Por qué
- Si alguien usa un token robado, el siguiente refresh del usuario legítimo falla y el robo se nota. Y la rotación no rompe la sesión cuando hay peticiones en paralelo.
- Código
- auth.service.ts:68 (se abre en una pestaña nueva)backend/src/modules/auth/auth.service.ts
- api.ts:43 (se abre en una pestaña nueva)frontend/src/lib/api.ts
- Test
“rota el refresh token: el usado ya no vale”
auth.test.ts:67 (se abre en una pestaña nueva)
Lo que falta
- Una factura anulada no se puede volver a emitir: faltan las facturas rectificativas.
- Los selectores del formulario de pedido cargan hasta 100 elementos; falta búsqueda remota.
- Sin tests end-to-end del frontend.