-- ===================================================== -- SCHEMA: purchase -- PROPÓSITO: Gestión de compras, proveedores, órdenes de compra -- MÓDULOS: MGN-006 (Compras Básico) -- FECHA: 2025-11-24 -- ===================================================== -- Crear schema CREATE SCHEMA IF NOT EXISTS purchase; -- ===================================================== -- TYPES (ENUMs) -- ===================================================== CREATE TYPE purchase.order_status AS ENUM ( 'draft', 'sent', 'confirmed', 'received', 'billed', 'cancelled' ); CREATE TYPE purchase.rfq_status AS ENUM ( 'draft', 'sent', 'responded', 'accepted', 'rejected', 'cancelled' ); CREATE TYPE purchase.agreement_type AS ENUM ( 'price', 'discount', 'blanket' ); -- ===================================================== -- TABLES -- ===================================================== -- Tabla: purchase_orders (Órdenes de compra) CREATE TABLE purchase.purchase_orders ( 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, -- Numeración name VARCHAR(100) NOT NULL, ref VARCHAR(100), -- Referencia del proveedor -- Proveedor partner_id UUID NOT NULL REFERENCES core.partners(id), -- Fechas order_date DATE NOT NULL, expected_date DATE, effective_date DATE, -- Configuración currency_id UUID NOT NULL REFERENCES core.currencies(id), payment_term_id UUID REFERENCES financial.payment_terms(id), -- Montos amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, -- Estado status purchase.order_status NOT NULL DEFAULT 'draft', -- Recepciones y facturación receipt_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, received invoice_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, billed -- Relaciones picking_id UUID REFERENCES inventory.pickings(id), -- Recepción generada invoice_id UUID REFERENCES financial.invoices(id), -- Factura generada -- Notas notes TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), confirmed_at TIMESTAMP, confirmed_by UUID REFERENCES auth.users(id), cancelled_at TIMESTAMP, cancelled_by UUID REFERENCES auth.users(id), CONSTRAINT uq_purchase_orders_name_company UNIQUE (company_id, name) ); -- Tabla: purchase_order_lines (Líneas de orden de compra) CREATE TABLE purchase.purchase_order_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, order_id UUID NOT NULL REFERENCES purchase.purchase_orders(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES inventory.products(id), description TEXT NOT NULL, -- Cantidades quantity DECIMAL(12, 4) NOT NULL, qty_received DECIMAL(12, 4) DEFAULT 0, qty_invoiced DECIMAL(12, 4) DEFAULT 0, uom_id UUID NOT NULL REFERENCES core.uom(id), -- Precios price_unit DECIMAL(15, 4) NOT NULL, discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento -- Impuestos tax_ids UUID[] DEFAULT '{}', -- Montos amount_untaxed DECIMAL(15, 2) NOT NULL, amount_tax DECIMAL(15, 2) NOT NULL, amount_total DECIMAL(15, 2) NOT NULL, -- Fechas esperadas expected_date DATE, -- Analítica analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT chk_purchase_order_lines_quantity CHECK (quantity > 0), CONSTRAINT chk_purchase_order_lines_discount CHECK (discount >= 0 AND discount <= 100) ); -- Índices para purchase_order_lines CREATE INDEX idx_purchase_order_lines_tenant_id ON purchase.purchase_order_lines(tenant_id); CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id); CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id); -- RLS para purchase_order_lines ALTER TABLE purchase.purchase_order_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_purchase_order_lines ON purchase.purchase_order_lines USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: rfqs (Request for Quotation - Solicitudes de cotización) CREATE TABLE purchase.rfqs ( 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, name VARCHAR(100) NOT NULL, -- Proveedores (puede ser enviada a múltiples proveedores) partner_ids UUID[] NOT NULL, -- Fechas request_date DATE NOT NULL, deadline_date DATE, response_date DATE, -- Estado status purchase.rfq_status NOT NULL DEFAULT 'draft', -- Descripción description TEXT, notes TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_rfqs_name_company UNIQUE (company_id, name) ); -- Tabla: rfq_lines (Líneas de RFQ) CREATE TABLE purchase.rfq_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, rfq_id UUID NOT NULL REFERENCES purchase.rfqs(id) ON DELETE CASCADE, product_id UUID REFERENCES inventory.products(id), description TEXT NOT NULL, quantity DECIMAL(12, 4) NOT NULL, uom_id UUID NOT NULL REFERENCES core.uom(id), -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_rfq_lines_quantity CHECK (quantity > 0) ); -- Índices para rfq_lines CREATE INDEX idx_rfq_lines_tenant_id ON purchase.rfq_lines(tenant_id); -- RLS para rfq_lines ALTER TABLE purchase.rfq_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_rfq_lines ON purchase.rfq_lines USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: vendor_pricelists (Listas de precios de proveedores) CREATE TABLE purchase.vendor_pricelists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, partner_id UUID NOT NULL REFERENCES core.partners(id), product_id UUID NOT NULL REFERENCES inventory.products(id), -- Precio price DECIMAL(15, 4) NOT NULL, currency_id UUID NOT NULL REFERENCES core.currencies(id), -- Cantidad mínima min_quantity DECIMAL(12, 4) DEFAULT 1, -- Validez valid_from DATE, valid_to DATE, -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT chk_vendor_pricelists_price CHECK (price >= 0), CONSTRAINT chk_vendor_pricelists_min_qty CHECK (min_quantity > 0), CONSTRAINT chk_vendor_pricelists_dates CHECK (valid_to IS NULL OR valid_to >= valid_from) ); -- Tabla: purchase_agreements (Acuerdos de compra / Contratos) CREATE TABLE purchase.purchase_agreements ( 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, name VARCHAR(255) NOT NULL, code VARCHAR(50), agreement_type purchase.agreement_type NOT NULL, -- Proveedor partner_id UUID NOT NULL REFERENCES core.partners(id), -- Vigencia start_date DATE NOT NULL, end_date DATE NOT NULL, -- Montos (para contratos blanket) amount_max DECIMAL(15, 2), currency_id UUID REFERENCES core.currencies(id), -- Estado is_active BOOLEAN DEFAULT TRUE, -- Términos terms TEXT, notes TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_purchase_agreements_code_company UNIQUE (company_id, code), CONSTRAINT chk_purchase_agreements_dates CHECK (end_date > start_date) ); -- Tabla: purchase_agreement_lines (Líneas de acuerdo) CREATE TABLE purchase.purchase_agreement_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, agreement_id UUID NOT NULL REFERENCES purchase.purchase_agreements(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES inventory.products(id), -- Cantidades quantity DECIMAL(12, 4), qty_ordered DECIMAL(12, 4) DEFAULT 0, -- Precio acordado price_unit DECIMAL(15, 4), discount DECIMAL(5, 2) DEFAULT 0, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Índices para purchase_agreement_lines CREATE INDEX idx_purchase_agreement_lines_tenant_id ON purchase.purchase_agreement_lines(tenant_id); -- RLS para purchase_agreement_lines ALTER TABLE purchase.purchase_agreement_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_purchase_agreement_lines ON purchase.purchase_agreement_lines USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: vendor_evaluations (Evaluaciones de proveedores) CREATE TABLE purchase.vendor_evaluations ( 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, partner_id UUID NOT NULL REFERENCES core.partners(id), -- Período de evaluación evaluation_date DATE NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, -- Calificaciones (1-5) quality_rating INTEGER, delivery_rating INTEGER, service_rating INTEGER, price_rating INTEGER, overall_rating DECIMAL(3, 2), -- Métricas on_time_delivery_rate DECIMAL(5, 2), -- Porcentaje defect_rate DECIMAL(5, 2), -- Porcentaje -- Comentarios comments TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT chk_vendor_evaluations_quality CHECK (quality_rating >= 1 AND quality_rating <= 5), CONSTRAINT chk_vendor_evaluations_delivery CHECK (delivery_rating >= 1 AND delivery_rating <= 5), CONSTRAINT chk_vendor_evaluations_service CHECK (service_rating >= 1 AND service_rating <= 5), CONSTRAINT chk_vendor_evaluations_price CHECK (price_rating >= 1 AND price_rating <= 5), CONSTRAINT chk_vendor_evaluations_overall CHECK (overall_rating >= 1 AND overall_rating <= 5), CONSTRAINT chk_vendor_evaluations_dates CHECK (period_end >= period_start) ); -- ===================================================== -- INDICES -- ===================================================== -- Purchase Orders CREATE INDEX idx_purchase_orders_tenant_id ON purchase.purchase_orders(tenant_id); CREATE INDEX idx_purchase_orders_company_id ON purchase.purchase_orders(company_id); CREATE INDEX idx_purchase_orders_partner_id ON purchase.purchase_orders(partner_id); CREATE INDEX idx_purchase_orders_name ON purchase.purchase_orders(name); CREATE INDEX idx_purchase_orders_status ON purchase.purchase_orders(status); CREATE INDEX idx_purchase_orders_order_date ON purchase.purchase_orders(order_date); CREATE INDEX idx_purchase_orders_expected_date ON purchase.purchase_orders(expected_date); -- Purchase Order Lines CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id); CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id); CREATE INDEX idx_purchase_order_lines_analytic_account_id ON purchase.purchase_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL; -- RFQs CREATE INDEX idx_rfqs_tenant_id ON purchase.rfqs(tenant_id); CREATE INDEX idx_rfqs_company_id ON purchase.rfqs(company_id); CREATE INDEX idx_rfqs_status ON purchase.rfqs(status); CREATE INDEX idx_rfqs_request_date ON purchase.rfqs(request_date); -- RFQ Lines CREATE INDEX idx_rfq_lines_rfq_id ON purchase.rfq_lines(rfq_id); CREATE INDEX idx_rfq_lines_product_id ON purchase.rfq_lines(product_id); -- Vendor Pricelists CREATE INDEX idx_vendor_pricelists_tenant_id ON purchase.vendor_pricelists(tenant_id); CREATE INDEX idx_vendor_pricelists_partner_id ON purchase.vendor_pricelists(partner_id); CREATE INDEX idx_vendor_pricelists_product_id ON purchase.vendor_pricelists(product_id); CREATE INDEX idx_vendor_pricelists_active ON purchase.vendor_pricelists(active) WHERE active = TRUE; -- Purchase Agreements CREATE INDEX idx_purchase_agreements_tenant_id ON purchase.purchase_agreements(tenant_id); CREATE INDEX idx_purchase_agreements_company_id ON purchase.purchase_agreements(company_id); CREATE INDEX idx_purchase_agreements_partner_id ON purchase.purchase_agreements(partner_id); CREATE INDEX idx_purchase_agreements_dates ON purchase.purchase_agreements(start_date, end_date); CREATE INDEX idx_purchase_agreements_active ON purchase.purchase_agreements(is_active) WHERE is_active = TRUE; -- Purchase Agreement Lines CREATE INDEX idx_purchase_agreement_lines_agreement_id ON purchase.purchase_agreement_lines(agreement_id); CREATE INDEX idx_purchase_agreement_lines_product_id ON purchase.purchase_agreement_lines(product_id); -- Vendor Evaluations CREATE INDEX idx_vendor_evaluations_tenant_id ON purchase.vendor_evaluations(tenant_id); CREATE INDEX idx_vendor_evaluations_partner_id ON purchase.vendor_evaluations(partner_id); CREATE INDEX idx_vendor_evaluations_date ON purchase.vendor_evaluations(evaluation_date); -- ===================================================== -- FUNCTIONS -- ===================================================== -- Función: calculate_purchase_order_totals CREATE OR REPLACE FUNCTION purchase.calculate_purchase_order_totals(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_amount_untaxed DECIMAL; v_amount_tax DECIMAL; v_amount_total DECIMAL; BEGIN SELECT COALESCE(SUM(amount_untaxed), 0), COALESCE(SUM(amount_tax), 0), COALESCE(SUM(amount_total), 0) INTO v_amount_untaxed, v_amount_tax, v_amount_total FROM purchase.purchase_order_lines WHERE order_id = p_order_id; UPDATE purchase.purchase_orders SET amount_untaxed = v_amount_untaxed, amount_tax = v_amount_tax, amount_total = v_amount_total, updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_order_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.calculate_purchase_order_totals IS 'Calcula los totales de una orden de compra'; -- Función: create_picking_from_po CREATE OR REPLACE FUNCTION purchase.create_picking_from_po(p_order_id UUID) RETURNS UUID AS $$ DECLARE v_order RECORD; v_picking_id UUID; v_location_supplier UUID; v_location_stock UUID; BEGIN -- Obtener datos de la orden SELECT * INTO v_order FROM purchase.purchase_orders WHERE id = p_order_id; IF NOT FOUND THEN RAISE EXCEPTION 'Purchase order % not found', p_order_id; END IF; -- Obtener ubicaciones (simplificado - en producción obtener de configuración) SELECT id INTO v_location_supplier FROM inventory.locations WHERE location_type = 'supplier' LIMIT 1; SELECT id INTO v_location_stock FROM inventory.locations WHERE location_type = 'internal' LIMIT 1; -- Crear picking INSERT INTO inventory.pickings ( tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, origin, scheduled_date ) VALUES ( v_order.tenant_id, v_order.company_id, 'IN/' || v_order.name, 'incoming', v_location_supplier, v_location_stock, v_order.partner_id, v_order.name, v_order.expected_date ) RETURNING id INTO v_picking_id; -- Actualizar la PO con el picking_id UPDATE purchase.purchase_orders SET picking_id = v_picking_id WHERE id = p_order_id; RETURN v_picking_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.create_picking_from_po IS 'Crea un picking de recepción a partir de una orden de compra'; -- ===================================================== -- TRIGGERS -- ===================================================== CREATE TRIGGER trg_purchase_orders_updated_at BEFORE UPDATE ON purchase.purchase_orders FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_rfqs_updated_at BEFORE UPDATE ON purchase.rfqs FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_vendor_pricelists_updated_at BEFORE UPDATE ON purchase.vendor_pricelists FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_purchase_agreements_updated_at BEFORE UPDATE ON purchase.purchase_agreements FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Trigger: Actualizar totales de PO al cambiar líneas CREATE OR REPLACE FUNCTION purchase.trg_update_po_totals() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'DELETE' THEN PERFORM purchase.calculate_purchase_order_totals(OLD.order_id); ELSE PERFORM purchase.calculate_purchase_order_totals(NEW.order_id); END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_purchase_order_lines_update_totals AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_order_lines FOR EACH ROW EXECUTE FUNCTION purchase.trg_update_po_totals(); -- ===================================================== -- TRACKING AUTOMÁTICO (mail.thread pattern) -- ===================================================== -- Trigger: Tracking automático para órdenes de compra CREATE TRIGGER track_purchase_order_changes AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_orders FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); COMMENT ON TRIGGER track_purchase_order_changes ON purchase.purchase_orders IS 'Registra automáticamente cambios en órdenes de compra (estado, proveedor, monto, fecha)'; -- ===================================================== -- ROW LEVEL SECURITY (RLS) -- ===================================================== ALTER TABLE purchase.purchase_orders ENABLE ROW LEVEL SECURITY; ALTER TABLE purchase.rfqs ENABLE ROW LEVEL SECURITY; ALTER TABLE purchase.vendor_pricelists ENABLE ROW LEVEL SECURITY; ALTER TABLE purchase.purchase_agreements ENABLE ROW LEVEL SECURITY; ALTER TABLE purchase.vendor_evaluations ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_purchase_orders ON purchase.purchase_orders USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_rfqs ON purchase.rfqs USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_vendor_pricelists ON purchase.vendor_pricelists USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_purchase_agreements ON purchase.purchase_agreements USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_vendor_evaluations ON purchase.vendor_evaluations USING (tenant_id = get_current_tenant_id()); -- ===================================================== -- COMENTARIOS -- ===================================================== COMMENT ON SCHEMA purchase IS 'Schema de gestión de compras y proveedores'; COMMENT ON TABLE purchase.purchase_orders IS 'Órdenes de compra a proveedores'; COMMENT ON TABLE purchase.purchase_order_lines IS 'Líneas de órdenes de compra'; COMMENT ON TABLE purchase.rfqs IS 'Solicitudes de cotización (RFQ)'; COMMENT ON TABLE purchase.rfq_lines IS 'Líneas de solicitudes de cotización'; COMMENT ON TABLE purchase.vendor_pricelists IS 'Listas de precios de proveedores'; COMMENT ON TABLE purchase.purchase_agreements IS 'Acuerdos/contratos de compra con proveedores'; COMMENT ON TABLE purchase.purchase_agreement_lines IS 'Líneas de acuerdos de compra'; COMMENT ON TABLE purchase.vendor_evaluations IS 'Evaluaciones de desempeño de proveedores'; -- ===================================================== -- FIN DEL SCHEMA PURCHASE -- =====================================================