-- ============================================================================ -- MIGRACIÓN: Sistema de Ranking de Partners (Clientes/Proveedores) -- Fecha: 2025-12-12 -- Descripción: Crea tablas y funciones para clasificación ABC de partners -- Impacto: Verticales que usan módulo de partners/ventas/compras -- Rollback: DROP TABLE y DROP FUNCTION incluidos al final -- ============================================================================ -- ============================================================================ -- 1. TABLA DE RANKINGS POR PERÍODO -- ============================================================================ CREATE TABLE IF NOT EXISTS core.partner_rankings ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, company_id UUID REFERENCES auth.companies(id) ON DELETE SET NULL, -- Período de análisis period_start DATE NOT NULL, period_end DATE NOT NULL, -- Métricas de Cliente total_sales DECIMAL(16,2) DEFAULT 0, sales_order_count INTEGER DEFAULT 0, avg_order_value DECIMAL(16,2) DEFAULT 0, -- Métricas de Proveedor total_purchases DECIMAL(16,2) DEFAULT 0, purchase_order_count INTEGER DEFAULT 0, avg_purchase_value DECIMAL(16,2) DEFAULT 0, -- Métricas de Pago avg_payment_days INTEGER, on_time_payment_rate DECIMAL(5,2), -- Porcentaje 0-100 -- Rankings (posición relativa dentro del período) sales_rank INTEGER, purchase_rank INTEGER, -- Clasificación ABC customer_abc CHAR(1) CHECK (customer_abc IN ('A', 'B', 'C', NULL)), supplier_abc CHAR(1) CHECK (supplier_abc IN ('A', 'B', 'C', NULL)), -- Scores calculados (0-100) customer_score DECIMAL(5,2) CHECK (customer_score IS NULL OR customer_score BETWEEN 0 AND 100), supplier_score DECIMAL(5,2) CHECK (supplier_score IS NULL OR supplier_score BETWEEN 0 AND 100), overall_score DECIMAL(5,2) CHECK (overall_score IS NULL OR overall_score BETWEEN 0 AND 100), -- Tendencia vs período anterior sales_trend DECIMAL(5,2), -- % cambio purchase_trend DECIMAL(5,2), -- Metadatos calculated_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(), -- Constraints UNIQUE(tenant_id, partner_id, company_id, period_start, period_end), CHECK (period_end >= period_start) ); -- ============================================================================ -- 2. CAMPOS DESNORMALIZADOS EN PARTNERS (para consultas rápidas) -- ============================================================================ DO $$ BEGIN -- Agregar columnas si no existen IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'customer_rank') THEN ALTER TABLE core.partners ADD COLUMN customer_rank INTEGER; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'supplier_rank') THEN ALTER TABLE core.partners ADD COLUMN supplier_rank INTEGER; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'customer_abc') THEN ALTER TABLE core.partners ADD COLUMN customer_abc CHAR(1); END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'supplier_abc') THEN ALTER TABLE core.partners ADD COLUMN supplier_abc CHAR(1); END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'last_ranking_date') THEN ALTER TABLE core.partners ADD COLUMN last_ranking_date DATE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'total_sales_ytd') THEN ALTER TABLE core.partners ADD COLUMN total_sales_ytd DECIMAL(16,2) DEFAULT 0; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'core' AND table_name = 'partners' AND column_name = 'total_purchases_ytd') THEN ALTER TABLE core.partners ADD COLUMN total_purchases_ytd DECIMAL(16,2) DEFAULT 0; END IF; END $$; -- ============================================================================ -- 3. ÍNDICES -- ============================================================================ CREATE INDEX IF NOT EXISTS idx_partner_rankings_tenant_period ON core.partner_rankings(tenant_id, period_start, period_end); CREATE INDEX IF NOT EXISTS idx_partner_rankings_partner ON core.partner_rankings(partner_id); CREATE INDEX IF NOT EXISTS idx_partner_rankings_abc ON core.partner_rankings(tenant_id, customer_abc) WHERE customer_abc IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_partners_customer_rank ON core.partners(tenant_id, customer_rank) WHERE customer_rank IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_partners_supplier_rank ON core.partners(tenant_id, supplier_rank) WHERE supplier_rank IS NOT NULL; -- ============================================================================ -- 4. RLS (Row Level Security) -- ============================================================================ ALTER TABLE core.partner_rankings ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS partner_rankings_tenant_isolation ON core.partner_rankings; CREATE POLICY partner_rankings_tenant_isolation ON core.partner_rankings USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ============================================================================ -- 5. FUNCIÓN: Calcular rankings de partners -- ============================================================================ CREATE OR REPLACE FUNCTION core.calculate_partner_rankings( p_tenant_id UUID, p_company_id UUID DEFAULT NULL, p_period_start DATE DEFAULT (CURRENT_DATE - INTERVAL '1 year')::date, p_period_end DATE DEFAULT CURRENT_DATE ) RETURNS TABLE ( partners_processed INTEGER, customers_ranked INTEGER, suppliers_ranked INTEGER ) AS $$ DECLARE v_partners_processed INTEGER := 0; v_customers_ranked INTEGER := 0; v_suppliers_ranked INTEGER := 0; BEGIN -- 1. Calcular métricas de ventas por partner INSERT INTO core.partner_rankings ( tenant_id, partner_id, company_id, period_start, period_end, total_sales, sales_order_count, avg_order_value ) SELECT p_tenant_id, so.partner_id, COALESCE(p_company_id, so.company_id), p_period_start, p_period_end, COALESCE(SUM(so.amount_total), 0), COUNT(*), COALESCE(AVG(so.amount_total), 0) FROM sales.sales_orders so WHERE so.tenant_id = p_tenant_id AND so.status IN ('sale', 'done') AND so.order_date BETWEEN p_period_start AND p_period_end AND (p_company_id IS NULL OR so.company_id = p_company_id) GROUP BY so.partner_id, so.company_id ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) DO UPDATE SET total_sales = EXCLUDED.total_sales, sales_order_count = EXCLUDED.sales_order_count, avg_order_value = EXCLUDED.avg_order_value, calculated_at = NOW(); GET DIAGNOSTICS v_customers_ranked = ROW_COUNT; -- 2. Calcular métricas de compras por partner INSERT INTO core.partner_rankings ( tenant_id, partner_id, company_id, period_start, period_end, total_purchases, purchase_order_count, avg_purchase_value ) SELECT p_tenant_id, po.partner_id, COALESCE(p_company_id, po.company_id), p_period_start, p_period_end, COALESCE(SUM(po.amount_total), 0), COUNT(*), COALESCE(AVG(po.amount_total), 0) FROM purchase.purchase_orders po WHERE po.tenant_id = p_tenant_id AND po.status IN ('confirmed', 'done') AND po.order_date BETWEEN p_period_start AND p_period_end AND (p_company_id IS NULL OR po.company_id = p_company_id) GROUP BY po.partner_id, po.company_id ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) DO UPDATE SET total_purchases = EXCLUDED.total_purchases, purchase_order_count = EXCLUDED.purchase_order_count, avg_purchase_value = EXCLUDED.avg_purchase_value, calculated_at = NOW(); GET DIAGNOSTICS v_suppliers_ranked = ROW_COUNT; -- 3. Calcular rankings de clientes (por total de ventas) WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY total_sales DESC) as rank, total_sales, SUM(total_sales) OVER () as grand_total, SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_total FROM core.partner_rankings WHERE tenant_id = p_tenant_id AND period_start = p_period_start AND period_end = p_period_end AND total_sales > 0 ) UPDATE core.partner_rankings pr SET sales_rank = r.rank, customer_abc = CASE WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' ELSE 'C' END, customer_score = CASE WHEN r.rank = 1 THEN 100 ELSE GREATEST(0, 100 - (r.rank - 1) * 5) END FROM ranked r WHERE pr.id = r.id; -- 4. Calcular rankings de proveedores (por total de compras) WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY total_purchases DESC) as rank, total_purchases, SUM(total_purchases) OVER () as grand_total, SUM(total_purchases) OVER (ORDER BY total_purchases DESC) as cumulative_total FROM core.partner_rankings WHERE tenant_id = p_tenant_id AND period_start = p_period_start AND period_end = p_period_end AND total_purchases > 0 ) UPDATE core.partner_rankings pr SET purchase_rank = r.rank, supplier_abc = CASE WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' ELSE 'C' END, supplier_score = CASE WHEN r.rank = 1 THEN 100 ELSE GREATEST(0, 100 - (r.rank - 1) * 5) END FROM ranked r WHERE pr.id = r.id; -- 5. Calcular score overall UPDATE core.partner_rankings SET overall_score = COALESCE( (COALESCE(customer_score, 0) + COALESCE(supplier_score, 0)) / NULLIF( CASE WHEN customer_score IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN supplier_score IS NOT NULL THEN 1 ELSE 0 END, 0 ), 0 ) WHERE tenant_id = p_tenant_id AND period_start = p_period_start AND period_end = p_period_end; -- 6. Actualizar campos desnormalizados en partners UPDATE core.partners p SET customer_rank = pr.sales_rank, supplier_rank = pr.purchase_rank, customer_abc = pr.customer_abc, supplier_abc = pr.supplier_abc, total_sales_ytd = pr.total_sales, total_purchases_ytd = pr.total_purchases, last_ranking_date = CURRENT_DATE FROM core.partner_rankings pr WHERE p.id = pr.partner_id AND p.tenant_id = p_tenant_id AND pr.tenant_id = p_tenant_id AND pr.period_start = p_period_start AND pr.period_end = p_period_end; GET DIAGNOSTICS v_partners_processed = ROW_COUNT; RETURN QUERY SELECT v_partners_processed, v_customers_ranked, v_suppliers_ranked; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION core.calculate_partner_rankings IS 'Calcula rankings ABC de partners basado en ventas/compras. Parámetros: - p_tenant_id: Tenant obligatorio - p_company_id: Opcional, filtrar por empresa - p_period_start: Inicio del período (default: hace 1 año) - p_period_end: Fin del período (default: hoy)'; -- ============================================================================ -- 6. VISTA: Top Partners -- ============================================================================ CREATE OR REPLACE VIEW core.top_partners_view AS SELECT p.id, p.tenant_id, p.name, p.email, p.is_customer, p.is_supplier, p.customer_rank, p.supplier_rank, p.customer_abc, p.supplier_abc, p.total_sales_ytd, p.total_purchases_ytd, p.last_ranking_date, CASE WHEN p.customer_abc = 'A' THEN 'Cliente VIP' WHEN p.customer_abc = 'B' THEN 'Cliente Regular' WHEN p.customer_abc = 'C' THEN 'Cliente Ocasional' ELSE NULL END as customer_category, CASE WHEN p.supplier_abc = 'A' THEN 'Proveedor Estratégico' WHEN p.supplier_abc = 'B' THEN 'Proveedor Regular' WHEN p.supplier_abc = 'C' THEN 'Proveedor Ocasional' ELSE NULL END as supplier_category FROM core.partners p WHERE p.deleted_at IS NULL AND (p.customer_rank IS NOT NULL OR p.supplier_rank IS NOT NULL); -- ============================================================================ -- ROLLBACK SCRIPT -- ============================================================================ /* DROP VIEW IF EXISTS core.top_partners_view; DROP FUNCTION IF EXISTS core.calculate_partner_rankings(UUID, UUID, DATE, DATE); DROP TABLE IF EXISTS core.partner_rankings; ALTER TABLE core.partners DROP COLUMN IF EXISTS customer_rank, DROP COLUMN IF EXISTS supplier_rank, DROP COLUMN IF EXISTS customer_abc, DROP COLUMN IF EXISTS supplier_abc, DROP COLUMN IF EXISTS last_ranking_date, DROP COLUMN IF EXISTS total_sales_ytd, DROP COLUMN IF EXISTS total_purchases_ytd; */ -- ============================================================================ -- VERIFICACIÓN -- ============================================================================ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'partner_rankings') THEN RAISE EXCEPTION 'Error: Tabla partner_rankings no fue creada'; END IF; RAISE NOTICE 'Migración completada exitosamente: Partner Rankings'; END $$;