erp-mecanicas-diesel-database/init/09-purchasing-schema.sql

532 lines
18 KiB
PL/PgSQL

-- ===========================================
-- MECANICAS DIESEL - Schema de Compras
-- ===========================================
-- Sistema de ordenes de compra, proveedores y recepciones
-- Gestion completa del ciclo de compras del taller
-- ============================================
-- SCHEMA: purchasing
-- ============================================
CREATE SCHEMA IF NOT EXISTS purchasing;
COMMENT ON SCHEMA purchasing IS 'Gestion de compras: ordenes de compra, recepciones, proveedores';
-- Grants
GRANT USAGE ON SCHEMA purchasing TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA purchasing GRANT ALL ON TABLES TO mecanicas_user;
-- ============================================
-- PROVEEDORES (complementa partners existentes)
-- ============================================
-- Extension de datos de proveedor
CREATE TABLE purchasing.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Datos basicos
code VARCHAR(20) NOT NULL,
name VARCHAR(256) NOT NULL,
trade_name VARCHAR(256), -- Nombre comercial
rfc VARCHAR(13),
-- Contacto
contact_name VARCHAR(256),
email VARCHAR(256),
phone VARCHAR(50),
mobile VARCHAR(50),
-- Direccion
street VARCHAR(256),
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
country VARCHAR(100) DEFAULT 'Mexico',
-- Datos comerciales
payment_term_days INTEGER DEFAULT 30,
credit_limit DECIMAL(20,6) DEFAULT 0,
currency_code VARCHAR(3) DEFAULT 'MXN',
-- Clasificacion
category VARCHAR(50), -- refacciones, lubricantes, herramientas, etc.
is_preferred BOOLEAN NOT NULL DEFAULT false,
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
-- Notas
notes TEXT,
-- Metadatos
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID,
CONSTRAINT uq_supplier_code UNIQUE(tenant_id, code)
);
COMMENT ON TABLE purchasing.suppliers IS 'Proveedores del taller';
-- Indices para suppliers
CREATE INDEX idx_suppliers_tenant ON purchasing.suppliers(tenant_id);
CREATE INDEX idx_suppliers_name ON purchasing.suppliers(name);
CREATE INDEX idx_suppliers_category ON purchasing.suppliers(category);
CREATE INDEX idx_suppliers_preferred ON purchasing.suppliers(is_preferred) WHERE is_preferred = true;
-- RLS para suppliers
SELECT create_tenant_rls_policies('purchasing', 'suppliers');
-- Trigger para updated_at
CREATE TRIGGER set_updated_at_suppliers
BEFORE UPDATE ON purchasing.suppliers
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- ORDENES DE COMPRA
-- ============================================
-- Estados de orden de compra
CREATE TYPE purchasing.po_status AS ENUM (
'draft', -- Borrador
'sent', -- Enviada a proveedor
'confirmed', -- Confirmada por proveedor
'partial', -- Parcialmente recibida
'received', -- Completamente recibida
'cancelled' -- Cancelada
);
-- Ordenes de compra
CREATE TABLE purchasing.purchase_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
order_number VARCHAR(50) NOT NULL,
reference VARCHAR(100), -- Referencia del proveedor
-- Proveedor
supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id),
-- Estado
status purchasing.po_status NOT NULL DEFAULT 'draft',
-- Fechas
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
expected_date DATE, -- Fecha esperada de entrega
received_date DATE, -- Fecha de recepcion completa
-- Importes
subtotal DECIMAL(20,6) NOT NULL DEFAULT 0,
discount_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
total DECIMAL(20,6) NOT NULL DEFAULT 0,
currency_code VARCHAR(3) DEFAULT 'MXN',
-- Urgencia (para taller)
priority VARCHAR(20) DEFAULT 'normal',
service_order_id UUID, -- Orden de servicio relacionada (si aplica)
-- Notas
notes TEXT,
internal_notes TEXT,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID NOT NULL,
confirmed_by UUID,
confirmed_at TIMESTAMPTZ,
CONSTRAINT uq_po_number UNIQUE(tenant_id, order_number),
CONSTRAINT chk_po_priority CHECK (priority IN ('low', 'normal', 'high', 'urgent'))
);
COMMENT ON TABLE purchasing.purchase_orders IS 'Ordenes de compra a proveedores';
COMMENT ON COLUMN purchasing.purchase_orders.service_order_id IS 'Orden de servicio que origino la compra (para urgencias)';
-- Indices para purchase_orders
CREATE INDEX idx_po_tenant ON purchasing.purchase_orders(tenant_id);
CREATE INDEX idx_po_supplier ON purchasing.purchase_orders(supplier_id);
CREATE INDEX idx_po_status ON purchasing.purchase_orders(status);
CREATE INDEX idx_po_date ON purchasing.purchase_orders(order_date DESC);
CREATE INDEX idx_po_expected ON purchasing.purchase_orders(expected_date) WHERE status NOT IN ('received', 'cancelled');
CREATE INDEX idx_po_service_order ON purchasing.purchase_orders(service_order_id) WHERE service_order_id IS NOT NULL;
-- RLS para purchase_orders
SELECT create_tenant_rls_policies('purchasing', 'purchase_orders');
-- Triggers
CREATE TRIGGER set_updated_at_purchase_orders
BEFORE UPDATE ON purchasing.purchase_orders
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- LINEAS DE ORDEN DE COMPRA
-- ============================================
CREATE TABLE purchasing.purchase_order_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id) ON DELETE CASCADE,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
part_id UUID, -- Referencia a parts_management.parts
product_code VARCHAR(50), -- Codigo del producto (desnormalizado)
description VARCHAR(500) NOT NULL,
-- Cantidades
quantity DECIMAL(20,6) NOT NULL,
unit_of_measure VARCHAR(20) DEFAULT 'PZA',
received_quantity DECIMAL(20,6) NOT NULL DEFAULT 0,
-- Precios
unit_price DECIMAL(20,6) NOT NULL,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
subtotal DECIMAL(20,6) NOT NULL,
tax_percent DECIMAL(5,2) NOT NULL DEFAULT 16.00, -- IVA Mexico
tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
total DECIMAL(20,6) NOT NULL,
-- Fechas
expected_date DATE,
-- Estado de linea
is_closed BOOLEAN NOT NULL DEFAULT false,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE purchasing.purchase_order_lines IS 'Lineas de detalle de ordenes de compra';
-- Indices para lines
CREATE INDEX idx_pol_order ON purchasing.purchase_order_lines(purchase_order_id);
CREATE INDEX idx_pol_part ON purchasing.purchase_order_lines(part_id) WHERE part_id IS NOT NULL;
CREATE INDEX idx_pol_pending ON purchasing.purchase_order_lines(purchase_order_id)
WHERE received_quantity < quantity AND is_closed = false;
-- ============================================
-- RECEPCIONES DE COMPRA
-- ============================================
CREATE TABLE purchasing.purchase_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
receipt_number VARCHAR(50) NOT NULL,
purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id),
-- Fecha y proveedor
receipt_date DATE NOT NULL DEFAULT CURRENT_DATE,
supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id),
-- Documentos del proveedor
supplier_invoice VARCHAR(50), -- Numero de factura proveedor
supplier_delivery_note VARCHAR(50), -- Remision del proveedor
-- Notas
notes TEXT,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
CONSTRAINT uq_receipt_number UNIQUE(tenant_id, receipt_number)
);
COMMENT ON TABLE purchasing.purchase_receipts IS 'Recepciones de mercancia de ordenes de compra';
-- Indices para receipts
CREATE INDEX idx_pr_tenant ON purchasing.purchase_receipts(tenant_id);
CREATE INDEX idx_pr_order ON purchasing.purchase_receipts(purchase_order_id);
CREATE INDEX idx_pr_date ON purchasing.purchase_receipts(receipt_date DESC);
-- RLS para receipts
SELECT create_tenant_rls_policies('purchasing', 'purchase_receipts');
-- Lineas de recepcion
CREATE TABLE purchasing.purchase_receipt_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receipt_id UUID NOT NULL REFERENCES purchasing.purchase_receipts(id) ON DELETE CASCADE,
order_line_id UUID NOT NULL REFERENCES purchasing.purchase_order_lines(id),
-- Cantidades
quantity_received DECIMAL(20,6) NOT NULL,
quantity_rejected DECIMAL(20,6) NOT NULL DEFAULT 0,
rejection_reason VARCHAR(256),
-- Lote/Serie (si aplica)
lot_number VARCHAR(100),
serial_numbers TEXT[],
-- Ubicacion destino
location_id UUID, -- Referencia a inventory location
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE purchasing.purchase_receipt_lines IS 'Detalle de productos recibidos';
CREATE INDEX idx_prl_receipt ON purchasing.purchase_receipt_lines(receipt_id);
CREATE INDEX idx_prl_order_line ON purchasing.purchase_receipt_lines(order_line_id);
-- ============================================
-- FUNCIONES AUXILIARES
-- ============================================
-- Funcion para generar numero de orden de compra
CREATE OR REPLACE FUNCTION purchasing.generate_po_number(p_tenant_id UUID)
RETURNS VARCHAR(50) AS $$
DECLARE
v_year TEXT;
v_sequence INTEGER;
v_number VARCHAR(50);
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
-- Obtener siguiente secuencia del año
SELECT COALESCE(MAX(
CAST(SUBSTRING(order_number FROM 'OC-' || v_year || '-(\d+)') AS INTEGER)
), 0) + 1
INTO v_sequence
FROM purchasing.purchase_orders
WHERE tenant_id = p_tenant_id
AND order_number LIKE 'OC-' || v_year || '-%';
v_number := 'OC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN v_number;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION purchasing.generate_po_number IS 'Genera numero secuencial de orden de compra (OC-YYYY-NNNNN)';
-- Funcion para generar numero de recepcion
CREATE OR REPLACE FUNCTION purchasing.generate_receipt_number(p_tenant_id UUID)
RETURNS VARCHAR(50) AS $$
DECLARE
v_year TEXT;
v_sequence INTEGER;
v_number VARCHAR(50);
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
SELECT COALESCE(MAX(
CAST(SUBSTRING(receipt_number FROM 'REC-' || v_year || '-(\d+)') AS INTEGER)
), 0) + 1
INTO v_sequence
FROM purchasing.purchase_receipts
WHERE tenant_id = p_tenant_id
AND receipt_number LIKE 'REC-' || v_year || '-%';
v_number := 'REC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN v_number;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION purchasing.generate_receipt_number IS 'Genera numero secuencial de recepcion (REC-YYYY-NNNNN)';
-- Funcion para calcular totales de linea
CREATE OR REPLACE FUNCTION purchasing.calculate_line_totals()
RETURNS TRIGGER AS $$
BEGIN
-- Calcular subtotal (con descuento)
NEW.subtotal := NEW.quantity * NEW.unit_price * (1 - NEW.discount_percent / 100);
-- Calcular impuesto
NEW.tax_amount := NEW.subtotal * NEW.tax_percent / 100;
-- Calcular total
NEW.total := NEW.subtotal + NEW.tax_amount;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER calculate_pol_totals
BEFORE INSERT OR UPDATE OF quantity, unit_price, discount_percent, tax_percent
ON purchasing.purchase_order_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.calculate_line_totals();
-- Funcion para actualizar totales de orden
CREATE OR REPLACE FUNCTION purchasing.update_order_totals()
RETURNS TRIGGER AS $$
BEGIN
UPDATE purchasing.purchase_orders po
SET
subtotal = COALESCE((
SELECT SUM(subtotal) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
tax_amount = COALESCE((
SELECT SUM(tax_amount) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
total = COALESCE((
SELECT SUM(total) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
updated_at = NOW()
WHERE id = COALESCE(NEW.purchase_order_id, OLD.purchase_order_id);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_po_totals
AFTER INSERT OR UPDATE OR DELETE
ON purchasing.purchase_order_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.update_order_totals();
-- Funcion para actualizar cantidades recibidas
CREATE OR REPLACE FUNCTION purchasing.update_received_quantities()
RETURNS TRIGGER AS $$
DECLARE
v_order_id UUID;
v_total_lines INTEGER;
v_received_lines INTEGER;
BEGIN
-- Actualizar cantidad recibida en linea de orden
UPDATE purchasing.purchase_order_lines pol
SET received_quantity = COALESCE((
SELECT SUM(prl.quantity_received)
FROM purchasing.purchase_receipt_lines prl
WHERE prl.order_line_id = pol.id
), 0)
WHERE id = NEW.order_line_id;
-- Obtener orden de compra
SELECT purchase_order_id INTO v_order_id
FROM purchasing.purchase_order_lines
WHERE id = NEW.order_line_id;
-- Verificar si toda la orden fue recibida
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE received_quantity >= quantity)
INTO v_total_lines, v_received_lines
FROM purchasing.purchase_order_lines
WHERE purchase_order_id = v_order_id;
-- Actualizar estado de la orden
UPDATE purchasing.purchase_orders
SET status = CASE
WHEN v_received_lines = v_total_lines THEN 'received'::purchasing.po_status
WHEN v_received_lines > 0 THEN 'partial'::purchasing.po_status
ELSE status
END,
received_date = CASE
WHEN v_received_lines = v_total_lines THEN CURRENT_DATE
ELSE received_date
END,
updated_at = NOW()
WHERE id = v_order_id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_po_received
AFTER INSERT
ON purchasing.purchase_receipt_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.update_received_quantities();
-- ============================================
-- VISTAS UTILES
-- ============================================
-- Vista de ordenes de compra pendientes
CREATE VIEW purchasing.v_pending_orders AS
SELECT
po.id,
po.tenant_id,
po.order_number,
po.status,
po.order_date,
po.expected_date,
s.name as supplier_name,
s.contact_name,
s.phone as supplier_phone,
po.total,
po.priority,
po.service_order_id,
CASE
WHEN po.expected_date < CURRENT_DATE THEN 'overdue'
WHEN po.expected_date = CURRENT_DATE THEN 'today'
WHEN po.expected_date <= CURRENT_DATE + 3 THEN 'soon'
ELSE 'normal'
END as urgency,
COUNT(pol.id) as line_count,
SUM(CASE WHEN pol.received_quantity < pol.quantity THEN 1 ELSE 0 END) as pending_lines
FROM purchasing.purchase_orders po
JOIN purchasing.suppliers s ON s.id = po.supplier_id
LEFT JOIN purchasing.purchase_order_lines pol ON pol.purchase_order_id = po.id
WHERE po.status NOT IN ('received', 'cancelled')
GROUP BY po.id, po.tenant_id, po.order_number, po.status, po.order_date,
po.expected_date, s.name, s.contact_name, s.phone, po.total,
po.priority, po.service_order_id
ORDER BY po.expected_date ASC NULLS LAST, po.priority DESC;
COMMENT ON VIEW purchasing.v_pending_orders IS 'Ordenes de compra pendientes de recibir';
-- Vista de productos pendientes de recibir
CREATE VIEW purchasing.v_pending_products AS
SELECT
po.tenant_id,
pol.part_id,
pol.product_code,
pol.description,
po.order_number,
po.supplier_id,
s.name as supplier_name,
pol.quantity,
pol.received_quantity,
pol.quantity - pol.received_quantity as pending_quantity,
pol.unit_price,
po.expected_date,
po.service_order_id
FROM purchasing.purchase_order_lines pol
JOIN purchasing.purchase_orders po ON po.id = pol.purchase_order_id
JOIN purchasing.suppliers s ON s.id = po.supplier_id
WHERE pol.received_quantity < pol.quantity
AND pol.is_closed = false
AND po.status NOT IN ('cancelled')
ORDER BY po.expected_date ASC NULLS LAST;
COMMENT ON VIEW purchasing.v_pending_products IS 'Productos pendientes de recibir por orden';
-- Vista de historial de compras por proveedor
CREATE VIEW purchasing.v_supplier_history AS
SELECT
s.id as supplier_id,
s.tenant_id,
s.code as supplier_code,
s.name as supplier_name,
s.category,
s.rating,
COUNT(DISTINCT po.id) as total_orders,
COUNT(DISTINCT po.id) FILTER (WHERE po.status = 'received') as completed_orders,
SUM(po.total) FILTER (WHERE po.status = 'received') as total_purchased,
AVG(po.received_date - po.expected_date) FILTER (WHERE po.status = 'received' AND po.expected_date IS NOT NULL) as avg_delivery_days,
MAX(po.order_date) as last_order_date
FROM purchasing.suppliers s
LEFT JOIN purchasing.purchase_orders po ON po.supplier_id = s.id
WHERE s.is_active = true
GROUP BY s.id, s.tenant_id, s.code, s.name, s.category, s.rating
ORDER BY total_purchased DESC NULLS LAST;
COMMENT ON VIEW purchasing.v_supplier_history IS 'Historial y estadisticas de compras por proveedor';
-- ============================================
-- GRANTS ADICIONALES
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA purchasing TO mecanicas_user;
GRANT SELECT ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user;