-- ============================================================= -- ARCHIVO: 21b-inventory-extended.sql -- DESCRIPCION: Tablas extendidas de inventario (Odoo-style) -- Incluye: locations, products (inventory-specific), stock_quants, -- stock_moves, stock_valuation_layers, inventory_adjustments, -- inventory_adjustment_lines -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-02-05 -- DEPENDE DE: 18-warehouses.sql, 21-inventory.sql -- COHERENCIA: Entidades TypeORM sin DDL previo -- ============================================================= -- ===================== -- SCHEMA: inventory (ya creado en 18-warehouses.sql) -- ===================== -- ===================== -- TIPO ENUM: location_type -- Tipos de ubicacion Odoo-style -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'location_type_enum') THEN CREATE TYPE inventory.location_type_enum AS ENUM ( 'internal', 'supplier', 'customer', 'inventory', 'production', 'transit' ); END IF; END$$; -- ===================== -- TIPO ENUM: product_type -- Tipos de producto para inventario -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'inv_product_type_enum') THEN CREATE TYPE inventory.inv_product_type_enum AS ENUM ( 'storable', 'consumable', 'service' ); END IF; END$$; -- ===================== -- TIPO ENUM: tracking_type -- Tipo de seguimiento de producto -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'tracking_type_enum') THEN CREATE TYPE inventory.tracking_type_enum AS ENUM ( 'none', 'lot', 'serial' ); END IF; END$$; -- ===================== -- TIPO ENUM: valuation_method -- Metodo de valuacion de inventario -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'valuation_method_enum') THEN CREATE TYPE inventory.valuation_method_enum AS ENUM ( 'standard', 'fifo', 'average' ); END IF; END$$; -- ===================== -- TIPO ENUM: adjustment_status -- Estado de ajuste de inventario -- ===================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'adjustment_status_enum') THEN CREATE TYPE inventory.adjustment_status_enum AS ENUM ( 'draft', 'confirmed', 'done', 'cancelled' ); END IF; END$$; -- ===================== -- TABLA: locations -- Ubicaciones logicas de inventario (Odoo-style) -- Complementa warehouse_locations con modelo Odoo de ubicaciones virtuales -- (supplier, customer, inventory loss, production, transit) -- Coherencia: location.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.locations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, warehouse_id UUID REFERENCES inventory.warehouses(id) ON DELETE SET NULL, -- Identificacion name VARCHAR(255) NOT NULL, complete_name VARCHAR(500), -- Tipo de ubicacion location_type inventory.location_type_enum NOT NULL, -- Jerarquia (auto-referencia) parent_id UUID REFERENCES inventory.locations(id) ON DELETE SET NULL, -- Flags especiales is_scrap_location BOOLEAN NOT NULL DEFAULT FALSE, is_return_location BOOLEAN NOT NULL DEFAULT FALSE, -- Estado active BOOLEAN NOT NULL DEFAULT TRUE, -- 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 locations CREATE INDEX IF NOT EXISTS idx_locations_tenant_id ON inventory.locations(tenant_id); CREATE INDEX IF NOT EXISTS idx_locations_warehouse_id ON inventory.locations(warehouse_id) WHERE warehouse_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_locations_parent_id ON inventory.locations(parent_id) WHERE parent_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_locations_type ON inventory.locations(location_type); CREATE INDEX IF NOT EXISTS idx_locations_active ON inventory.locations(active) WHERE active = TRUE; CREATE INDEX IF NOT EXISTS idx_locations_scrap ON inventory.locations(is_scrap_location) WHERE is_scrap_location = TRUE; CREATE INDEX IF NOT EXISTS idx_locations_return ON inventory.locations(is_return_location) WHERE is_return_location = TRUE; -- ===================== -- TABLA: products -- Productos especificos de inventario (Odoo-style) -- Diferente de products.products: este es para gestion de almacen, -- valuacion, tracking por lote/serie, mientras products.products -- es para comercio/retail con codigos SAT, impuestos, etc. -- Coherencia: product.entity.ts (inventory module) -- ===================== CREATE TABLE IF NOT EXISTS inventory.products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Identificacion name VARCHAR(255) NOT NULL, code VARCHAR(100), barcode VARCHAR(100), description TEXT, -- Clasificacion product_type inventory.inv_product_type_enum NOT NULL DEFAULT 'storable', tracking inventory.tracking_type_enum NOT NULL DEFAULT 'none', category_id UUID, -- Unidades de medida uom_id UUID NOT NULL, purchase_uom_id UUID, -- Precios y costo cost_price DECIMAL(12, 2) NOT NULL DEFAULT 0, list_price DECIMAL(12, 2) NOT NULL DEFAULT 0, -- Valuacion valuation_method inventory.valuation_method_enum NOT NULL DEFAULT 'fifo', -- Flags is_storable BOOLEAN NOT NULL DEFAULT TRUE, can_be_sold BOOLEAN NOT NULL DEFAULT TRUE, can_be_purchased BOOLEAN NOT NULL DEFAULT TRUE, active BOOLEAN NOT NULL DEFAULT TRUE, -- Medidas fisicas weight DECIMAL(12, 4), volume DECIMAL(12, 4), -- Imagen image_url VARCHAR(500), -- 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), deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES auth.users(id) ); -- Indices para products CREATE INDEX IF NOT EXISTS idx_inv_products_tenant_id ON inventory.products(tenant_id); CREATE INDEX IF NOT EXISTS idx_inv_products_code ON inventory.products(code) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_inv_products_barcode ON inventory.products(barcode) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_inv_products_category_id ON inventory.products(category_id) WHERE category_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_inv_products_active ON inventory.products(active) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_inv_products_type ON inventory.products(product_type); CREATE INDEX IF NOT EXISTS idx_inv_products_tracking ON inventory.products(tracking) WHERE tracking != 'none'; CREATE INDEX IF NOT EXISTS idx_inv_products_valuation ON inventory.products(valuation_method); -- Unique code per tenant (soft-delete aware) CREATE UNIQUE INDEX IF NOT EXISTS uq_inv_products_tenant_code ON inventory.products(tenant_id, code) WHERE deleted_at IS NULL AND code IS NOT NULL; -- ===================== -- TABLA: stock_quants -- Cantidades de stock por producto/ubicacion/lote (modelo Odoo) -- Representa la cantidad real de un producto en una ubicacion especifica, -- opcionalmente separada por lote -- Coherencia: stock-quant.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.stock_quants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Producto y ubicacion product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE CASCADE, location_id UUID NOT NULL REFERENCES inventory.locations(id) ON DELETE CASCADE, -- Lote (opcional) lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, -- Cantidades quantity DECIMAL(16, 4) NOT NULL DEFAULT 0, reserved_quantity DECIMAL(16, 4) NOT NULL DEFAULT 0, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, -- Unicidad: un solo registro por combinacion producto+ubicacion+lote UNIQUE(product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID)) ); -- Indices para stock_quants CREATE INDEX IF NOT EXISTS idx_stock_quants_tenant_id ON inventory.stock_quants(tenant_id); CREATE INDEX IF NOT EXISTS idx_stock_quants_product_id ON inventory.stock_quants(product_id); CREATE INDEX IF NOT EXISTS idx_stock_quants_location_id ON inventory.stock_quants(location_id); CREATE INDEX IF NOT EXISTS idx_stock_quants_lot_id ON inventory.stock_quants(lot_id) WHERE lot_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_stock_quants_quantity ON inventory.stock_quants(quantity) WHERE quantity != 0; CREATE INDEX IF NOT EXISTS idx_stock_quants_reserved ON inventory.stock_quants(reserved_quantity) WHERE reserved_quantity != 0; -- ===================== -- TABLA: stock_moves -- Movimientos individuales de stock vinculados a un picking (Odoo-style) -- Cada linea de un picking genera un stock_move -- Coherencia: stock-move.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.stock_moves ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Picking padre picking_id UUID NOT NULL REFERENCES inventory.pickings(id) ON DELETE CASCADE, -- Producto product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, product_uom_id UUID NOT NULL, -- Ubicaciones origen y destino location_id UUID NOT NULL REFERENCES inventory.locations(id), location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), -- Cantidades product_qty DECIMAL(16, 4) NOT NULL, quantity_done DECIMAL(16, 4) NOT NULL DEFAULT 0, -- Lote (opcional) lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, -- Estado (reutiliza move_status_enum de 21-inventory.sql) status inventory.move_status_enum NOT NULL DEFAULT 'draft', -- Fecha del movimiento date TIMESTAMPTZ, -- Documento origen origin VARCHAR(255), -- 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 stock_moves CREATE INDEX IF NOT EXISTS idx_stock_moves_tenant_id ON inventory.stock_moves(tenant_id); CREATE INDEX IF NOT EXISTS idx_stock_moves_picking_id ON inventory.stock_moves(picking_id); CREATE INDEX IF NOT EXISTS idx_stock_moves_product_id ON inventory.stock_moves(product_id); CREATE INDEX IF NOT EXISTS idx_stock_moves_status ON inventory.stock_moves(status); CREATE INDEX IF NOT EXISTS idx_stock_moves_date ON inventory.stock_moves(date) WHERE date IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_stock_moves_location ON inventory.stock_moves(location_id); CREATE INDEX IF NOT EXISTS idx_stock_moves_location_dest ON inventory.stock_moves(location_dest_id); CREATE INDEX IF NOT EXISTS idx_stock_moves_lot_id ON inventory.stock_moves(lot_id) WHERE lot_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_stock_moves_origin ON inventory.stock_moves(origin) WHERE origin IS NOT NULL; -- ===================== -- TABLA: stock_valuation_layers -- Capas de valuacion de inventario (FIFO/AVCO/Standard) -- Cada movimiento de stock genera una capa de valuacion para -- rastrear el costo historico del inventario -- Coherencia: stock-valuation-layer.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.stock_valuation_layers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Producto y empresa product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, -- Cantidades y valores quantity DECIMAL(16, 4) NOT NULL, unit_cost DECIMAL(12, 2) NOT NULL, value DECIMAL(16, 2) NOT NULL, -- Remanente (para FIFO) remaining_qty DECIMAL(16, 4) NOT NULL, remaining_value DECIMAL(16, 2) NOT NULL, -- Movimiento de stock origen stock_move_id UUID REFERENCES inventory.stock_moves(id) ON DELETE SET NULL, -- Descripcion description VARCHAR(255), -- Contabilidad (referencia a asientos contables) account_move_id UUID, journal_entry_id UUID, -- 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 stock_valuation_layers CREATE INDEX IF NOT EXISTS idx_valuation_layers_tenant_id ON inventory.stock_valuation_layers(tenant_id); CREATE INDEX IF NOT EXISTS idx_valuation_layers_product_id ON inventory.stock_valuation_layers(product_id); CREATE INDEX IF NOT EXISTS idx_valuation_layers_company_id ON inventory.stock_valuation_layers(company_id); CREATE INDEX IF NOT EXISTS idx_valuation_layers_stock_move_id ON inventory.stock_valuation_layers(stock_move_id) WHERE stock_move_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_valuation_layers_remaining_qty ON inventory.stock_valuation_layers(remaining_qty) WHERE remaining_qty > 0; CREATE INDEX IF NOT EXISTS idx_valuation_layers_account_move ON inventory.stock_valuation_layers(account_move_id) WHERE account_move_id IS NOT NULL; -- ===================== -- TABLA: inventory_adjustments -- Ajustes de inventario (conteos y correcciones) -- Agrupa lineas de ajuste para una ubicacion y fecha especifica -- Coherencia: inventory-adjustment.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.inventory_adjustments ( 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, -- Ubicacion de ajuste location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Fecha del ajuste date DATE NOT NULL, -- Estado status inventory.adjustment_status_enum NOT NULL DEFAULT 'draft', -- Notas notes TEXT, -- 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 inventory_adjustments CREATE INDEX IF NOT EXISTS idx_adjustments_tenant_id ON inventory.inventory_adjustments(tenant_id); CREATE INDEX IF NOT EXISTS idx_adjustments_company_id ON inventory.inventory_adjustments(company_id); CREATE INDEX IF NOT EXISTS idx_adjustments_status ON inventory.inventory_adjustments(status); CREATE INDEX IF NOT EXISTS idx_adjustments_date ON inventory.inventory_adjustments(date); CREATE INDEX IF NOT EXISTS idx_adjustments_location ON inventory.inventory_adjustments(location_id); -- ===================== -- TABLA: inventory_adjustment_lines -- Lineas individuales de ajuste de inventario -- Cada linea compara cantidad teorica vs contada para un producto -- Coherencia: inventory-adjustment-line.entity.ts -- ===================== CREATE TABLE IF NOT EXISTS inventory.inventory_adjustment_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), adjustment_id UUID NOT NULL REFERENCES inventory.inventory_adjustments(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Producto y ubicacion product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Lote (opcional) lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, -- Cantidades theoretical_qty DECIMAL(16, 4) NOT NULL DEFAULT 0, counted_qty DECIMAL(16, 4) NOT NULL DEFAULT 0, difference_qty DECIMAL(16, 4) GENERATED ALWAYS AS (counted_qty - theoretical_qty) STORED, -- Unidad de medida uom_id UUID, -- Notas notes TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para inventory_adjustment_lines CREATE INDEX IF NOT EXISTS idx_adjustment_lines_adjustment_id ON inventory.inventory_adjustment_lines(adjustment_id); CREATE INDEX IF NOT EXISTS idx_adjustment_lines_product_id ON inventory.inventory_adjustment_lines(product_id); CREATE INDEX IF NOT EXISTS idx_adjustment_lines_location_id ON inventory.inventory_adjustment_lines(location_id); CREATE INDEX IF NOT EXISTS idx_adjustment_lines_lot_id ON inventory.inventory_adjustment_lines(lot_id) WHERE lot_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_adjustment_lines_tenant_id ON inventory.inventory_adjustment_lines(tenant_id); -- ===================== -- COMENTARIOS -- ===================== -- locations COMMENT ON TABLE inventory.locations IS 'Ubicaciones logicas de inventario (Odoo-style). Incluye ubicaciones virtuales: supplier, customer, inventory loss, production, transit'; COMMENT ON COLUMN inventory.locations.location_type IS 'Tipo: internal (almacen), supplier (proveedor virtual), customer (cliente virtual), inventory (perdida/ajuste), production (produccion), transit (en transito)'; COMMENT ON COLUMN inventory.locations.complete_name IS 'Nombre completo con jerarquia (ej: WH/Stock/Zone-A)'; COMMENT ON COLUMN inventory.locations.is_scrap_location IS 'Si es TRUE, productos movidos aqui se consideran scrap/desperdicio'; COMMENT ON COLUMN inventory.locations.is_return_location IS 'Si es TRUE, esta ubicacion se usa para devoluciones'; -- products (inventory) COMMENT ON TABLE inventory.products IS 'Productos para gestion de inventario (Odoo-style). Diferente de products.products que es para comercio/retail'; COMMENT ON COLUMN inventory.products.product_type IS 'Tipo: storable (se almacena), consumable (se consume sin tracking), service (no se almacena)'; COMMENT ON COLUMN inventory.products.tracking IS 'Seguimiento: none, lot (por lote), serial (numero de serie unico)'; COMMENT ON COLUMN inventory.products.valuation_method IS 'Metodo de valuacion: standard (costo fijo), fifo (primero en entrar), average (costo promedio)'; COMMENT ON COLUMN inventory.products.is_storable IS 'Flag derivado de product_type=storable. Facilita filtros directos'; -- stock_quants COMMENT ON TABLE inventory.stock_quants IS 'Cantidades reales de stock por producto/ubicacion/lote (modelo Odoo quant)'; COMMENT ON COLUMN inventory.stock_quants.quantity IS 'Cantidad total disponible en esta ubicacion'; COMMENT ON COLUMN inventory.stock_quants.reserved_quantity IS 'Cantidad reservada para pickings pendientes'; -- stock_moves COMMENT ON TABLE inventory.stock_moves IS 'Movimientos individuales de stock vinculados a un picking. Cada linea de picking genera un move'; COMMENT ON COLUMN inventory.stock_moves.product_qty IS 'Cantidad solicitada del movimiento'; COMMENT ON COLUMN inventory.stock_moves.quantity_done IS 'Cantidad realmente procesada'; COMMENT ON COLUMN inventory.stock_moves.status IS 'Estado: draft, waiting, confirmed, assigned, done, cancelled'; COMMENT ON COLUMN inventory.stock_moves.origin IS 'Documento origen (ej: PO-2026-001, SO-2026-001)'; -- stock_valuation_layers COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valuacion de inventario para FIFO/AVCO/Standard. Cada movimiento crea una capa'; COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad restante en esta capa (para FIFO: se consume de las capas mas antiguas)'; COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_value IS 'Valor monetario restante en esta capa'; COMMENT ON COLUMN inventory.stock_valuation_layers.account_move_id IS 'Referencia al asiento contable generado (si aplica)'; -- inventory_adjustments COMMENT ON TABLE inventory.inventory_adjustments IS 'Ajustes de inventario para corregir diferencias entre stock teorico y real'; COMMENT ON COLUMN inventory.inventory_adjustments.status IS 'Estado: draft (borrador), confirmed (confirmado), done (aplicado), cancelled'; -- inventory_adjustment_lines COMMENT ON TABLE inventory.inventory_adjustment_lines IS 'Lineas de ajuste: cada una compara cantidad teorica vs contada para un producto en una ubicacion'; COMMENT ON COLUMN inventory.inventory_adjustment_lines.theoretical_qty IS 'Cantidad segun el sistema antes del conteo'; COMMENT ON COLUMN inventory.inventory_adjustment_lines.counted_qty IS 'Cantidad real contada fisicamente'; COMMENT ON COLUMN inventory.inventory_adjustment_lines.difference_qty IS 'Diferencia calculada: counted_qty - theoretical_qty (columna generada)';