erp-core/database/ddl/11-crm.sql
rckrdmrd 4c4e27d9ba feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:20 -06:00

995 lines
32 KiB
PL/PgSQL

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