# DDL-SPEC: Schemas purchasing_management & inventory_management ## Identificacion | Campo | Valor | |-------|-------| | **Schemas** | purchasing_management, inventory_management | | **Modulo** | MAI-004 - Compras e Inventarios | | **Vertical** | Construccion | | **Version** | 1.0 | | **Estado** | En Diseno | | **Autor** | Database-Agent | | **Fecha** | 2025-12-06 | --- ## Descripcion General Los schemas `purchasing_management` e `inventory_management` gestionan el ciclo completo de compras e inventarios para proyectos de construccion: desde requisiciones de materiales, cotizaciones, ordenes de compra, hasta el control de almacenes, movimientos de inventario y valoracion PEPS (Primeras Entradas, Primeras Salidas). ### Alcance - Gestion de requisiciones de materiales con flujo de aprobacion multi-nivel - Solicitudes de cotizacion (RFQ) y recepcion de cotizaciones - Generacion y seguimiento de ordenes de compra - Control de almacenes (generales, por proyecto, temporales) - Movimientos de inventario (entradas, salidas, traspasos, ajustes) - Valoracion de inventario mediante metodo PEPS - Niveles de stock en tiempo real con reservas - Inventarios fisicos y ajustes ### RF Cubiertos | RF | Titulo | Tablas | |----|--------|--------| | RF-COMP-001 | Requisiciones de Materiales | requisitions | | RF-COMP-002 | Cotizaciones y RFQs | quotations, rfqs | | RF-COMP-003 | Ordenes de Compra | purchase_orders, po_receipts | | RF-INV-001 | Gestion de Almacenes | warehouses, warehouse_locations | | RF-INV-002 | Movimientos de Inventario | inventory_movements | | RF-INV-003 | Niveles de Stock | stock_levels, inventory_lots | --- ## Diagrama Entidad-Relacion ```mermaid erDiagram %% Purchasing Management requisitions ||--o{ purchase_orders : "genera" requisitions ||--o{ rfqs : "origina" rfqs ||--o{ quotations : "recibe" quotations ||--o| purchase_orders : "convierte_en" purchase_orders ||--o{ po_receipts : "recibe_material" %% Inventory Management warehouses ||--o{ warehouse_locations : "tiene_ubicaciones" warehouses ||--o{ inventory_movements : "registra_movimientos" warehouses ||--o{ stock_levels : "mantiene_stock" warehouses ||--o{ inventory_lots : "almacena_lotes" inventory_movements ||--o{ inventory_lots : "consume_lotes" po_receipts }o--|| warehouses : "destino" stock_levels ||--o{ inventory_lots : "compuesto_por" requisitions { uuid id PK uuid tenant_id FK varchar code UK uuid project_id FK uuid requested_by FK date required_date requisition_status status jsonb items decimal estimated_total jsonb approval_flow text rejected_reason timestamptz created_at timestamptz updated_at uuid created_by FK uuid updated_by FK boolean is_active timestamptz deleted_at uuid deleted_by FK } rfqs { uuid id PK uuid tenant_id FK varchar code UK uuid requisition_id FK uuid project_id FK date issue_date date closing_date rfq_status status jsonb items jsonb invited_suppliers text terms_conditions timestamptz created_at timestamptz updated_at uuid created_by FK uuid updated_by FK boolean is_active } quotations { uuid id PK uuid tenant_id FK varchar code UK uuid rfq_id FK uuid supplier_id FK date quote_date date valid_until quotation_status status jsonb items decimal subtotal decimal tax decimal total int delivery_days varchar payment_terms decimal payment_discount text notes timestamptz created_at timestamptz updated_at uuid created_by FK uuid updated_by FK boolean is_active } purchase_orders { uuid id PK uuid tenant_id FK varchar code UK uuid supplier_id FK uuid project_id FK uuid requisition_id FK uuid rfq_id FK uuid quotation_id FK date order_date date delivery_date text delivery_address jsonb items decimal subtotal decimal tax decimal total po_status status varchar payment_terms int payment_terms_days decimal early_payment_discount boolean requires_advance decimal advance_percentage int warranty_days text special_conditions uuid approved_by FK timestamptz approved_at timestamptz sent_at timestamptz created_at timestamptz updated_at uuid created_by FK uuid updated_by FK boolean is_active } po_receipts { uuid id PK uuid tenant_id FK varchar code UK uuid purchase_order_id FK uuid warehouse_id FK date receipt_date uuid received_by FK jsonb items varchar delivery_note varchar transport_company text notes text[] attachments timestamptz created_at uuid created_by FK } warehouses { uuid id PK uuid tenant_id FK varchar code UK varchar name warehouse_type type uuid project_id FK text address varchar city varchar state uuid managed_by FK decimal total_area decimal covered_area jsonb settings timestamptz created_at timestamptz updated_at uuid created_by FK uuid updated_by FK boolean is_active timestamptz deleted_at uuid deleted_by FK } warehouse_locations { uuid id PK uuid tenant_id FK uuid warehouse_id FK varchar zone varchar position varchar code UK varchar description decimal capacity_m3 timestamptz created_at boolean is_active } inventory_movements { uuid id PK uuid tenant_id FK varchar code UK uuid warehouse_id FK movement_type type date movement_date movement_source source_type uuid source_id uuid project_id FK uuid budget_item_id FK uuid transfer_warehouse_id FK jsonb items decimal total_value text notes uuid authorized_by FK uuid recorded_by FK transfer_status transfer_status timestamptz received_at timestamptz created_at uuid created_by FK } stock_levels { uuid id PK uuid tenant_id FK uuid warehouse_id FK uuid material_id FK uuid location_id FK decimal quantity decimal reserved_quantity decimal available_quantity decimal average_cost decimal total_value date last_movement_date date last_entry_date date last_exit_date timestamptz updated_at } inventory_lots { uuid id PK uuid tenant_id FK uuid warehouse_id FK uuid material_id FK varchar lot_number date entry_date decimal quantity decimal remaining_quantity decimal unit_cost movement_source source_type uuid source_id boolean is_depleted timestamptz created_at } ``` --- ## Schema: purchasing_management ### Extensiones Necesarias ```sql -- Extensiones ya creadas en core -- uuid-ossp, pg_trgm, btree_gist, pgcrypto ``` ### 1. ENUMs ```sql -- ============================================================================ -- ENUMs - Purchasing Management -- ============================================================================ -- Estado de requisiciones CREATE TYPE purchasing_management.requisition_status AS ENUM ( 'draft', -- Borrador 'pending', -- Pendiente de aprobacion 'approved', -- Aprobada 'rejected', -- Rechazada 'quoted', -- En proceso de cotizacion 'ordered', -- Convertida a OC 'cancelled' -- Cancelada ); COMMENT ON TYPE purchasing_management.requisition_status IS 'Estados del ciclo de vida de una requisicion'; -- Estado de RFQs CREATE TYPE purchasing_management.rfq_status AS ENUM ( 'draft', -- Borrador 'sent', -- Enviada a proveedores 'receiving', -- Recibiendo cotizaciones 'evaluating', -- En evaluacion 'awarded', -- Adjudicada 'closed', -- Cerrada 'cancelled' -- Cancelada ); COMMENT ON TYPE purchasing_management.rfq_status IS 'Estados de solicitud de cotizacion'; -- Estado de cotizaciones CREATE TYPE purchasing_management.quotation_status AS ENUM ( 'received', -- Recibida 'under_review', -- En revision 'accepted', -- Aceptada 'rejected', -- Rechazada 'expired' -- Vencida ); COMMENT ON TYPE purchasing_management.quotation_status IS 'Estados de cotizacion de proveedor'; -- Estado de ordenes de compra CREATE TYPE purchasing_management.po_status AS ENUM ( 'draft', -- Borrador 'pending', -- Pendiente de aprobacion 'approved', -- Aprobada 'sent', -- Enviada a proveedor 'partially_received', -- Parcialmente recibida 'received', -- Recibida completamente 'cancelled', -- Cancelada 'closed' -- Cerrada ); COMMENT ON TYPE purchasing_management.po_status IS 'Estados del ciclo de vida de orden de compra'; ``` ### 2. Tabla: requisitions ```sql -- ============================================================================ -- Schema: purchasing_management -- Tabla: requisitions -- Descripcion: Requisiciones de materiales para proyectos -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS purchasing_management.requisitions ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Relaciones project_id UUID NOT NULL, -- FK a projects schema requested_by UUID NOT NULL REFERENCES core_users.users(id), -- Datos principales required_date DATE NOT NULL, urgency VARCHAR(20) NOT NULL DEFAULT 'normal', -- Items (JSONB estructura flexible) items JSONB NOT NULL, /* Estructura: [{ materialId: UUID, materialCode: string, description: string, quantity: number, unit: string, budgetedPrice: number, budgetItemId: UUID, notes: string }] */ justification TEXT, estimated_total DECIMAL(15,2) NOT NULL, -- Estado y flujo de aprobacion status purchasing_management.requisition_status NOT NULL DEFAULT 'draft', approval_flow JSONB, /* Estructura: [{ level: number, approverRole: string, approverId: UUID, approverName: string, status: 'pending'|'approved'|'rejected', comments: string, approvedAt: timestamp }] */ rejected_reason TEXT, -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), updated_by UUID REFERENCES core_users.users(id), -- Soft delete is_active BOOLEAN NOT NULL DEFAULT true, deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_requisitions_urgency CHECK (urgency IN ('normal', 'urgent', 'emergency')), CONSTRAINT chk_requisitions_estimated_total CHECK (estimated_total >= 0), CONSTRAINT uq_requisitions_code_tenant UNIQUE (tenant_id, code) ); -- Comentarios COMMENT ON TABLE purchasing_management.requisitions IS 'Requisiciones de materiales para proyectos de construccion'; COMMENT ON COLUMN purchasing_management.requisitions.tenant_id IS 'ID del tenant (constructora)'; COMMENT ON COLUMN purchasing_management.requisitions.code IS 'Codigo unico de requisicion (ej: REQ-2025-00001)'; COMMENT ON COLUMN purchasing_management.requisitions.items IS 'Items solicitados en formato JSONB'; COMMENT ON COLUMN purchasing_management.requisitions.approval_flow IS 'Flujo de aprobacion multi-nivel segun monto'; -- Indices CREATE INDEX idx_requisitions_tenant_id ON purchasing_management.requisitions(tenant_id); CREATE INDEX idx_requisitions_project_id ON purchasing_management.requisitions(project_id); CREATE INDEX idx_requisitions_status ON purchasing_management.requisitions(status) WHERE is_active = true; CREATE INDEX idx_requisitions_requested_by ON purchasing_management.requisitions(requested_by); CREATE INDEX idx_requisitions_created_at ON purchasing_management.requisitions(created_at DESC); CREATE INDEX idx_requisitions_required_date ON purchasing_management.requisitions(required_date); -- Indice GIN para busqueda en JSONB CREATE INDEX idx_requisitions_items_gin ON purchasing_management.requisitions USING gin (items jsonb_path_ops); -- RLS Policies ALTER TABLE purchasing_management.requisitions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON purchasing_management.requisitions FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON purchasing_management.requisitions FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON purchasing_management.requisitions FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON purchasing_management.requisitions FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 3. Tabla: rfqs (Request for Quotation) ```sql -- ============================================================================ -- Schema: purchasing_management -- Tabla: rfqs -- Descripcion: Solicitudes de cotizacion a proveedores -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS purchasing_management.rfqs ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Relaciones requisition_id UUID REFERENCES purchasing_management.requisitions(id), project_id UUID NOT NULL, -- Datos principales issue_date DATE NOT NULL DEFAULT CURRENT_DATE, closing_date DATE NOT NULL, -- Items a cotizar items JSONB NOT NULL, /* Estructura: [{ materialId: UUID, materialCode: string, description: string, quantity: number, unit: string, technicalSpecs: string }] */ -- Proveedores invitados invited_suppliers JSONB NOT NULL, /* Estructura: [{ supplierId: UUID, supplierName: string, contactEmail: string, invitedAt: timestamp, notifiedAt: timestamp }] */ -- Estado status purchasing_management.rfq_status NOT NULL DEFAULT 'draft', -- Terminos y condiciones terms_conditions TEXT, delivery_address TEXT, payment_terms VARCHAR(100), -- Configuracion metadata JSONB DEFAULT '{}', -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), updated_by UUID REFERENCES core_users.users(id), -- Soft delete is_active BOOLEAN NOT NULL DEFAULT true, deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_rfqs_closing_date CHECK (closing_date >= issue_date), CONSTRAINT uq_rfqs_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE purchasing_management.rfqs IS 'Solicitudes de cotizacion (RFQ) a proveedores'; COMMENT ON COLUMN purchasing_management.rfqs.code IS 'Codigo unico RFQ (ej: RFQ-2025-00001)'; -- Indices CREATE INDEX idx_rfqs_tenant_id ON purchasing_management.rfqs(tenant_id); CREATE INDEX idx_rfqs_requisition_id ON purchasing_management.rfqs(requisition_id); CREATE INDEX idx_rfqs_project_id ON purchasing_management.rfqs(project_id); CREATE INDEX idx_rfqs_status ON purchasing_management.rfqs(status) WHERE is_active = true; CREATE INDEX idx_rfqs_closing_date ON purchasing_management.rfqs(closing_date); -- RLS Policies ALTER TABLE purchasing_management.rfqs ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON purchasing_management.rfqs FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON purchasing_management.rfqs FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON purchasing_management.rfqs FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON purchasing_management.rfqs FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 4. Tabla: quotations ```sql -- ============================================================================ -- Schema: purchasing_management -- Tabla: quotations -- Descripcion: Cotizaciones recibidas de proveedores -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS purchasing_management.quotations ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Relaciones rfq_id UUID NOT NULL REFERENCES purchasing_management.rfqs(id), supplier_id UUID NOT NULL, -- FK a core_catalogs.contacts -- Datos de cotizacion quote_date DATE NOT NULL DEFAULT CURRENT_DATE, valid_until DATE NOT NULL, quote_number VARCHAR(50), -- Numero de cotizacion del proveedor -- Items cotizados items JSONB NOT NULL, /* Estructura: [{ rfqItemId: string, materialId: UUID, description: string, quantity: number, unit: string, unitPrice: number, subtotal: number, deliveryDays: number, brand: string, notes: string }] */ -- Totales subtotal DECIMAL(15,2) NOT NULL, tax DECIMAL(15,2) NOT NULL DEFAULT 0, total DECIMAL(15,2) NOT NULL, -- Condiciones delivery_days INT NOT NULL, payment_terms VARCHAR(100), payment_discount DECIMAL(5,2) DEFAULT 0, warranty_days INT DEFAULT 30, includes_delivery BOOLEAN DEFAULT true, -- Estado status purchasing_management.quotation_status NOT NULL DEFAULT 'received', -- Evaluacion evaluation_score DECIMAL(5,2), -- Score de evaluacion (0-100) evaluation_notes TEXT, evaluated_by UUID REFERENCES core_users.users(id), evaluated_at TIMESTAMPTZ, -- Adjuntos attachments TEXT[], notes TEXT, -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), updated_by UUID REFERENCES core_users.users(id), -- Soft delete is_active BOOLEAN NOT NULL DEFAULT true, deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_quotations_totals CHECK (total = subtotal + tax), CONSTRAINT chk_quotations_valid_until CHECK (valid_until >= quote_date), CONSTRAINT chk_quotations_evaluation_score CHECK (evaluation_score IS NULL OR (evaluation_score >= 0 AND evaluation_score <= 100)), CONSTRAINT uq_quotations_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE purchasing_management.quotations IS 'Cotizaciones recibidas de proveedores'; COMMENT ON COLUMN purchasing_management.quotations.evaluation_score IS 'Score de evaluacion 0-100 basado en precio, tiempo, calidad'; -- Indices CREATE INDEX idx_quotations_tenant_id ON purchasing_management.quotations(tenant_id); CREATE INDEX idx_quotations_rfq_id ON purchasing_management.quotations(rfq_id); CREATE INDEX idx_quotations_supplier_id ON purchasing_management.quotations(supplier_id); CREATE INDEX idx_quotations_status ON purchasing_management.quotations(status) WHERE is_active = true; CREATE INDEX idx_quotations_valid_until ON purchasing_management.quotations(valid_until); -- RLS Policies ALTER TABLE purchasing_management.quotations ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON purchasing_management.quotations FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON purchasing_management.quotations FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON purchasing_management.quotations FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON purchasing_management.quotations FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 5. Tabla: purchase_orders ```sql -- ============================================================================ -- Schema: purchasing_management -- Tabla: purchase_orders -- Descripcion: Ordenes de compra a proveedores -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS purchasing_management.purchase_orders ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Relaciones supplier_id UUID NOT NULL, -- FK a core_catalogs.contacts project_id UUID NOT NULL, requisition_id UUID REFERENCES purchasing_management.requisitions(id), rfq_id UUID REFERENCES purchasing_management.rfqs(id), quotation_id UUID REFERENCES purchasing_management.quotations(id), -- Fechas order_date DATE NOT NULL DEFAULT CURRENT_DATE, delivery_date DATE NOT NULL, -- Entrega delivery_address TEXT NOT NULL, delivery_contact VARCHAR(100), delivery_phone VARCHAR(20), -- Items items JSONB NOT NULL, /* Estructura: [{ materialId: UUID, materialCode: string, description: string, quantity: number, unit: string, unitPrice: number, subtotal: number, budgetItemId: UUID, notes: string }] */ -- Totales subtotal DECIMAL(15,2) NOT NULL, tax DECIMAL(15,2) NOT NULL DEFAULT 0, total DECIMAL(15,2) NOT NULL, -- Estado status purchasing_management.po_status NOT NULL DEFAULT 'draft', -- Condiciones de pago payment_terms VARCHAR(100), payment_terms_days INT DEFAULT 30, early_payment_discount DECIMAL(5,2) DEFAULT 0, requires_advance BOOLEAN DEFAULT false, advance_percentage DECIMAL(5,2), -- Otras condiciones warranty_days INT DEFAULT 30, includes_delivery BOOLEAN DEFAULT true, special_conditions TEXT, -- Aprobacion approved_by UUID REFERENCES core_users.users(id), approved_at TIMESTAMPTZ, -- Envio sent_at TIMESTAMPTZ, sent_by UUID REFERENCES core_users.users(id), -- Adjuntos attachments TEXT[], -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), updated_by UUID REFERENCES core_users.users(id), -- Soft delete is_active BOOLEAN NOT NULL DEFAULT true, deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_purchase_orders_totals CHECK (total = subtotal + tax), CONSTRAINT chk_purchase_orders_delivery_date CHECK (delivery_date >= order_date), CONSTRAINT chk_purchase_orders_advance CHECK ( (requires_advance = false AND advance_percentage IS NULL) OR (requires_advance = true AND advance_percentage > 0 AND advance_percentage <= 100) ), CONSTRAINT uq_purchase_orders_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE purchasing_management.purchase_orders IS 'Ordenes de compra a proveedores'; COMMENT ON COLUMN purchasing_management.purchase_orders.code IS 'Codigo unico OC (ej: OC-2025-00001)'; -- Indices CREATE INDEX idx_purchase_orders_tenant_id ON purchasing_management.purchase_orders(tenant_id); CREATE INDEX idx_purchase_orders_supplier_id ON purchasing_management.purchase_orders(supplier_id); CREATE INDEX idx_purchase_orders_project_id ON purchasing_management.purchase_orders(project_id); CREATE INDEX idx_purchase_orders_status ON purchasing_management.purchase_orders(status) WHERE is_active = true; CREATE INDEX idx_purchase_orders_delivery_date ON purchasing_management.purchase_orders(delivery_date); CREATE INDEX idx_purchase_orders_order_date ON purchasing_management.purchase_orders(order_date DESC); -- RLS Policies ALTER TABLE purchasing_management.purchase_orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON purchasing_management.purchase_orders FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON purchasing_management.purchase_orders FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON purchasing_management.purchase_orders FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON purchasing_management.purchase_orders FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 6. Tabla: po_receipts ```sql -- ============================================================================ -- Schema: purchasing_management -- Tabla: po_receipts -- Descripcion: Recepciones de material de ordenes de compra -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS purchasing_management.po_receipts ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Relaciones purchase_order_id UUID NOT NULL REFERENCES purchasing_management.purchase_orders(id), warehouse_id UUID NOT NULL, -- FK a inventory_management.warehouses -- Datos de recepcion receipt_date DATE NOT NULL DEFAULT CURRENT_DATE, received_by UUID NOT NULL REFERENCES core_users.users(id), -- Items recibidos items JSONB NOT NULL, /* Estructura: [{ poItemId: string, materialId: UUID, materialCode: string, description: string, orderedQuantity: number, receivedQuantity: number, acceptedQuantity: number, rejectedQuantity: number, unit: string, unitCost: number, rejectionReason: string }] */ -- Informacion de entrega delivery_note VARCHAR(50), transport_company VARCHAR(100), driver_name VARCHAR(100), vehicle_plates VARCHAR(20), notes TEXT, attachments TEXT[], -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT uq_po_receipts_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE purchasing_management.po_receipts IS 'Recepciones de material de ordenes de compra'; COMMENT ON COLUMN purchasing_management.po_receipts.code IS 'Codigo unico recepcion (ej: REC-2025-00001)'; -- Indices CREATE INDEX idx_po_receipts_tenant_id ON purchasing_management.po_receipts(tenant_id); CREATE INDEX idx_po_receipts_purchase_order_id ON purchasing_management.po_receipts(purchase_order_id); CREATE INDEX idx_po_receipts_warehouse_id ON purchasing_management.po_receipts(warehouse_id); CREATE INDEX idx_po_receipts_receipt_date ON purchasing_management.po_receipts(receipt_date DESC); -- RLS Policies ALTER TABLE purchasing_management.po_receipts ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON purchasing_management.po_receipts FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON purchasing_management.po_receipts FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON purchasing_management.po_receipts FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON purchasing_management.po_receipts FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` --- ## Schema: inventory_management ### 1. ENUMs ```sql -- ============================================================================ -- ENUMs - Inventory Management -- ============================================================================ -- Tipo de almacen CREATE TYPE inventory_management.warehouse_type AS ENUM ( 'general', -- Almacen general corporativo 'project', -- Almacen de proyecto especifico 'temporary' -- Almacen temporal ); COMMENT ON TYPE inventory_management.warehouse_type IS 'Tipos de almacen'; -- Tipo de movimiento CREATE TYPE inventory_management.movement_type AS ENUM ( 'entry', -- Entrada de material 'exit', -- Salida de material 'transfer_out', -- Traspaso salida 'transfer_in', -- Traspaso entrada 'adjustment' -- Ajuste de inventario ); COMMENT ON TYPE inventory_management.movement_type IS 'Tipos de movimiento de inventario'; -- Origen del movimiento CREATE TYPE inventory_management.movement_source AS ENUM ( 'purchase_order', -- Orden de compra 'transfer', -- Traspaso entre almacenes 'return', -- Devolucion 'adjustment', -- Ajuste 'production', -- Produccion 'consumption' -- Consumo en proyecto ); COMMENT ON TYPE inventory_management.movement_source IS 'Origen del movimiento de inventario'; -- Estado de traspaso CREATE TYPE inventory_management.transfer_status AS ENUM ( 'pending', -- Pendiente 'in_transit', -- En transito 'received', -- Recibido 'cancelled' -- Cancelado ); COMMENT ON TYPE inventory_management.transfer_status IS 'Estados de traspaso entre almacenes'; ``` ### 2. Tabla: warehouses ```sql -- ============================================================================ -- Schema: inventory_management -- Tabla: warehouses -- Descripcion: Almacenes generales, de proyecto y temporales -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS inventory_management.warehouses ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(20) NOT NULL, name VARCHAR(255) NOT NULL, -- Tipo type inventory_management.warehouse_type NOT NULL DEFAULT 'general', -- Relacion con proyecto (solo si type = 'project') project_id UUID, -- Ubicacion address TEXT, city VARCHAR(100), state VARCHAR(100), postal_code VARCHAR(10), -- Responsable managed_by UUID REFERENCES core_users.users(id), -- Dimensiones total_area DECIMAL(10,2), covered_area DECIMAL(10,2), -- Configuracion settings JSONB DEFAULT '{}', /* Estructura: { allowNegativeStock: boolean, requiresLocationTracking: boolean, defaultLocationId: UUID, autoGenerateLocations: boolean } */ -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), updated_by UUID REFERENCES core_users.users(id), -- Soft delete is_active BOOLEAN NOT NULL DEFAULT true, deleted_at TIMESTAMPTZ, deleted_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_warehouses_project CHECK ( (type = 'project' AND project_id IS NOT NULL) OR (type != 'project') ), CONSTRAINT uq_warehouses_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE inventory_management.warehouses IS 'Almacenes generales, de proyecto y temporales'; COMMENT ON COLUMN inventory_management.warehouses.type IS 'Tipo: general, project, temporary'; -- Indices CREATE INDEX idx_warehouses_tenant_id ON inventory_management.warehouses(tenant_id); CREATE INDEX idx_warehouses_type ON inventory_management.warehouses(type) WHERE is_active = true; CREATE INDEX idx_warehouses_project_id ON inventory_management.warehouses(project_id) WHERE project_id IS NOT NULL; CREATE INDEX idx_warehouses_managed_by ON inventory_management.warehouses(managed_by); -- RLS Policies ALTER TABLE inventory_management.warehouses ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON inventory_management.warehouses FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON inventory_management.warehouses FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON inventory_management.warehouses FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON inventory_management.warehouses FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 3. Tabla: warehouse_locations ```sql -- ============================================================================ -- Schema: inventory_management -- Tabla: warehouse_locations -- Descripcion: Ubicaciones dentro de almacenes (ej: A-01, B-03) -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS inventory_management.warehouse_locations ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Relacion warehouse_id UUID NOT NULL REFERENCES inventory_management.warehouses(id) ON DELETE CASCADE, -- Identificacion zone VARCHAR(10) NOT NULL, -- A, B, C, D position VARCHAR(10) NOT NULL, -- 01, 02, 03 code VARCHAR(20) NOT NULL, -- A-01, B-03 description VARCHAR(255), capacity_m3 DECIMAL(10,2), -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), is_active BOOLEAN NOT NULL DEFAULT true, -- Constraints CONSTRAINT uq_warehouse_locations_code UNIQUE (warehouse_id, code) ); COMMENT ON TABLE inventory_management.warehouse_locations IS 'Ubicaciones fisicas dentro de almacenes'; COMMENT ON COLUMN inventory_management.warehouse_locations.code IS 'Codigo de ubicacion (ej: A-01, B-03)'; -- Indices CREATE INDEX idx_warehouse_locations_tenant_id ON inventory_management.warehouse_locations(tenant_id); CREATE INDEX idx_warehouse_locations_warehouse_id ON inventory_management.warehouse_locations(warehouse_id); CREATE INDEX idx_warehouse_locations_code ON inventory_management.warehouse_locations(code); -- RLS Policies ALTER TABLE inventory_management.warehouse_locations ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON inventory_management.warehouse_locations FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON inventory_management.warehouse_locations FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON inventory_management.warehouse_locations FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON inventory_management.warehouse_locations FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 4. Tabla: inventory_movements ```sql -- ============================================================================ -- Schema: inventory_management -- Tabla: inventory_movements -- Descripcion: Movimientos de inventario (entradas, salidas, traspasos) -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS inventory_management.inventory_movements ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Identificacion code VARCHAR(30) NOT NULL, -- Almacen warehouse_id UUID NOT NULL REFERENCES inventory_management.warehouses(id), -- Tipo de movimiento type inventory_management.movement_type NOT NULL, movement_date DATE NOT NULL DEFAULT CURRENT_DATE, -- Origen del movimiento source_type inventory_management.movement_source, source_id UUID, -- Para salidas a proyecto project_id UUID, budget_item_id UUID, -- Para traspasos transfer_warehouse_id UUID REFERENCES inventory_management.warehouses(id), transfer_status inventory_management.transfer_status, received_at TIMESTAMPTZ, -- Items items JSONB NOT NULL, /* Estructura: [{ materialId: UUID, materialCode: string, description: string, quantity: number, unit: string, unitCost: number, totalCost: number, lotId: UUID, locationId: UUID }] */ total_value DECIMAL(15,2) NOT NULL, notes TEXT, attachments TEXT[], -- Autorizacion authorized_by UUID REFERENCES core_users.users(id), recorded_by UUID NOT NULL REFERENCES core_users.users(id), -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID REFERENCES core_users.users(id), -- Constraints CONSTRAINT chk_inventory_movements_transfer CHECK ( (type IN ('transfer_out', 'transfer_in') AND transfer_warehouse_id IS NOT NULL AND transfer_status IS NOT NULL) OR (type NOT IN ('transfer_out', 'transfer_in')) ), CONSTRAINT chk_inventory_movements_exit_project CHECK ( (type = 'exit' AND source_type = 'consumption' AND project_id IS NOT NULL) OR (type != 'exit' OR source_type != 'consumption') ), CONSTRAINT uq_inventory_movements_code_tenant UNIQUE (tenant_id, code) ); COMMENT ON TABLE inventory_management.inventory_movements IS 'Registro de todos los movimientos de inventario'; COMMENT ON COLUMN inventory_management.inventory_movements.type IS 'Tipo: entry, exit, transfer_out, transfer_in, adjustment'; COMMENT ON COLUMN inventory_management.inventory_movements.source_type IS 'Origen: purchase_order, transfer, return, adjustment, production'; -- Indices CREATE INDEX idx_inventory_movements_tenant_id ON inventory_management.inventory_movements(tenant_id); CREATE INDEX idx_inventory_movements_warehouse_id ON inventory_management.inventory_movements(warehouse_id); CREATE INDEX idx_inventory_movements_type ON inventory_management.inventory_movements(type); CREATE INDEX idx_inventory_movements_date ON inventory_management.inventory_movements(movement_date DESC); CREATE INDEX idx_inventory_movements_project_id ON inventory_management.inventory_movements(project_id) WHERE project_id IS NOT NULL; CREATE INDEX idx_inventory_movements_source ON inventory_management.inventory_movements(source_type, source_id); -- Indice GIN para busqueda en items CREATE INDEX idx_inventory_movements_items_gin ON inventory_management.inventory_movements USING gin (items jsonb_path_ops); -- RLS Policies ALTER TABLE inventory_management.inventory_movements ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON inventory_management.inventory_movements FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON inventory_management.inventory_movements FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON inventory_management.inventory_movements FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON inventory_management.inventory_movements FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 5. Tabla: stock_levels ```sql -- ============================================================================ -- Schema: inventory_management -- Tabla: stock_levels -- Descripcion: Niveles de stock actuales por almacen y material -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS inventory_management.stock_levels ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Relaciones warehouse_id UUID NOT NULL REFERENCES inventory_management.warehouses(id), material_id UUID NOT NULL, -- FK a core_catalogs.contacts o materials location_id UUID REFERENCES inventory_management.warehouse_locations(id), -- Cantidades quantity DECIMAL(12,4) NOT NULL DEFAULT 0, reserved_quantity DECIMAL(12,4) NOT NULL DEFAULT 0, available_quantity DECIMAL(12,4) GENERATED ALWAYS AS (quantity - reserved_quantity) STORED, -- Valoracion average_cost DECIMAL(12,2) NOT NULL DEFAULT 0, total_value DECIMAL(15,2) GENERATED ALWAYS AS (quantity * average_cost) STORED, -- Fechas de ultimo movimiento last_movement_date DATE, last_entry_date DATE, last_exit_date DATE, -- Auditoria updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Constraints CONSTRAINT chk_stock_levels_quantity CHECK (quantity >= 0), CONSTRAINT chk_stock_levels_reserved CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity), CONSTRAINT uq_stock_levels_warehouse_material UNIQUE (warehouse_id, material_id) ); COMMENT ON TABLE inventory_management.stock_levels IS 'Niveles de stock actuales por almacen y material'; COMMENT ON COLUMN inventory_management.stock_levels.quantity IS 'Cantidad total en almacen'; COMMENT ON COLUMN inventory_management.stock_levels.reserved_quantity IS 'Cantidad reservada para salidas'; COMMENT ON COLUMN inventory_management.stock_levels.available_quantity IS 'Cantidad disponible (quantity - reserved_quantity)'; COMMENT ON COLUMN inventory_management.stock_levels.average_cost IS 'Costo promedio ponderado'; -- Indices CREATE INDEX idx_stock_levels_tenant_id ON inventory_management.stock_levels(tenant_id); CREATE INDEX idx_stock_levels_warehouse_id ON inventory_management.stock_levels(warehouse_id); CREATE INDEX idx_stock_levels_material_id ON inventory_management.stock_levels(material_id); CREATE INDEX idx_stock_levels_location_id ON inventory_management.stock_levels(location_id) WHERE location_id IS NOT NULL; CREATE INDEX idx_stock_levels_last_movement ON inventory_management.stock_levels(last_movement_date DESC); -- Indice para buscar stock bajo CREATE INDEX idx_stock_levels_quantity ON inventory_management.stock_levels(quantity) WHERE quantity > 0; -- RLS Policies ALTER TABLE inventory_management.stock_levels ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON inventory_management.stock_levels FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON inventory_management.stock_levels FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON inventory_management.stock_levels FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON inventory_management.stock_levels FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 6. Tabla: inventory_lots ```sql -- ============================================================================ -- Schema: inventory_management -- Tabla: inventory_lots -- Descripcion: Lotes de inventario para valoracion PEPS -- Modulo: MAI-004 -- ============================================================================ CREATE TABLE IF NOT EXISTS inventory_management.inventory_lots ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Multi-tenant tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Relaciones warehouse_id UUID NOT NULL REFERENCES inventory_management.warehouses(id), material_id UUID NOT NULL, -- Identificacion del lote lot_number VARCHAR(50), entry_date DATE NOT NULL DEFAULT CURRENT_DATE, -- Cantidades quantity DECIMAL(12,4) NOT NULL, remaining_quantity DECIMAL(12,4) NOT NULL, unit_cost DECIMAL(12,2) NOT NULL, -- Origen source_type inventory_management.movement_source NOT NULL, source_id UUID NOT NULL, -- Estado is_depleted BOOLEAN GENERATED ALWAYS AS (remaining_quantity <= 0.0001) STORED, -- Auditoria created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Constraints CONSTRAINT chk_inventory_lots_remaining CHECK (remaining_quantity >= 0 AND remaining_quantity <= quantity), CONSTRAINT chk_inventory_lots_unit_cost CHECK (unit_cost >= 0) ); COMMENT ON TABLE inventory_management.inventory_lots IS 'Lotes de inventario para valoracion PEPS (Primeras Entradas, Primeras Salidas)'; COMMENT ON COLUMN inventory_management.inventory_lots.lot_number IS 'Numero de lote del proveedor (opcional)'; COMMENT ON COLUMN inventory_management.inventory_lots.remaining_quantity IS 'Cantidad restante del lote'; COMMENT ON COLUMN inventory_management.inventory_lots.is_depleted IS 'Si el lote se agoto completamente'; -- Indices CREATE INDEX idx_inventory_lots_tenant_id ON inventory_management.inventory_lots(tenant_id); CREATE INDEX idx_inventory_lots_warehouse_material ON inventory_management.inventory_lots(warehouse_id, material_id, entry_date); CREATE INDEX idx_inventory_lots_not_depleted ON inventory_management.inventory_lots(warehouse_id, material_id, entry_date) WHERE is_depleted = false; CREATE INDEX idx_inventory_lots_source ON inventory_management.inventory_lots(source_type, source_id); -- RLS Policies ALTER TABLE inventory_management.inventory_lots ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_select ON inventory_management.inventory_lots FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_insert ON inventory_management.inventory_lots FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_update ON inventory_management.inventory_lots FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_delete ON inventory_management.inventory_lots FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` --- ## Funciones y Triggers ### 1. Trigger: updated_at ```sql -- ============================================================================ -- TRIGGER: Actualizar updated_at automaticamente -- ============================================================================ -- Aplicar a tablas con updated_at CREATE TRIGGER trg_requisitions_update_timestamp BEFORE UPDATE ON purchasing_management.requisitions FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); CREATE TRIGGER trg_rfqs_update_timestamp BEFORE UPDATE ON purchasing_management.rfqs FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); CREATE TRIGGER trg_quotations_update_timestamp BEFORE UPDATE ON purchasing_management.quotations FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); CREATE TRIGGER trg_purchase_orders_update_timestamp BEFORE UPDATE ON purchasing_management.purchase_orders FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); CREATE TRIGGER trg_warehouses_update_timestamp BEFORE UPDATE ON inventory_management.warehouses FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); CREATE TRIGGER trg_stock_levels_update_timestamp BEFORE UPDATE ON inventory_management.stock_levels FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); ``` ### 2. Trigger: Actualizar estado de OC al recibir material ```sql -- ============================================================================ -- TRIGGER: Actualizar estado de orden de compra segun recepciones -- ============================================================================ CREATE OR REPLACE FUNCTION purchasing_management.update_po_status_on_receipt() RETURNS TRIGGER AS $$ DECLARE v_total_ordered DECIMAL(15,4); v_total_received DECIMAL(15,4); v_po_item JSONB; v_receipt_item JSONB; BEGIN -- Calcular total ordenado SELECT COALESCE(SUM((item->>'quantity')::DECIMAL), 0) INTO v_total_ordered FROM purchasing_management.purchase_orders po, JSONB_ARRAY_ELEMENTS(po.items) AS item WHERE po.id = NEW.purchase_order_id; -- Calcular total recibido (aceptado) de todas las recepciones SELECT COALESCE(SUM((item->>'acceptedQuantity')::DECIMAL), 0) INTO v_total_received FROM purchasing_management.po_receipts pr, JSONB_ARRAY_ELEMENTS(pr.items) AS item WHERE pr.purchase_order_id = NEW.purchase_order_id; -- Actualizar estado de la OC IF v_total_received >= v_total_ordered THEN UPDATE purchasing_management.purchase_orders SET status = 'received', updated_at = now() WHERE id = NEW.purchase_order_id AND status != 'received'; ELSIF v_total_received > 0 THEN UPDATE purchasing_management.purchase_orders SET status = 'partially_received', updated_at = now() WHERE id = NEW.purchase_order_id AND status NOT IN ('received', 'partially_received'); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_po_receipts_update_po_status AFTER INSERT ON purchasing_management.po_receipts FOR EACH ROW EXECUTE FUNCTION purchasing_management.update_po_status_on_receipt(); ``` ### 3. Funcion: Actualizacion de stock y creacion de lotes ```sql -- ============================================================================ -- FUNCION: Actualizar stock y crear lotes PEPS al recibir material -- ============================================================================ CREATE OR REPLACE FUNCTION inventory_management.update_stock_on_entry( p_tenant_id UUID, p_warehouse_id UUID, p_material_id UUID, p_quantity DECIMAL(12,4), p_unit_cost DECIMAL(12,2), p_source_type inventory_management.movement_source, p_source_id UUID, p_lot_number VARCHAR(50) DEFAULT NULL ) RETURNS VOID AS $$ DECLARE v_current_quantity DECIMAL(12,4); v_current_value DECIMAL(15,2); v_new_quantity DECIMAL(12,4); v_new_average_cost DECIMAL(12,2); BEGIN -- Obtener stock actual SELECT quantity, total_value INTO v_current_quantity, v_current_value FROM inventory_management.stock_levels WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id; -- Si no existe, crear registro de stock IF NOT FOUND THEN INSERT INTO inventory_management.stock_levels ( tenant_id, warehouse_id, material_id, quantity, reserved_quantity, average_cost, last_entry_date, last_movement_date ) VALUES ( p_tenant_id, p_warehouse_id, p_material_id, p_quantity, 0, p_unit_cost, CURRENT_DATE, CURRENT_DATE ); ELSE -- Calcular nueva cantidad y costo promedio v_new_quantity := v_current_quantity + p_quantity; v_new_average_cost := (v_current_value + (p_quantity * p_unit_cost)) / v_new_quantity; -- Actualizar stock UPDATE inventory_management.stock_levels SET quantity = v_new_quantity, average_cost = v_new_average_cost, last_entry_date = CURRENT_DATE, last_movement_date = CURRENT_DATE, updated_at = now() WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id; END IF; -- Crear lote PEPS INSERT INTO inventory_management.inventory_lots ( tenant_id, warehouse_id, material_id, lot_number, entry_date, quantity, remaining_quantity, unit_cost, source_type, source_id ) VALUES ( p_tenant_id, p_warehouse_id, p_material_id, p_lot_number, CURRENT_DATE, p_quantity, p_quantity, p_unit_cost, p_source_type, p_source_id ); END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory_management.update_stock_on_entry IS 'Actualiza stock y crea lote PEPS al recibir material'; ``` ### 4. Funcion: Valoracion PEPS en salidas ```sql -- ============================================================================ -- FUNCION: Calcular costo PEPS en salidas de inventario -- ============================================================================ CREATE OR REPLACE FUNCTION inventory_management.calculate_fifo_cost( p_warehouse_id UUID, p_material_id UUID, p_quantity DECIMAL(12,4) ) RETURNS TABLE ( lot_id UUID, quantity_consumed DECIMAL(12,4), unit_cost DECIMAL(12,2), total_cost DECIMAL(15,2) ) AS $$ DECLARE v_remaining DECIMAL(12,4) := p_quantity; v_lot RECORD; v_consume_qty DECIMAL(12,4); BEGIN -- Iterar lotes en orden FIFO (primero el mas antiguo) FOR v_lot IN SELECT id, remaining_quantity, unit_cost FROM inventory_management.inventory_lots WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id AND is_depleted = false ORDER BY entry_date ASC, created_at ASC LOOP EXIT WHEN v_remaining <= 0; -- Calcular cuanto consumir de este lote v_consume_qty := LEAST(v_lot.remaining_quantity, v_remaining); -- Retornar informacion del consumo lot_id := v_lot.id; quantity_consumed := v_consume_qty; unit_cost := v_lot.unit_cost; total_cost := v_consume_qty * v_lot.unit_cost; RETURN NEXT; v_remaining := v_remaining - v_consume_qty; END LOOP; -- Verificar si hay stock suficiente IF v_remaining > 0.0001 THEN RAISE EXCEPTION 'Stock insuficiente. Faltan % unidades', v_remaining; END IF; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory_management.calculate_fifo_cost IS 'Calcula costo PEPS y distribucion por lotes para una salida'; ``` ### 5. Funcion: Salida de inventario con PEPS ```sql -- ============================================================================ -- FUNCION: Procesar salida de inventario con valoracion PEPS -- ============================================================================ CREATE OR REPLACE FUNCTION inventory_management.update_stock_on_exit( p_tenant_id UUID, p_warehouse_id UUID, p_material_id UUID, p_quantity DECIMAL(12,4) ) RETURNS TABLE ( total_cost DECIMAL(15,2), average_cost DECIMAL(12,2) ) AS $$ DECLARE v_fifo_record RECORD; v_total_cost DECIMAL(15,2) := 0; v_avg_cost DECIMAL(12,2); BEGIN -- Verificar stock disponible IF NOT EXISTS ( SELECT 1 FROM inventory_management.stock_levels WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id AND available_quantity >= p_quantity ) THEN RAISE EXCEPTION 'Stock insuficiente disponible para material %', p_material_id; END IF; -- Consumir lotes PEPS FOR v_fifo_record IN SELECT * FROM inventory_management.calculate_fifo_cost(p_warehouse_id, p_material_id, p_quantity) LOOP -- Actualizar lote UPDATE inventory_management.inventory_lots SET remaining_quantity = remaining_quantity - v_fifo_record.quantity_consumed WHERE id = v_fifo_record.lot_id; v_total_cost := v_total_cost + v_fifo_record.total_cost; END LOOP; -- Calcular costo promedio v_avg_cost := v_total_cost / p_quantity; -- Actualizar stock UPDATE inventory_management.stock_levels SET quantity = quantity - p_quantity, last_exit_date = CURRENT_DATE, last_movement_date = CURRENT_DATE, updated_at = now() WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id; total_cost := v_total_cost; average_cost := v_avg_cost; RETURN NEXT; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory_management.update_stock_on_exit IS 'Procesa salida de inventario con valoracion PEPS'; ``` ### 6. Funcion: Generar codigo de requisicion ```sql -- ============================================================================ -- FUNCION: Generar codigo automatico de requisicion -- ============================================================================ CREATE OR REPLACE FUNCTION purchasing_management.generate_requisition_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; v_code VARCHAR(30); BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX(SUBSTRING(code FROM 10)::INT), 0) + 1 INTO v_sequence FROM purchasing_management.requisitions WHERE tenant_id = NEW.tenant_id AND code LIKE 'REQ-' || v_year || '-%'; v_code := 'REQ-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); NEW.code := v_code; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_requisitions_generate_code BEFORE INSERT ON purchasing_management.requisitions FOR EACH ROW EXECUTE FUNCTION purchasing_management.generate_requisition_code(); ``` ### 7. Funcion: Generar codigos para otras entidades ```sql -- ============================================================================ -- FUNCIONES: Generar codigos automaticos -- ============================================================================ -- RFQ CREATE OR REPLACE FUNCTION purchasing_management.generate_rfq_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX(SUBSTRING(code FROM 10)::INT), 0) + 1 INTO v_sequence FROM purchasing_management.rfqs WHERE tenant_id = NEW.tenant_id AND code LIKE 'RFQ-' || v_year || '-%'; NEW.code := 'RFQ-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_rfqs_generate_code BEFORE INSERT ON purchasing_management.rfqs FOR EACH ROW EXECUTE FUNCTION purchasing_management.generate_rfq_code(); -- Quotation CREATE OR REPLACE FUNCTION purchasing_management.generate_quotation_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX(SUBSTRING(code FROM 11)::INT), 0) + 1 INTO v_sequence FROM purchasing_management.quotations WHERE tenant_id = NEW.tenant_id AND code LIKE 'QUOT-' || v_year || '-%'; NEW.code := 'QUOT-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_quotations_generate_code BEFORE INSERT ON purchasing_management.quotations FOR EACH ROW EXECUTE FUNCTION purchasing_management.generate_quotation_code(); -- Purchase Order CREATE OR REPLACE FUNCTION purchasing_management.generate_po_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX(SUBSTRING(code FROM 9)::INT), 0) + 1 INTO v_sequence FROM purchasing_management.purchase_orders WHERE tenant_id = NEW.tenant_id AND code LIKE 'OC-' || v_year || '-%'; NEW.code := 'OC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_purchase_orders_generate_code BEFORE INSERT ON purchasing_management.purchase_orders FOR EACH ROW EXECUTE FUNCTION purchasing_management.generate_po_code(); -- PO Receipt CREATE OR REPLACE FUNCTION purchasing_management.generate_po_receipt_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX(SUBSTRING(code FROM 10)::INT), 0) + 1 INTO v_sequence FROM purchasing_management.po_receipts WHERE tenant_id = NEW.tenant_id AND code LIKE 'REC-' || v_year || '-%'; NEW.code := 'REC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_po_receipts_generate_code BEFORE INSERT ON purchasing_management.po_receipts FOR EACH ROW EXECUTE FUNCTION purchasing_management.generate_po_receipt_code(); -- Inventory Movement CREATE OR REPLACE FUNCTION inventory_management.generate_movement_code() RETURNS TRIGGER AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; v_prefix VARCHAR(5); BEGIN IF NEW.code IS NOT NULL AND NEW.code != '' THEN RETURN NEW; END IF; v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); -- Prefijo segun tipo de movimiento v_prefix := CASE NEW.type WHEN 'entry' THEN 'ENT' WHEN 'exit' THEN 'SAL' WHEN 'transfer_out' THEN 'TSO' WHEN 'transfer_in' THEN 'TSI' WHEN 'adjustment' THEN 'AJU' ELSE 'MOV' END; SELECT COALESCE(MAX(SUBSTRING(code FROM LENGTH(v_prefix) + 7)::INT), 0) + 1 INTO v_sequence FROM inventory_management.inventory_movements WHERE tenant_id = NEW.tenant_id AND code LIKE v_prefix || '-' || v_year || '-%'; NEW.code := v_prefix || '-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_inventory_movements_generate_code BEFORE INSERT ON inventory_management.inventory_movements FOR EACH ROW EXECUTE FUNCTION inventory_management.generate_movement_code(); ``` --- ## Vistas Utiles ### 1. Vista: Stock consolidado por material ```sql -- ============================================================================ -- VISTA: Stock consolidado por material (todos los almacenes) -- ============================================================================ CREATE OR REPLACE VIEW inventory_management.v_stock_consolidated AS SELECT tenant_id, material_id, SUM(quantity) AS total_quantity, SUM(reserved_quantity) AS total_reserved, SUM(available_quantity) AS total_available, AVG(average_cost) AS weighted_average_cost, SUM(total_value) AS total_value, COUNT(DISTINCT warehouse_id) AS warehouses_count, MAX(last_movement_date) AS last_movement_date FROM inventory_management.stock_levels WHERE quantity > 0 GROUP BY tenant_id, material_id; COMMENT ON VIEW inventory_management.v_stock_consolidated IS 'Stock consolidado por material sumando todos los almacenes'; ``` ### 2. Vista: Ordenes de compra pendientes de recepcion ```sql -- ============================================================================ -- VISTA: Ordenes de compra pendientes de recepcion -- ============================================================================ CREATE OR REPLACE VIEW purchasing_management.v_pending_receipts AS SELECT po.id, po.code, po.tenant_id, po.supplier_id, po.project_id, po.order_date, po.delivery_date, po.status, po.total, CURRENT_DATE - po.delivery_date AS days_overdue, CASE WHEN CURRENT_DATE > po.delivery_date THEN true ELSE false END AS is_overdue FROM purchasing_management.purchase_orders po WHERE po.status IN ('sent', 'partially_received') AND po.is_active = true ORDER BY po.delivery_date ASC; COMMENT ON VIEW purchasing_management.v_pending_receipts IS 'Ordenes de compra pendientes de recibir (total o parcialmente)'; ``` --- ## Scripts de Inicializacion ### Schema creation ```sql -- ============================================================================ -- CREAR SCHEMAS -- ============================================================================ CREATE SCHEMA IF NOT EXISTS purchasing_management; CREATE SCHEMA IF NOT EXISTS inventory_management; COMMENT ON SCHEMA purchasing_management IS 'Gestion de compras: requisiciones, cotizaciones, ordenes'; COMMENT ON SCHEMA inventory_management IS 'Gestion de inventarios: almacenes, movimientos, stock'; ``` --- ## Notas de Implementacion ### Orden de Creacion 1. Crear schemas 2. Crear ENUMs 3. Crear tablas en orden: - purchasing_management.requisitions - purchasing_management.rfqs - purchasing_management.quotations - purchasing_management.purchase_orders - purchasing_management.po_receipts - inventory_management.warehouses - inventory_management.warehouse_locations - inventory_management.inventory_movements - inventory_management.stock_levels - inventory_management.inventory_lots 4. Crear indices 5. Habilitar RLS y crear policies 6. Crear funciones 7. Crear triggers 8. Crear vistas ### Dependencias Externas - `core_tenants.tenants` - Tabla de tenants - `core_users.users` - Tabla de usuarios - `core_shared.set_updated_at()` - Funcion compartida para updated_at - Schema `projects` - Para relacion con proyectos - Schema `core_catalogs` - Para materiales y proveedores ### Consideraciones de Rendimiento - Indices GIN en columnas JSONB para busquedas eficientes - Indices parciales en columnas de estado para queries frecuentes - Columnas generadas (STORED) para calculos frecuentes - Particionamiento futuro en `inventory_movements` por fecha si crece mucho ### Migraciones - Crear migracion TypeORM siguiendo nomenclatura: `{timestamp}-create-purchasing-inventory-schemas.ts` - Separar en multiples archivos si es muy grande - Incluir rollback (down) para todas las operaciones --- **Estado:** En Diseno **Proximos Pasos:** Revision y aprobacion para implementacion **Fecha Creacion:** 2025-12-06