-- ===================================================== -- 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', 'to_approve', -- COR-001: Estado de aprobación (Odoo alignment) 'purchase', -- COR-001: Renombrado de 'confirmed' para alinear con Odoo '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, -- COR-010: Dirección de envío (dropship) dest_address_id UUID REFERENCES core.partners(id), -- COR-011: Bloqueo de orden locked BOOLEAN DEFAULT FALSE, -- COR-001: Campos de aprobación approval_required BOOLEAN DEFAULT FALSE, amount_approval_threshold DECIMAL(15, 2), -- 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), approved_at TIMESTAMP, -- COR-001 approved_by UUID REFERENCES auth.users(id), -- COR-001 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'; -- COR-009: Función de aprobación de órdenes de compra CREATE OR REPLACE FUNCTION purchase.button_approve(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_order RECORD; 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; -- Verificar estado válido para aprobación IF v_order.status != 'to_approve' THEN RAISE EXCEPTION 'Purchase order % is not in to_approve status', p_order_id; END IF; -- Verificar que no esté bloqueada IF v_order.locked THEN RAISE EXCEPTION 'Purchase order % is locked', p_order_id; END IF; -- Aprobar la orden UPDATE purchase.purchase_orders SET status = 'purchase', approved_at = CURRENT_TIMESTAMP, approved_by = get_current_user_id(), updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_order_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.button_approve IS 'COR-009: Aprueba una orden de compra en estado to_approve (Odoo alignment)'; -- COR-009: Función para enviar a aprobación CREATE OR REPLACE FUNCTION purchase.button_confirm(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_order RECORD; 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; -- Verificar estado válido IF v_order.status NOT IN ('draft', 'sent') THEN RAISE EXCEPTION 'Purchase order % cannot be confirmed from status %', p_order_id, v_order.status; END IF; -- Si requiere aprobación y supera threshold, enviar a aprobación IF v_order.approval_required AND v_order.amount_approval_threshold IS NOT NULL AND v_order.amount_total > v_order.amount_approval_threshold THEN UPDATE purchase.purchase_orders SET status = 'to_approve', updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_order_id; ELSE -- Confirmar directamente UPDATE purchase.purchase_orders SET status = 'purchase', confirmed_at = CURRENT_TIMESTAMP, confirmed_by = get_current_user_id(), updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_order_id; END IF; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.button_confirm IS 'COR-009: Confirma una orden de compra, enviando a aprobación si supera threshold'; -- ===================================================== -- 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'; -- ===================================================== -- COR-029: Purchase Order Functions -- Funciones de cancel y draft para PO -- ===================================================== -- Funcion: button_cancel CREATE OR REPLACE FUNCTION purchase.button_cancel(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_order RECORD; BEGIN 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; IF v_order.locked THEN RAISE EXCEPTION 'Cannot cancel locked order'; END IF; IF v_order.status = 'cancelled' THEN RETURN; END IF; -- Cancel related pickings UPDATE inventory.pickings SET status = 'cancelled' WHERE origin_document_type = 'purchase_order' AND origin_document_id = p_order_id AND status != 'done'; -- Update order status UPDATE purchase.purchase_orders SET status = 'cancelled', updated_at = NOW() WHERE id = p_order_id; END; $$ LANGUAGE plpgsql; -- Funcion: button_draft CREATE OR REPLACE FUNCTION purchase.button_draft(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_order RECORD; BEGIN 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; IF v_order.status NOT IN ('cancelled', 'sent') THEN RAISE EXCEPTION 'Can only set to draft from cancelled or sent state'; END IF; UPDATE purchase.purchase_orders SET status = 'draft', updated_at = NOW() WHERE id = p_order_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.button_cancel IS 'COR-029: Cancel purchase order and related pickings'; COMMENT ON FUNCTION purchase.button_draft IS 'COR-029: Set purchase order back to draft state'; -- ===================================================== -- COR-045: Product Supplierinfo -- Equivalente a product.supplierinfo de Odoo -- ===================================================== CREATE TABLE purchase.product_supplierinfo ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID REFERENCES auth.companies(id) ON DELETE CASCADE, -- Producto y proveedor product_id UUID REFERENCES inventory.products(id) ON DELETE CASCADE, product_tmpl_id UUID, -- Para future product templates partner_id UUID NOT NULL REFERENCES core.partners(id), -- Referencia del proveedor product_name VARCHAR(255), -- Nombre del producto en catalogo proveedor product_code VARCHAR(100), -- Codigo del proveedor -- Precios price DECIMAL(20,6) NOT NULL DEFAULT 0, currency_id UUID REFERENCES core.currencies(id), -- Cantidades min_qty DECIMAL(20,6) DEFAULT 0, -- Cantidad minima -- Tiempos de entrega delay INTEGER DEFAULT 1, -- Dias de entrega -- Validez date_start DATE, date_end DATE, -- Secuencia para ordenar proveedores sequence INTEGER DEFAULT 1, -- Control is_active BOOLEAN DEFAULT TRUE, -- Auditoria created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_product_supplierinfo_tenant ON purchase.product_supplierinfo(tenant_id); CREATE INDEX idx_product_supplierinfo_product ON purchase.product_supplierinfo(product_id); CREATE INDEX idx_product_supplierinfo_partner ON purchase.product_supplierinfo(partner_id); CREATE INDEX idx_product_supplierinfo_sequence ON purchase.product_supplierinfo(sequence); -- RLS ALTER TABLE purchase.product_supplierinfo ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_product_supplierinfo ON purchase.product_supplierinfo USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); COMMENT ON TABLE purchase.product_supplierinfo IS 'COR-045: Product supplier info - Equivalent to product.supplierinfo'; -- ===================================================== -- COR-046: Purchase Order Additional Fields -- Campos adicionales para alinear con Odoo -- ===================================================== -- Agregar campos a purchase_orders ALTER TABLE purchase.purchase_orders ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id), ADD COLUMN IF NOT EXISTS incoterm_id UUID, -- FK to financial.incoterms ADD COLUMN IF NOT EXISTS incoterm_location VARCHAR(255), ADD COLUMN IF NOT EXISTS fiscal_position_id UUID, -- FK to financial.fiscal_positions ADD COLUMN IF NOT EXISTS origin VARCHAR(255), -- Documento origen ADD COLUMN IF NOT EXISTS date_planned TIMESTAMP WITH TIME ZONE, -- Fecha esperada receipt ADD COLUMN IF NOT EXISTS date_approve TIMESTAMP WITH TIME ZONE; -- Fecha de aprobacion -- Agregar campos a purchase_order_lines ALTER TABLE purchase.purchase_order_lines ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10, ADD COLUMN IF NOT EXISTS product_packaging_id UUID, -- FK future packaging table ADD COLUMN IF NOT EXISTS product_packaging_qty DECIMAL(20,6), ADD COLUMN IF NOT EXISTS qty_to_receive DECIMAL(20,6) GENERATED ALWAYS AS (quantity - qty_received) STORED, ADD COLUMN IF NOT EXISTS price_subtotal DECIMAL(20,6), -- Computed subtotal ADD COLUMN IF NOT EXISTS date_planned TIMESTAMP WITH TIME ZONE; CREATE INDEX idx_purchase_orders_user ON purchase.purchase_orders(user_id); CREATE INDEX idx_purchase_orders_origin ON purchase.purchase_orders(origin); COMMENT ON COLUMN purchase.purchase_orders.incoterm_id IS 'COR-046: Incoterm reference'; COMMENT ON COLUMN purchase.purchase_orders.origin IS 'COR-046: Source document reference'; -- ===================================================== -- COR-047: Purchase Order Confirm with Stock Move -- Funcion para confirmar PO y crear stock moves -- ===================================================== CREATE OR REPLACE FUNCTION purchase.action_create_stock_moves(p_order_id UUID) RETURNS UUID AS $$ DECLARE v_order RECORD; v_line RECORD; v_picking_id UUID; v_move_id UUID; v_location_supplier UUID; v_location_dest UUID; v_picking_type_id UUID; BEGIN -- Obtener 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 SELECT id INTO v_location_supplier FROM inventory.locations WHERE location_type = 'supplier' AND tenant_id = v_order.tenant_id LIMIT 1; SELECT id INTO v_location_dest FROM inventory.locations WHERE location_type = 'internal' AND tenant_id = v_order.tenant_id LIMIT 1; -- Obtener picking type de recepcion SELECT id INTO v_picking_type_id FROM inventory.picking_types WHERE code = 'incoming' AND tenant_id = v_order.tenant_id LIMIT 1; -- Crear picking si no existe IF v_order.picking_id IS NULL THEN INSERT INTO inventory.pickings ( tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, origin, scheduled_date, status ) VALUES ( v_order.tenant_id, v_order.company_id, 'IN/' || v_order.name, 'incoming', v_location_supplier, v_location_dest, v_order.partner_id, v_order.name, v_order.expected_date, 'draft' ) RETURNING id INTO v_picking_id; UPDATE purchase.purchase_orders SET picking_id = v_picking_id WHERE id = p_order_id; ELSE v_picking_id := v_order.picking_id; END IF; -- Crear stock moves para cada linea FOR v_line IN SELECT * FROM purchase.purchase_order_lines WHERE order_id = p_order_id LOOP INSERT INTO inventory.stock_moves ( tenant_id, picking_id, product_id, product_uom_id, product_qty, location_id, location_dest_id, origin, state, name ) VALUES ( v_order.tenant_id, v_picking_id, v_line.product_id, v_line.uom_id, v_line.quantity, v_location_supplier, v_location_dest, v_order.name, 'draft', v_line.description ) RETURNING id INTO v_move_id; END LOOP; RETURN v_picking_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION purchase.action_create_stock_moves IS 'COR-047: Create stock moves from confirmed PO'; -- ===================================================== -- FIN DEL SCHEMA PURCHASE -- =====================================================