-- ============================================================= -- ARCHIVO: 21-inventory.sql -- DESCRIPCION: Niveles de stock y movimientos de inventario -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-13 -- DEPENDE DE: 17-products.sql, 18-warehouses.sql -- ============================================================= -- ===================== -- SCHEMA: inventory (ya creado en 18-warehouses.sql) -- ===================== -- ===================== -- TABLA: stock_levels -- Niveles de inventario por producto/almacen/ubicacion -- ===================== CREATE TABLE IF NOT EXISTS inventory.stock_levels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE, warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE, location_id UUID REFERENCES inventory.warehouse_locations(id) ON DELETE SET NULL, -- Cantidades quantity_on_hand DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Cantidad fisica disponible quantity_reserved DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Reservada para ordenes quantity_available DECIMAL(15, 4) GENERATED ALWAYS AS (quantity_on_hand - quantity_reserved) STORED, quantity_incoming DECIMAL(15, 4) NOT NULL DEFAULT 0, -- En transito/por recibir quantity_outgoing DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Por enviar -- Lote y serie lot_number VARCHAR(50), serial_number VARCHAR(50), expiry_date DATE, -- Costo unit_cost DECIMAL(15, 4), total_cost DECIMAL(15, 4), -- Ultima actividad last_movement_at TIMESTAMPTZ, last_count_at TIMESTAMPTZ, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(product_id, warehouse_id, COALESCE(location_id, '00000000-0000-0000-0000-000000000000'::UUID), COALESCE(lot_number, ''), COALESCE(serial_number, '')) ); -- Indices para stock_levels CREATE INDEX IF NOT EXISTS idx_stock_levels_tenant ON inventory.stock_levels(tenant_id); CREATE INDEX IF NOT EXISTS idx_stock_levels_product ON inventory.stock_levels(product_id); CREATE INDEX IF NOT EXISTS idx_stock_levels_warehouse ON inventory.stock_levels(warehouse_id); CREATE INDEX IF NOT EXISTS idx_stock_levels_location ON inventory.stock_levels(location_id); CREATE INDEX IF NOT EXISTS idx_stock_levels_lot ON inventory.stock_levels(lot_number); CREATE INDEX IF NOT EXISTS idx_stock_levels_serial ON inventory.stock_levels(serial_number); CREATE INDEX IF NOT EXISTS idx_stock_levels_expiry ON inventory.stock_levels(expiry_date) WHERE expiry_date IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_stock_levels_low_stock ON inventory.stock_levels(quantity_on_hand) WHERE quantity_on_hand <= 0; CREATE INDEX IF NOT EXISTS idx_stock_levels_available ON inventory.stock_levels(quantity_available); -- ===================== -- TABLA: stock_movements -- Movimientos de inventario (entradas, salidas, transferencias, ajustes) -- ===================== CREATE TABLE IF NOT EXISTS inventory.stock_movements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Tipo de movimiento movement_type VARCHAR(20) NOT NULL, -- receipt, shipment, transfer, adjustment, return, production, consumption movement_number VARCHAR(30) NOT NULL, -- Numero secuencial -- Producto product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT, -- Origen y destino source_warehouse_id UUID REFERENCES inventory.warehouses(id), source_location_id UUID REFERENCES inventory.warehouse_locations(id), dest_warehouse_id UUID REFERENCES inventory.warehouses(id), dest_location_id UUID REFERENCES inventory.warehouse_locations(id), -- Cantidad quantity DECIMAL(15, 4) NOT NULL, uom VARCHAR(20) DEFAULT 'PZA', -- Lote y serie lot_number VARCHAR(50), serial_number VARCHAR(50), expiry_date DATE, -- Costo unit_cost DECIMAL(15, 4), total_cost DECIMAL(15, 4), -- Referencia reference_type VARCHAR(30), -- sales_order, purchase_order, transfer_order, adjustment, return reference_id UUID, reference_number VARCHAR(50), -- Razon (para ajustes) reason VARCHAR(100), notes TEXT, -- Estado status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, cancelled confirmed_at TIMESTAMPTZ, confirmed_by UUID REFERENCES auth.users(id), -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMPTZ ); -- Indices para stock_movements CREATE INDEX IF NOT EXISTS idx_stock_movements_tenant ON inventory.stock_movements(tenant_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_type ON inventory.stock_movements(movement_type); CREATE INDEX IF NOT EXISTS idx_stock_movements_number ON inventory.stock_movements(movement_number); CREATE INDEX IF NOT EXISTS idx_stock_movements_product ON inventory.stock_movements(product_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_source ON inventory.stock_movements(source_warehouse_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_dest ON inventory.stock_movements(dest_warehouse_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_status ON inventory.stock_movements(status); CREATE INDEX IF NOT EXISTS idx_stock_movements_reference ON inventory.stock_movements(reference_type, reference_id); CREATE INDEX IF NOT EXISTS idx_stock_movements_date ON inventory.stock_movements(created_at); CREATE INDEX IF NOT EXISTS idx_stock_movements_lot ON inventory.stock_movements(lot_number) WHERE lot_number IS NOT NULL; -- ===================== -- TABLA: inventory_counts -- Conteos fisicos de inventario -- ===================== CREATE TABLE IF NOT EXISTS inventory.inventory_counts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE, -- Identificacion count_number VARCHAR(30) NOT NULL, name VARCHAR(100), -- Tipo de conteo count_type VARCHAR(20) DEFAULT 'full', -- full, partial, cycle, spot -- Fecha programada scheduled_date DATE, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, -- Estado status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, in_progress, completed, cancelled -- Responsable assigned_to UUID REFERENCES auth.users(id), -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para inventory_counts CREATE INDEX IF NOT EXISTS idx_inventory_counts_tenant ON inventory.inventory_counts(tenant_id); CREATE INDEX IF NOT EXISTS idx_inventory_counts_warehouse ON inventory.inventory_counts(warehouse_id); CREATE INDEX IF NOT EXISTS idx_inventory_counts_status ON inventory.inventory_counts(status); CREATE INDEX IF NOT EXISTS idx_inventory_counts_date ON inventory.inventory_counts(scheduled_date); -- ===================== -- TABLA: inventory_count_lines -- Lineas de conteo de inventario -- ===================== CREATE TABLE IF NOT EXISTS inventory.inventory_count_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), count_id UUID NOT NULL REFERENCES inventory.inventory_counts(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT, location_id UUID REFERENCES inventory.warehouse_locations(id), -- Cantidades system_quantity DECIMAL(15, 4), -- Cantidad segun sistema counted_quantity DECIMAL(15, 4), -- Cantidad contada difference DECIMAL(15, 4) GENERATED ALWAYS AS (COALESCE(counted_quantity, 0) - COALESCE(system_quantity, 0)) STORED, -- Lote y serie lot_number VARCHAR(50), serial_number VARCHAR(50), -- Estado is_counted BOOLEAN DEFAULT FALSE, counted_at TIMESTAMPTZ, counted_by UUID REFERENCES auth.users(id), -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para inventory_count_lines CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_count ON inventory.inventory_count_lines(count_id); CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_product ON inventory.inventory_count_lines(product_id); CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_location ON inventory.inventory_count_lines(location_id); CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_counted ON inventory.inventory_count_lines(is_counted); -- ===================== -- TABLA: transfer_orders -- Ordenes de transferencia entre almacenes -- ===================== CREATE TABLE IF NOT EXISTS inventory.transfer_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Identificacion transfer_number VARCHAR(30) NOT NULL, -- Origen y destino source_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), dest_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id), -- Fechas scheduled_date DATE, shipped_at TIMESTAMPTZ, received_at TIMESTAMPTZ, -- Estado status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, shipped, in_transit, received, cancelled -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMPTZ, UNIQUE(tenant_id, transfer_number) ); -- Indices para transfer_orders CREATE INDEX IF NOT EXISTS idx_transfer_orders_tenant ON inventory.transfer_orders(tenant_id); CREATE INDEX IF NOT EXISTS idx_transfer_orders_source ON inventory.transfer_orders(source_warehouse_id); CREATE INDEX IF NOT EXISTS idx_transfer_orders_dest ON inventory.transfer_orders(dest_warehouse_id); CREATE INDEX IF NOT EXISTS idx_transfer_orders_status ON inventory.transfer_orders(status); CREATE INDEX IF NOT EXISTS idx_transfer_orders_date ON inventory.transfer_orders(scheduled_date); -- ===================== -- TABLA: transfer_order_lines -- Lineas de orden de transferencia -- ===================== CREATE TABLE IF NOT EXISTS inventory.transfer_order_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), transfer_id UUID NOT NULL REFERENCES inventory.transfer_orders(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT, -- Ubicaciones especificas source_location_id UUID REFERENCES inventory.warehouse_locations(id), dest_location_id UUID REFERENCES inventory.warehouse_locations(id), -- Cantidades quantity_requested DECIMAL(15, 4) NOT NULL, quantity_shipped DECIMAL(15, 4) DEFAULT 0, quantity_received DECIMAL(15, 4) DEFAULT 0, -- Lote y serie lot_number VARCHAR(50), serial_number VARCHAR(50), -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para transfer_order_lines CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_transfer ON inventory.transfer_order_lines(transfer_id); CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_product ON inventory.transfer_order_lines(product_id); -- ===================== -- COMENTARIOS -- ===================== COMMENT ON TABLE inventory.stock_levels IS 'Niveles actuales de inventario por producto/almacen/ubicacion'; COMMENT ON COLUMN inventory.stock_levels.quantity_on_hand IS 'Cantidad fisica disponible en el almacen'; COMMENT ON COLUMN inventory.stock_levels.quantity_reserved IS 'Cantidad reservada para ordenes pendientes'; COMMENT ON COLUMN inventory.stock_levels.quantity_available IS 'Cantidad disponible para venta (on_hand - reserved)'; COMMENT ON COLUMN inventory.stock_levels.quantity_incoming IS 'Cantidad en transito o por recibir'; COMMENT ON TABLE inventory.stock_movements IS 'Historial de movimientos de inventario'; COMMENT ON COLUMN inventory.stock_movements.movement_type IS 'Tipo: receipt (entrada), shipment (salida), transfer, adjustment, return, production, consumption'; COMMENT ON COLUMN inventory.stock_movements.status IS 'Estado: draft, confirmed, cancelled'; COMMENT ON TABLE inventory.inventory_counts IS 'Conteos fisicos de inventario para reconciliacion'; COMMENT ON COLUMN inventory.inventory_counts.count_type IS 'Tipo: full (completo), partial, cycle (ciclico), spot (aleatorio)'; COMMENT ON TABLE inventory.transfer_orders IS 'Ordenes de transferencia entre almacenes'; COMMENT ON COLUMN inventory.transfer_orders.status IS 'Estado: draft, confirmed, shipped, in_transit, received, cancelled'; -- ===================== -- TABLA: lots -- Lotes de productos para trazabilidad y control de caducidad -- Coherencia: lot.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.lots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE, -- Identificacion name VARCHAR(100) NOT NULL, ref VARCHAR(100), -- Fechas de control manufacture_date DATE, expiration_date DATE, removal_date DATE, alert_date DATE, -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), UNIQUE(product_id, name) ); -- Indices para lots CREATE INDEX IF NOT EXISTS idx_lots_tenant ON inventory.lots(tenant_id); CREATE INDEX IF NOT EXISTS idx_lots_product ON inventory.lots(product_id); CREATE INDEX IF NOT EXISTS idx_lots_name_product ON inventory.lots(product_id, name); CREATE INDEX IF NOT EXISTS idx_lots_expiration ON inventory.lots(expiration_date) WHERE expiration_date IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_lots_alert ON inventory.lots(alert_date) WHERE alert_date IS NOT NULL; -- ===================== -- TIPO ENUM: picking_type -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'picking_type_enum') THEN CREATE TYPE inventory.picking_type_enum AS ENUM ('incoming', 'outgoing', 'internal'); END IF; END$$; -- ===================== -- TIPO ENUM: move_status -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'move_status_enum') THEN CREATE TYPE inventory.move_status_enum AS ENUM ('draft', 'waiting', 'confirmed', 'assigned', 'done', 'cancelled'); END IF; END$$; -- ===================== -- TABLA: pickings -- Operaciones de recepcion, envio y movimientos internos -- Coherencia: picking.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.pickings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, -- Identificacion name VARCHAR(100) NOT NULL, -- Tipo de operacion picking_type inventory.picking_type_enum NOT NULL, -- Ubicaciones location_id UUID NOT NULL REFERENCES inventory.warehouse_locations(id), location_dest_id UUID NOT NULL REFERENCES inventory.warehouse_locations(id), -- Partner (proveedor/cliente) partner_id UUID REFERENCES partners.partners(id), -- Fechas scheduled_date TIMESTAMPTZ, date_done TIMESTAMPTZ, -- Origen (referencia documento) origin VARCHAR(255), -- Estado status inventory.move_status_enum NOT NULL DEFAULT 'draft', -- Notas notes TEXT, -- Validacion validated_at TIMESTAMPTZ, validated_by UUID REFERENCES auth.users(id), -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by UUID REFERENCES auth.users(id) ); -- Indices para pickings CREATE INDEX IF NOT EXISTS idx_pickings_tenant ON inventory.pickings(tenant_id); CREATE INDEX IF NOT EXISTS idx_pickings_company ON inventory.pickings(company_id); CREATE INDEX IF NOT EXISTS idx_pickings_status ON inventory.pickings(status); CREATE INDEX IF NOT EXISTS idx_pickings_partner ON inventory.pickings(partner_id) WHERE partner_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_pickings_scheduled ON inventory.pickings(scheduled_date); CREATE INDEX IF NOT EXISTS idx_pickings_type ON inventory.pickings(picking_type); -- ===================== -- COMENTARIOS ADICIONALES -- ===================== COMMENT ON TABLE inventory.lots IS 'Lotes de productos para trazabilidad, control de caducidad y FIFO/FEFO'; COMMENT ON COLUMN inventory.lots.name IS 'Numero o nombre del lote (debe ser unico por producto)'; COMMENT ON COLUMN inventory.lots.expiration_date IS 'Fecha de caducidad del lote'; COMMENT ON COLUMN inventory.lots.alert_date IS 'Fecha para alerta previa a caducidad'; COMMENT ON TABLE inventory.pickings IS 'Operaciones de recepcion (incoming), envio (outgoing) y movimientos internos'; COMMENT ON COLUMN inventory.pickings.picking_type IS 'Tipo: incoming (recepcion), outgoing (envio), internal (transferencia interna)'; COMMENT ON COLUMN inventory.pickings.status IS 'Estado: draft, waiting, confirmed, assigned, done, cancelled'; COMMENT ON COLUMN inventory.pickings.origin IS 'Documento origen (ej: PO-2026-001, SO-2026-001)';