-- ===================================================== -- SCHEMA: crm -- PROPOSITO: Customer Relationship Management -- MODULOS: MGN-CRM (CRM) -- FECHA: 2025-11-24 -- ===================================================== -- Crear schema CREATE SCHEMA IF NOT EXISTS crm; -- ===================================================== -- TYPES (ENUMs) -- ===================================================== CREATE TYPE crm.lead_status AS ENUM ( 'new', 'contacted', 'qualified', 'converted', 'lost' ); CREATE TYPE crm.opportunity_status AS ENUM ( 'open', 'won', 'lost' ); CREATE TYPE crm.activity_type AS ENUM ( 'call', 'email', 'meeting', 'task', 'note' ); CREATE TYPE crm.lead_source AS ENUM ( 'website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other' ); -- ===================================================== -- TABLES -- ===================================================== -- Tabla: lead_stages (Etapas del pipeline de leads) CREATE TABLE crm.lead_stages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, sequence INTEGER NOT NULL DEFAULT 10, is_won BOOLEAN DEFAULT FALSE, probability DECIMAL(5, 2) DEFAULT 0, requirements TEXT, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, name) ); -- Tabla: opportunity_stages (Etapas del pipeline de oportunidades) CREATE TABLE crm.opportunity_stages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, sequence INTEGER NOT NULL DEFAULT 10, is_won BOOLEAN DEFAULT FALSE, probability DECIMAL(5, 2) DEFAULT 0, requirements TEXT, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, name) ); -- Tabla: lost_reasons (Razones de perdida) CREATE TABLE crm.lost_reasons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, name) ); -- Tabla: leads (Prospectos/Leads) CREATE TABLE crm.leads ( 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, -- Numeracion name VARCHAR(255) NOT NULL, ref VARCHAR(100), -- Contacto contact_name VARCHAR(255), email VARCHAR(255), phone VARCHAR(50), mobile VARCHAR(50), website VARCHAR(255), -- Empresa del prospecto company_name VARCHAR(255), job_position VARCHAR(100), industry VARCHAR(100), employee_count VARCHAR(50), annual_revenue DECIMAL(15, 2), -- Direccion street VARCHAR(255), city VARCHAR(100), state VARCHAR(100), zip VARCHAR(20), country VARCHAR(100), -- Pipeline stage_id UUID REFERENCES crm.lead_stages(id), status crm.lead_status NOT NULL DEFAULT 'new', -- Asignacion user_id UUID REFERENCES auth.users(id), sales_team_id UUID REFERENCES sales.sales_teams(id), -- Origen source crm.lead_source, campaign_id UUID, -- Para futuro modulo marketing medium VARCHAR(100), -- Valoracion priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3), probability DECIMAL(5, 2) DEFAULT 0, expected_revenue DECIMAL(15, 2), -- Fechas date_open TIMESTAMP WITH TIME ZONE, date_closed TIMESTAMP WITH TIME ZONE, date_deadline DATE, date_last_activity TIMESTAMP WITH TIME ZONE, -- Conversion partner_id UUID REFERENCES core.partners(id), opportunity_id UUID, -- Se llena al convertir -- Perdida lost_reason_id UUID REFERENCES crm.lost_reasons(id), lost_notes TEXT, -- Notas description TEXT, notes TEXT, tags VARCHAR(255)[], -- Auditoria created_by UUID REFERENCES auth.users(id), updated_by UUID REFERENCES auth.users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Tabla: opportunities (Oportunidades de venta) CREATE TABLE crm.opportunities ( 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, -- Numeracion name VARCHAR(255) NOT NULL, ref VARCHAR(100), -- Cliente partner_id UUID NOT NULL REFERENCES core.partners(id), contact_name VARCHAR(255), email VARCHAR(255), phone VARCHAR(50), -- Pipeline stage_id UUID REFERENCES crm.opportunity_stages(id), status crm.opportunity_status NOT NULL DEFAULT 'open', -- Asignacion user_id UUID REFERENCES auth.users(id), sales_team_id UUID REFERENCES sales.sales_teams(id), -- Valoracion priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3), probability DECIMAL(5, 2) DEFAULT 0, expected_revenue DECIMAL(15, 2), recurring_revenue DECIMAL(15, 2), recurring_plan VARCHAR(50), -- Fechas date_deadline DATE, date_closed TIMESTAMP WITH TIME ZONE, date_last_activity TIMESTAMP WITH TIME ZONE, -- Origen (si viene de lead) lead_id UUID REFERENCES crm.leads(id), source crm.lead_source, campaign_id UUID, medium VARCHAR(100), -- Cierre lost_reason_id UUID REFERENCES crm.lost_reasons(id), lost_notes TEXT, -- Relaciones quotation_id UUID REFERENCES sales.quotations(id), order_id UUID REFERENCES sales.sales_orders(id), -- Notas description TEXT, notes TEXT, tags VARCHAR(255)[], -- Auditoria created_by UUID REFERENCES auth.users(id), updated_by UUID REFERENCES auth.users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Actualizar referencia circular en leads ALTER TABLE crm.leads ADD CONSTRAINT fk_leads_opportunity FOREIGN KEY (opportunity_id) REFERENCES crm.opportunities(id); -- Tabla: crm_activities (Actividades CRM) CREATE TABLE crm.activities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Referencia polimorfica res_model VARCHAR(100) NOT NULL, res_id UUID NOT NULL, -- Actividad activity_type crm.activity_type NOT NULL, summary VARCHAR(255), description TEXT, -- Fechas date_deadline DATE, date_done TIMESTAMP WITH TIME ZONE, -- Asignacion user_id UUID REFERENCES auth.users(id), assigned_to UUID REFERENCES auth.users(id), -- Estado done BOOLEAN DEFAULT FALSE, -- Auditoria created_by UUID REFERENCES auth.users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- ===================================================== -- INDEXES -- ===================================================== CREATE INDEX idx_lead_stages_tenant ON crm.lead_stages(tenant_id); CREATE INDEX idx_opportunity_stages_tenant ON crm.opportunity_stages(tenant_id); CREATE INDEX idx_lost_reasons_tenant ON crm.lost_reasons(tenant_id); CREATE INDEX idx_leads_tenant ON crm.leads(tenant_id); CREATE INDEX idx_leads_company ON crm.leads(company_id); CREATE INDEX idx_leads_status ON crm.leads(status); CREATE INDEX idx_leads_stage ON crm.leads(stage_id); CREATE INDEX idx_leads_user ON crm.leads(user_id); CREATE INDEX idx_leads_partner ON crm.leads(partner_id); CREATE INDEX idx_leads_email ON crm.leads(email); CREATE INDEX idx_opportunities_tenant ON crm.opportunities(tenant_id); CREATE INDEX idx_opportunities_company ON crm.opportunities(company_id); CREATE INDEX idx_opportunities_status ON crm.opportunities(status); CREATE INDEX idx_opportunities_stage ON crm.opportunities(stage_id); CREATE INDEX idx_opportunities_user ON crm.opportunities(user_id); CREATE INDEX idx_opportunities_partner ON crm.opportunities(partner_id); CREATE INDEX idx_crm_activities_tenant ON crm.activities(tenant_id); CREATE INDEX idx_crm_activities_model ON crm.activities(res_model, res_id); CREATE INDEX idx_crm_activities_user ON crm.activities(assigned_to); CREATE INDEX idx_crm_activities_deadline ON crm.activities(date_deadline); -- ===================================================== -- TRIGGERS -- ===================================================== CREATE TRIGGER update_lead_stages_timestamp BEFORE UPDATE ON crm.lead_stages FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); CREATE TRIGGER update_opportunity_stages_timestamp BEFORE UPDATE ON crm.opportunity_stages FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); CREATE TRIGGER update_leads_timestamp BEFORE UPDATE ON crm.leads FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); CREATE TRIGGER update_opportunities_timestamp BEFORE UPDATE ON crm.opportunities FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); CREATE TRIGGER update_crm_activities_timestamp BEFORE UPDATE ON crm.activities FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); -- ===================================================== -- ROW LEVEL SECURITY -- ===================================================== -- Habilitar RLS ALTER TABLE crm.lead_stages ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.opportunity_stages ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.lost_reasons ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.leads ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.opportunities ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.activities ENABLE ROW LEVEL SECURITY; -- PolĂ­ticas de aislamiento por tenant CREATE POLICY tenant_isolation_lead_stages ON crm.lead_stages USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_opportunity_stages ON crm.opportunity_stages USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_lost_reasons ON crm.lost_reasons USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_leads ON crm.leads USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_opportunities ON crm.opportunities USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_crm_activities ON crm.activities USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ===================================================== -- COR-014: Predictive Lead Scoring (PLS) -- Sistema de scoring predictivo para leads/oportunidades -- ===================================================== -- Tabla: lead_scoring_rules (Reglas de scoring) CREATE TABLE crm.lead_scoring_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, -- Tipo de regla rule_type VARCHAR(50) NOT NULL, -- field_value, activity, demographic, behavioral -- Condicion (JSON) -- Ejemplo: {"field": "industry", "operator": "equals", "value": "technology"} -- Ejemplo: {"field": "annual_revenue", "operator": "greater_than", "value": 1000000} condition JSONB NOT NULL, -- Puntuacion score_value INTEGER NOT NULL, -- Puede ser negativo para penalizaciones -- Peso para ML (0-1) weight DECIMAL(3, 2) DEFAULT 1.0, -- Control active BOOLEAN DEFAULT TRUE, sequence INTEGER DEFAULT 10, -- Auditoria created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_lead_scoring_rules_name_tenant UNIQUE (tenant_id, name), CONSTRAINT chk_lead_scoring_rules_weight CHECK (weight >= 0 AND weight <= 1) ); -- Tabla: lead_scoring_history (Historial de scoring) CREATE TABLE crm.lead_scoring_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Referencia al lead/oportunidad lead_id UUID REFERENCES crm.leads(id) ON DELETE CASCADE, opportunity_id UUID REFERENCES crm.opportunities(id) ON DELETE CASCADE, -- Scores score_before INTEGER, score_after INTEGER NOT NULL, score_delta INTEGER GENERATED ALWAYS AS (score_after - COALESCE(score_before, 0)) STORED, -- Regla aplicada (opcional) rule_id UUID REFERENCES crm.lead_scoring_rules(id), -- Razon del cambio reason VARCHAR(255), -- Auditoria created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT chk_lead_scoring_history_ref CHECK ( (lead_id IS NOT NULL AND opportunity_id IS NULL) OR (lead_id IS NULL AND opportunity_id IS NOT NULL) ) ); -- Agregar campos de scoring a leads ALTER TABLE crm.leads ADD COLUMN IF NOT EXISTS automated_score INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS manual_score_adjustment INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS total_score INTEGER GENERATED ALWAYS AS (automated_score + manual_score_adjustment) STORED, ADD COLUMN IF NOT EXISTS score_calculated_at TIMESTAMP WITH TIME ZONE, ADD COLUMN IF NOT EXISTS score_tier VARCHAR(20); -- hot, warm, cold -- Agregar campos de scoring a opportunities ALTER TABLE crm.opportunities ADD COLUMN IF NOT EXISTS automated_score INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS manual_score_adjustment INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS total_score INTEGER GENERATED ALWAYS AS (automated_score + manual_score_adjustment) STORED, ADD COLUMN IF NOT EXISTS score_calculated_at TIMESTAMP WITH TIME ZONE, ADD COLUMN IF NOT EXISTS score_tier VARCHAR(20); -- hot, warm, cold -- Indices para scoring CREATE INDEX idx_lead_scoring_rules_tenant ON crm.lead_scoring_rules(tenant_id); CREATE INDEX idx_lead_scoring_rules_active ON crm.lead_scoring_rules(active) WHERE active = TRUE; CREATE INDEX idx_lead_scoring_history_lead ON crm.lead_scoring_history(lead_id); CREATE INDEX idx_lead_scoring_history_opportunity ON crm.lead_scoring_history(opportunity_id); CREATE INDEX idx_leads_total_score ON crm.leads(total_score DESC); CREATE INDEX idx_leads_score_tier ON crm.leads(score_tier); CREATE INDEX idx_opportunities_total_score ON crm.opportunities(total_score DESC); -- RLS para scoring tables ALTER TABLE crm.lead_scoring_rules ENABLE ROW LEVEL SECURITY; ALTER TABLE crm.lead_scoring_history ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_lead_scoring_rules ON crm.lead_scoring_rules USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); CREATE POLICY tenant_isolation_lead_scoring_history ON crm.lead_scoring_history USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Funcion: calculate_lead_score CREATE OR REPLACE FUNCTION crm.calculate_lead_score(p_lead_id UUID) RETURNS INTEGER AS $$ DECLARE v_lead RECORD; v_rule RECORD; v_total_score INTEGER := 0; v_condition JSONB; v_field_value TEXT; v_matches BOOLEAN; BEGIN -- Obtener lead SELECT * INTO v_lead FROM crm.leads WHERE id = p_lead_id; IF NOT FOUND THEN RAISE EXCEPTION 'Lead % not found', p_lead_id; END IF; -- Evaluar cada regla activa FOR v_rule IN SELECT * FROM crm.lead_scoring_rules WHERE tenant_id = v_lead.tenant_id AND active = TRUE ORDER BY sequence LOOP v_condition := v_rule.condition; v_matches := FALSE; -- Evaluar condicion basada en tipo de regla IF v_rule.rule_type = 'field_value' THEN -- Obtener valor del campo dinamicamente EXECUTE format('SELECT ($1).%I::TEXT', v_condition->>'field') INTO v_field_value USING v_lead; -- Evaluar operador CASE v_condition->>'operator' WHEN 'equals' THEN v_matches := v_field_value = (v_condition->>'value'); WHEN 'not_equals' THEN v_matches := v_field_value != (v_condition->>'value'); WHEN 'contains' THEN v_matches := v_field_value ILIKE '%' || (v_condition->>'value') || '%'; WHEN 'greater_than' THEN v_matches := v_field_value::NUMERIC > (v_condition->>'value')::NUMERIC; WHEN 'less_than' THEN v_matches := v_field_value::NUMERIC < (v_condition->>'value')::NUMERIC; ELSE v_matches := FALSE; END CASE; END IF; IF v_matches THEN v_total_score := v_total_score + v_rule.score_value; END IF; END LOOP; -- Actualizar lead con score UPDATE crm.leads SET automated_score = v_total_score, score_calculated_at = CURRENT_TIMESTAMP, score_tier = CASE WHEN v_total_score >= 80 THEN 'hot' WHEN v_total_score >= 40 THEN 'warm' ELSE 'cold' END WHERE id = p_lead_id; -- Registrar en historial INSERT INTO crm.lead_scoring_history (tenant_id, lead_id, score_before, score_after, reason) VALUES (v_lead.tenant_id, p_lead_id, v_lead.automated_score, v_total_score, 'Auto-calculated'); RETURN v_total_score; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.calculate_lead_score IS 'COR-014: Calcula el score de un lead basado en reglas de scoring activas'; -- ===================================================== -- COR-019: Auto-Assignment Rules -- Reglas de asignacion automatica de leads -- ===================================================== -- Tabla: lead_assignment_rules (Reglas de asignacion) CREATE TABLE crm.lead_assignment_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, description TEXT, -- Condiciones (JSON array) -- Ejemplo: [{"field": "source", "operator": "equals", "value": "website"}] conditions JSONB NOT NULL DEFAULT '[]', -- Asignacion assignment_type VARCHAR(20) NOT NULL, -- user, team, round_robin user_id UUID REFERENCES auth.users(id), sales_team_id UUID REFERENCES sales.sales_teams(id), -- Round-robin tracking last_assigned_user_id UUID REFERENCES auth.users(id), round_robin_users UUID[] DEFAULT '{}', -- Prioridad sequence INTEGER DEFAULT 10, -- Control active BOOLEAN DEFAULT TRUE, -- Auditoria created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_lead_assignment_rules_name_tenant UNIQUE (tenant_id, name), CONSTRAINT chk_assignment_type CHECK (assignment_type IN ('user', 'team', 'round_robin')) ); -- Indices para assignment rules CREATE INDEX idx_lead_assignment_rules_tenant ON crm.lead_assignment_rules(tenant_id); CREATE INDEX idx_lead_assignment_rules_active ON crm.lead_assignment_rules(active) WHERE active = TRUE; CREATE INDEX idx_lead_assignment_rules_sequence ON crm.lead_assignment_rules(sequence); -- RLS ALTER TABLE crm.lead_assignment_rules ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_lead_assignment_rules ON crm.lead_assignment_rules USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Funcion: auto_assign_lead CREATE OR REPLACE FUNCTION crm.auto_assign_lead(p_lead_id UUID) RETURNS UUID AS $$ DECLARE v_lead RECORD; v_rule RECORD; v_assigned_user_id UUID; v_matches BOOLEAN; v_condition JSONB; v_all_conditions_match BOOLEAN; v_next_user_idx INTEGER; BEGIN -- Obtener lead SELECT * INTO v_lead FROM crm.leads WHERE id = p_lead_id; IF NOT FOUND THEN RAISE EXCEPTION 'Lead % not found', p_lead_id; END IF; -- Si ya tiene usuario asignado, retornar IF v_lead.user_id IS NOT NULL THEN RETURN v_lead.user_id; END IF; -- Evaluar reglas en orden de prioridad FOR v_rule IN SELECT * FROM crm.lead_assignment_rules WHERE tenant_id = v_lead.tenant_id AND active = TRUE ORDER BY sequence LOOP v_all_conditions_match := TRUE; -- Evaluar todas las condiciones FOR v_condition IN SELECT * FROM jsonb_array_elements(v_rule.conditions) LOOP -- Simplificado: solo verificar igualdad EXECUTE format('SELECT ($1).%I::TEXT = $2', v_condition->>'field') INTO v_matches USING v_lead, v_condition->>'value'; IF NOT v_matches THEN v_all_conditions_match := FALSE; EXIT; END IF; END LOOP; IF v_all_conditions_match THEN -- Determinar usuario a asignar CASE v_rule.assignment_type WHEN 'user' THEN v_assigned_user_id := v_rule.user_id; WHEN 'team' THEN -- Asignar al lider del equipo SELECT team_leader_id INTO v_assigned_user_id FROM sales.sales_teams WHERE id = v_rule.sales_team_id; WHEN 'round_robin' THEN -- Obtener siguiente usuario en round-robin IF array_length(v_rule.round_robin_users, 1) > 0 THEN v_next_user_idx := 1; IF v_rule.last_assigned_user_id IS NOT NULL THEN FOR i IN 1..array_length(v_rule.round_robin_users, 1) LOOP IF v_rule.round_robin_users[i] = v_rule.last_assigned_user_id THEN v_next_user_idx := CASE WHEN i >= array_length(v_rule.round_robin_users, 1) THEN 1 ELSE i + 1 END; EXIT; END IF; END LOOP; END IF; v_assigned_user_id := v_rule.round_robin_users[v_next_user_idx]; -- Actualizar ultimo asignado UPDATE crm.lead_assignment_rules SET last_assigned_user_id = v_assigned_user_id WHERE id = v_rule.id; END IF; END CASE; -- Asignar lead IF v_assigned_user_id IS NOT NULL THEN UPDATE crm.leads SET user_id = v_assigned_user_id, sales_team_id = COALESCE(v_rule.sales_team_id, v_lead.sales_team_id) WHERE id = p_lead_id; RETURN v_assigned_user_id; END IF; END IF; END LOOP; RETURN NULL; -- No se encontro regla aplicable END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.auto_assign_lead IS 'COR-019: Asigna automaticamente un lead basado en reglas de asignacion'; -- ===================================================== -- COMMENTS -- ===================================================== COMMENT ON TABLE crm.lead_stages IS 'Etapas del pipeline de leads'; COMMENT ON TABLE crm.opportunity_stages IS 'Etapas del pipeline de oportunidades'; COMMENT ON TABLE crm.lost_reasons IS 'Razones de perdida de leads/oportunidades'; COMMENT ON TABLE crm.leads IS 'Prospectos/leads de ventas'; COMMENT ON TABLE crm.opportunities IS 'Oportunidades de venta'; COMMENT ON TABLE crm.activities IS 'Actividades CRM (llamadas, reuniones, etc.)'; COMMENT ON TABLE crm.lead_scoring_rules IS 'COR-014: Reglas de scoring predictivo para leads'; COMMENT ON TABLE crm.lead_scoring_history IS 'COR-014: Historial de cambios de score'; COMMENT ON TABLE crm.lead_assignment_rules IS 'COR-019: Reglas de asignacion automatica de leads'; -- ===================================================== -- COR-030: Merge Leads Function -- Equivalente a la funcionalidad de merge en Odoo CRM -- ===================================================== -- Agregar columna para tracking de leads fusionados ALTER TABLE crm.leads ADD COLUMN IF NOT EXISTS merged_into_id UUID REFERENCES crm.leads(id); -- Funcion: merge_leads CREATE OR REPLACE FUNCTION crm.merge_leads( p_lead_ids UUID[], p_target_lead_id UUID ) RETURNS UUID AS $$ DECLARE v_lead_id UUID; v_target RECORD; BEGIN -- Validar target existe SELECT * INTO v_target FROM crm.leads WHERE id = p_target_lead_id; IF NOT FOUND THEN RAISE EXCEPTION 'Target lead % not found', p_target_lead_id; END IF; -- Fusionar leads FOREACH v_lead_id IN ARRAY p_lead_ids LOOP IF v_lead_id != p_target_lead_id THEN -- Mover actividades al target UPDATE crm.activities SET lead_id = p_target_lead_id WHERE lead_id = v_lead_id; -- Acumular expected revenue UPDATE crm.leads t SET expected_revenue = t.expected_revenue + COALESCE( (SELECT expected_revenue FROM crm.leads WHERE id = v_lead_id), 0 ) WHERE t.id = p_target_lead_id; -- Marcar como fusionado (soft delete) UPDATE crm.leads SET is_deleted = TRUE, merged_into_id = p_target_lead_id, updated_at = NOW() WHERE id = v_lead_id; END IF; END LOOP; RETURN p_target_lead_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.merge_leads IS 'COR-030: Merge multiple leads into one target lead'; -- ===================================================== -- COR-051: Convert Lead to Opportunity -- Equivalente a convert_opportunity de Odoo -- ===================================================== CREATE OR REPLACE FUNCTION crm.convert_lead_to_opportunity( p_lead_id UUID, p_partner_id UUID DEFAULT NULL, p_create_partner BOOLEAN DEFAULT TRUE ) RETURNS UUID AS $$ DECLARE v_lead RECORD; v_opportunity_id UUID; v_partner_id UUID; BEGIN -- Obtener lead SELECT * INTO v_lead FROM crm.leads WHERE id = p_lead_id; IF NOT FOUND THEN RAISE EXCEPTION 'Lead % not found', p_lead_id; END IF; IF v_lead.status = 'converted' THEN RAISE EXCEPTION 'Lead % is already converted', p_lead_id; END IF; -- Determinar partner IF p_partner_id IS NOT NULL THEN v_partner_id := p_partner_id; ELSIF v_lead.partner_id IS NOT NULL THEN v_partner_id := v_lead.partner_id; ELSIF p_create_partner THEN -- Crear partner desde datos del lead INSERT INTO core.partners ( tenant_id, company_id, name, email, phone, mobile, website, street, city, state, zip, country, is_customer, is_company ) VALUES ( v_lead.tenant_id, v_lead.company_id, COALESCE(v_lead.company_name, v_lead.contact_name, v_lead.name), v_lead.email, v_lead.phone, v_lead.mobile, v_lead.website, v_lead.street, v_lead.city, v_lead.state, v_lead.zip, v_lead.country, TRUE, v_lead.company_name IS NOT NULL ) RETURNING id INTO v_partner_id; ELSE RAISE EXCEPTION 'No partner specified and create_partner is false'; END IF; -- Crear oportunidad INSERT INTO crm.opportunities ( tenant_id, company_id, name, ref, partner_id, contact_name, email, phone, stage_id, status, user_id, sales_team_id, priority, probability, expected_revenue, date_deadline, lead_id, source, campaign_id, medium, description, notes, tags, created_by ) VALUES ( v_lead.tenant_id, v_lead.company_id, v_lead.name, v_lead.ref, v_partner_id, v_lead.contact_name, v_lead.email, v_lead.phone, (SELECT id FROM crm.opportunity_stages WHERE tenant_id = v_lead.tenant_id ORDER BY sequence LIMIT 1), 'open', v_lead.user_id, v_lead.sales_team_id, v_lead.priority, v_lead.probability, v_lead.expected_revenue, v_lead.date_deadline, p_lead_id, v_lead.source, v_lead.campaign_id, v_lead.medium, v_lead.description, v_lead.notes, v_lead.tags, v_lead.created_by ) RETURNING id INTO v_opportunity_id; -- Actualizar lead UPDATE crm.leads SET status = 'converted', partner_id = v_partner_id, opportunity_id = v_opportunity_id, date_closed = NOW(), updated_at = NOW() WHERE id = p_lead_id; -- Mover actividades UPDATE crm.activities SET res_model = 'crm.opportunities', res_id = v_opportunity_id WHERE res_model = 'crm.leads' AND res_id = p_lead_id; RETURN v_opportunity_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.convert_lead_to_opportunity IS 'COR-051: Convert lead to opportunity with optional partner creation'; -- ===================================================== -- COR-052: Lead/Opportunity Additional Fields -- Campos adicionales para alinear con Odoo -- ===================================================== -- Agregar campos a leads ALTER TABLE crm.leads ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS color INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS referred VARCHAR(255), -- Referido por ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'lead', -- lead, opportunity ADD COLUMN IF NOT EXISTS day_open INTEGER, -- Dias desde apertura ADD COLUMN IF NOT EXISTS day_close INTEGER, -- Dias para cierre ADD COLUMN IF NOT EXISTS planned_revenue DECIMAL(20,6), ADD COLUMN IF NOT EXISTS date_conversion TIMESTAMP WITH TIME ZONE, -- Fecha de conversion ADD COLUMN IF NOT EXISTS date_action DATE, -- Proxima accion ADD COLUMN IF NOT EXISTS title_action VARCHAR(255); -- Titulo proxima accion -- Agregar campos a opportunities ALTER TABLE crm.opportunities ADD COLUMN IF NOT EXISTS color INTEGER DEFAULT 0, ADD COLUMN IF NOT EXISTS is_won BOOLEAN DEFAULT FALSE, ADD COLUMN IF NOT EXISTS referred VARCHAR(255), ADD COLUMN IF NOT EXISTS day_open INTEGER, ADD COLUMN IF NOT EXISTS day_close INTEGER, ADD COLUMN IF NOT EXISTS date_action DATE, ADD COLUMN IF NOT EXISTS title_action VARCHAR(255), ADD COLUMN IF NOT EXISTS prorated_revenue DECIMAL(20,6), -- Revenue * probability ADD COLUMN IF NOT EXISTS company_currency_id UUID REFERENCES core.currencies(id); CREATE INDEX idx_leads_is_deleted ON crm.leads(is_deleted) WHERE is_deleted = FALSE; CREATE INDEX idx_leads_type ON crm.leads(type); CREATE INDEX idx_opportunities_is_won ON crm.opportunities(is_won); -- ===================================================== -- COR-053: Mark Lead/Opportunity as Lost -- Funcion para marcar como perdido -- ===================================================== CREATE OR REPLACE FUNCTION crm.action_set_lost( p_model VARCHAR, p_id UUID, p_lost_reason_id UUID, p_lost_notes TEXT DEFAULT NULL ) RETURNS VOID AS $$ BEGIN IF p_model = 'lead' THEN UPDATE crm.leads SET status = 'lost', lost_reason_id = p_lost_reason_id, lost_notes = p_lost_notes, date_closed = NOW(), updated_at = NOW() WHERE id = p_id; ELSIF p_model = 'opportunity' THEN UPDATE crm.opportunities SET status = 'lost', lost_reason_id = p_lost_reason_id, lost_notes = p_lost_notes, date_closed = NOW(), is_won = FALSE, updated_at = NOW() WHERE id = p_id; ELSE RAISE EXCEPTION 'Invalid model: %', p_model; END IF; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.action_set_lost IS 'COR-053: Mark lead or opportunity as lost'; -- ===================================================== -- COR-054: Mark Opportunity as Won -- Funcion para marcar como ganado -- ===================================================== CREATE OR REPLACE FUNCTION crm.action_set_won(p_opportunity_id UUID) RETURNS VOID AS $$ DECLARE v_opportunity RECORD; v_won_stage_id UUID; BEGIN SELECT * INTO v_opportunity FROM crm.opportunities WHERE id = p_opportunity_id; IF NOT FOUND THEN RAISE EXCEPTION 'Opportunity % not found', p_opportunity_id; END IF; -- Obtener etapa de ganado SELECT id INTO v_won_stage_id FROM crm.opportunity_stages WHERE tenant_id = v_opportunity.tenant_id AND is_won = TRUE ORDER BY sequence DESC LIMIT 1; UPDATE crm.opportunities SET status = 'won', is_won = TRUE, stage_id = COALESCE(v_won_stage_id, stage_id), probability = 100, date_closed = NOW(), updated_at = NOW() WHERE id = p_opportunity_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION crm.action_set_won IS 'COR-054: Mark opportunity as won'; -- ===================================================== -- COR-055: CRM Tags -- Etiquetas para leads y oportunidades -- ===================================================== CREATE TABLE crm.tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, color INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, name) ); CREATE TABLE crm.lead_tag_rel ( lead_id UUID NOT NULL REFERENCES crm.leads(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES crm.tags(id) ON DELETE CASCADE, PRIMARY KEY (lead_id, tag_id) ); CREATE TABLE crm.opportunity_tag_rel ( opportunity_id UUID NOT NULL REFERENCES crm.opportunities(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES crm.tags(id) ON DELETE CASCADE, PRIMARY KEY (opportunity_id, tag_id) ); CREATE INDEX idx_crm_tags_tenant ON crm.tags(tenant_id); CREATE INDEX idx_lead_tag_rel_lead ON crm.lead_tag_rel(lead_id); CREATE INDEX idx_opportunity_tag_rel_opportunity ON crm.opportunity_tag_rel(opportunity_id); -- RLS ALTER TABLE crm.tags ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_crm_tags ON crm.tags USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); COMMENT ON TABLE crm.tags IS 'COR-055: CRM tags for leads and opportunities'; -- ===================================================== -- FIN DEL SCHEMA CRM -- =====================================================