erp-core-database/ddl/06-purchase.sql

584 lines
20 KiB
PL/PgSQL

-- =====================================================
-- 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
-- =====================================================