Proyecto práctico: Sistema de inventario con PostgreSQL — modelado, índices, funciones y triggers

sql Proyecto práctico: Sistema de inventario con PostgreSQL — modelado, índices, funciones y triggers

Objetivo

Construir un esquema de base de datos en PostgreSQL para un sistema de inventario sencillo pero robusto: modelado relacional, scripts de migración, índices para rendimiento, funciones y triggers para integridad y operaciones atómicas. Incluye ejemplos de consultas útiles para reporting.

Requisitos previos

  • PostgreSQL 12+ instalado y acceso a psql.
  • Permisos para crear esquemas, tablas y extensiones (o pedir a DBA).
  • Conocimientos básicos de SQL y transacciones.

Estructura de carpetas (sugerida)


inventory-sql/
  migrations/
    1_init.sql
    2_seed.sql
    3_indexes.sql
    4_functions_triggers.sql
    5_queries.sql
  README.md

Archivo 1: migrations/1_init.sql


-- crear esquema y tablas base
create schema if not exists inventory;

-- extensión para trigram search (opcional pero útil)
create extension if not exists pg_trgm;

create table if not exists inventory.products (
  id serial primary key,
  sku text not null unique,
  name text not null,
  description text,
  preferred_reorder_quantity integer default 0,
  min_stock integer default 0
);

create table if not exists inventory.warehouses (
  id serial primary key,
  name text not null unique,
  location text
);

-- movimientos de inventario: recibos, salidas, ajustes
create type inventory.movement_type as enum ('in','out','adjust');

create table if not exists inventory.stock_movements (
  id bigserial primary key,
  product_id integer not null references inventory.products(id) on delete restrict,
  warehouse_id integer not null references inventory.warehouses(id) on delete restrict,
  quantity numeric not null check (quantity <> 0),
  movement inventory.movement_type not null,
  unit_cost numeric(12,2), -- opcional para valoración
  note text,
  created_at timestamptz not null default now()
);

-- tabla denormalizada para stock actual por producto y bodega
create table if not exists inventory.product_stock (
  product_id integer references inventory.products(id) on delete cascade,
  warehouse_id integer references inventory.warehouses(id) on delete cascade,
  quantity numeric not null default 0,
  last_updated timestamptz default now(),
  constraint product_stock_pk primary key (product_id, warehouse_id)
);

Por qué así

  • Separamos movimientos (audit trail) de la tabla de stock actual para permitir reconciliaciones y trazabilidad.
  • El tipo movement_type fuerza valores válidos.
  • La tabla denormalizada product_stock acelera consultas de stock en tiempo real evitando agregaciones constantes sobre movimientos.

Archivo 2: migrations/2_seed.sql


-- datos de ejemplo
insert into inventory.products (sku, name, description, preferred_reorder_quantity, min_stock)
values
  ('SKU-001','Tornillo 8mm','Tornillo métrico 8mm',100,10),
  ('SKU-002','Tuerca 8mm','Tuerca para tornillo 8mm',200,20),
  ('SKU-003','Arandela 8mm','Arandela plana 8mm',500,50)
on conflict (sku) do nothing;

insert into inventory.warehouses (name, location)
values
  ('Almacén Central','Madrid'),
  ('Almacén Norte','Bilbao')
on conflict (name) do nothing;

-- movimientos iniciales
insert into inventory.stock_movements (product_id, warehouse_id, quantity, movement, unit_cost)
values
  (1,1,100,'in',0.05),
  (2,1,200,'in',0.03),
  (3,2,500,'in',0.01);

-- inicializar tabla product_stock sumando movimientos actuales
truncate inventory.product_stock;

insert into inventory.product_stock (product_id, warehouse_id, quantity, last_updated)
select product_id, warehouse_id, coalesce(sum(quantity),0), max(created_at)
from inventory.stock_movements
group by product_id, warehouse_id;

Por qué así

  • Los seed permiten probar consultas y validar triggers y funciones.
  • La inicialización de product_stock se hace agregando movimientos existentes.

Archivo 3: migrations/3_indexes.sql


-- índices para consultas frecuentes
create index if not exists idx_stockmovements_product_created
  on inventory.stock_movements (product_id, created_at desc);

create index if not exists idx_stockmovements_warehouse_created
  on inventory.stock_movements (warehouse_id, created_at desc);

-- índice para consultas por producto/almacén (ya hay PK en product_stock)

-- índice trigram para búsqueda por nombre de producto
create index if not exists idx_products_name_trgm
  on inventory.products using gin (name gin_trgm_ops);

-- índice parcial para productos bajos de stock (según tabla denormalizada)
create index if not exists idx_product_stock_low
  on inventory.product_stock (warehouse_id)
  where quantity <= 50; -- ajuste según umbral típico

-- estadísticas de ayuda
analyze inventory.products;
analyze inventory.stock_movements;
analyze inventory.product_stock;

Por qué así

  • Índices compuestos que acompañan las consultas por producto/fecha o almacén/fecha.
  • Trigram para búsquedas textuales rápidas en name.
  • Índice parcial para acelerar la detección de productos con stock bajo.

Archivo 4: migrations/4_functions_triggers.sql


-- función para aplicar un movimiento y mantener product_stock en una transacción
create or replace function inventory.apply_movement()
returns trigger language plpgsql as $$
begin
  -- aplicamos el movimiento: ajustamos la fila product_stock correspondiente
  if tg_op = 'INSERT' then
    perform 1 from inventory.product_stock
      where product_id = new.product_id and warehouse_id = new.warehouse_id;

    if found then
      update inventory.product_stock
      set quantity = quantity + new.quantity,
          last_updated = new.created_at
      where product_id = new.product_id and warehouse_id = new.warehouse_id;
    else
      insert into inventory.product_stock(product_id, warehouse_id, quantity, last_updated)
      values (new.product_id, new.warehouse_id, new.quantity, new.created_at);
    end if;
    return new;
  elsif tg_op = 'DELETE' then
    -- para simplicidad, no permitimos borrar movimientos históricos; si ocurre,
    -- recalculamos la fila afectada
    update inventory.product_stock ps
    set quantity = sub.sumq, last_updated = now()
    from (
      select product_id, warehouse_id, coalesce(sum(quantity),0) as sumq
      from inventory.stock_movements
      where product_id = old.product_id and warehouse_id = old.warehouse_id
      group by product_id, warehouse_id
    ) sub
    where ps.product_id = sub.product_id and ps.warehouse_id = sub.warehouse_id;
    return old;
  elsif tg_op = 'UPDATE' then
    -- en actualización, recomputar por seguridad
    update inventory.product_stock ps
    set quantity = sub.sumq, last_updated = now()
    from (
      select product_id, warehouse_id, coalesce(sum(quantity),0) as sumq
      from inventory.stock_movements
      where product_id in (old.product_id, new.product_id)
        and warehouse_id in (old.warehouse_id, new.warehouse_id)
      group by product_id, warehouse_id
    ) sub
    where ps.product_id = sub.product_id and ps.warehouse_id = sub.warehouse_id;
    return new;
  end if;
end;
$$;

-- trigger que llama a la función después de cada insert/update/delete en stock_movements
create trigger trg_apply_movement
after insert or update or delete on inventory.stock_movements
for each row execute function inventory.apply_movement();

Por qué así

  • La función/trigger mantiene la tabla denormalizada en la misma transacción que inserta el movimiento, evitando inconsistencias.
  • Usamos after para que el movimiento exista antes de actualizar el stock; se podría usar before si se quiere validar contra saldo actual.
  • Para cargas masivas se podría deshabilitar el trigger y recomputar al final por rendimiento.

Archivo 5: migrations/5_queries.sql


-- 1) Stock actual por producto y almacén
select p.sku, p.name, w.name as warehouse, ps.quantity
from inventory.product_stock ps
join inventory.products p on p.id = ps.product_id
join inventory.warehouses w on w.id = ps.warehouse_id
order by p.sku, w.name;

-- 2) Movimientos recientes de un SKU
select sm.id, sm.created_at, sm.movement, sm.quantity, sm.unit_cost, sm.note, w.name
from inventory.stock_movements sm
join inventory.products p on p.id = sm.product_id
join inventory.warehouses w on w.id = sm.warehouse_id
where p.sku = 'SKU-001'
order by sm.created_at desc
limit 50;

-- 3) Productos por debajo del stock mínimo
select p.sku, p.name, sum(ps.quantity) as total_stock, p.min_stock
from inventory.product_stock ps
join inventory.products p on p.id = ps.product_id
group by p.sku, p.name, p.min_stock
having coalesce(sum(ps.quantity),0) <= p.min_stock
order by total_stock;

-- 4) Valoración simple por almacén (cantidad * último unit_cost aproximado)
-- nota: para valoración real hay metodologías (FIFO, LIFO, promedio ponderado)
with last_cost as (
  select distinct on (product_id, warehouse_id) product_id, warehouse_id, unit_cost
  from inventory.stock_movements
  where unit_cost is not null
  order by product_id, warehouse_id, created_at desc
)
select w.name as warehouse, sum(ps.quantity * coalesce(lc.unit_cost,0)) as total_value
from inventory.product_stock ps
left join last_cost lc on lc.product_id = ps.product_id and lc.warehouse_id = ps.warehouse_id
join inventory.warehouses w on w.id = ps.warehouse_id
group by w.name;

-- 5) Búsqueda de producto por nombre (trigram)
select id, sku, name from inventory.products
where name ilike '%tornillo%'
order by similarity(name,'tornillo') desc
limit 10;

Por qué estas consultas

  • Cubren reporting típico: stock por almacén, historial por SKU, alertas de reaprovisionamiento y valoración.
  • La valoración usa el último unit_cost por simplicidad; en producción decide la política de coste (FIFO/LIFO/prom. ponderado).

Cómo ejecutar


-- desde la línea de comandos (conéctate a la BD donde quieres crear el esquema)
psql -d tu_basedatos -f migrations/1_init.sql
psql -d tu_basedatos -f migrations/2_seed.sql
psql -d tu_basedatos -f migrations/3_indexes.sql
psql -d tu_basedatos -f migrations/4_functions_triggers.sql
-- después puedes probar las consultas
psql -d tu_basedatos -f migrations/5_queries.sql

Buenas prácticas y decisiones de diseño

  • Mantener movimientos históricos permite auditoría y reconciliación; no borres movimientos salvo con proceso controlado.
  • Denormalizar (product_stock) para lecturas rápidas es habitual; mantén la fuente de verdad en stock_movements.
  • Para altas tasas de escritura considera batch inserts y deshabilitar triggers temporalmente, luego recomputar agregados.
  • Piensa en particionado si la tabla stock_movements crece mucho (por fecha o por warehouse).
  • Usa EXPLAIN (ANALYZE, BUFFERS) para investigar consultas lentas y ajustar índices.

Tip avanzado: si necesitas valoración FIFO exacta, implementa una tabla de lotes/entradas con cantidades residuales y consume lotes desde más antiguos hacia nuevos en una función que haga la salida. Eso mantiene trazabilidad de coste por salida y evita aproximaciones basadas en último coste.

Advertencia de seguridad: si este esquema se expone vía una API, evita construir SQL dinámico concatenando entradas de usuario; usa sentencias parametrizadas o funciones con parámetros para prevenir inyección SQL.

Siguiente paso sugerido: agrega un proceso de reconciliación periódica que compare product_stock con la agregación de stock_movements y genere alertas en caso de divergencia.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (1)

O
Ovasalces
11 abr 2026
Muy interesante lo que enseñas . La verdad que me asombra lo mucho que enseñas !!! Serías tan amable de invitar a algún grupo de WhatsApp?
Iniciar Sesión