- Create sales schema (00-schema.sql) - Add sales enums (01-enums.sql): lead_status, lead_source, opportunity_stage, activity_type, activity_status - Add tables (02-tables.sql): pipeline_stages, leads, opportunities, activities - Add functions (03-functions.sql): convert_lead_to_opportunity, update_opportunity_stage, calculate_lead_score, get_pipeline_summary, initialize_default_stages - Add RLS policies (04-rls.sql) for tenant isolation - Add indexes (05-indexes.sql) for performance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
253 lines
7.2 KiB
PL/PgSQL
253 lines
7.2 KiB
PL/PgSQL
-- ============================================
|
|
-- TEMPLATE-SAAS: Sales Functions
|
|
-- Version: 1.0.0
|
|
-- Module: SAAS-018
|
|
-- ============================================
|
|
|
|
-- ============================================
|
|
-- Function: Convert Lead to Opportunity
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION sales.convert_lead_to_opportunity(
|
|
p_lead_id UUID,
|
|
p_opportunity_name VARCHAR(255) DEFAULT NULL,
|
|
p_amount DECIMAL(15, 2) DEFAULT 0,
|
|
p_expected_close_date DATE DEFAULT NULL
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_lead RECORD;
|
|
v_opportunity_id UUID;
|
|
v_tenant_id UUID;
|
|
BEGIN
|
|
-- Get lead data
|
|
SELECT * INTO v_lead
|
|
FROM sales.leads
|
|
WHERE id = p_lead_id
|
|
AND deleted_at IS NULL
|
|
AND status != 'converted';
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Lead not found or already converted';
|
|
END IF;
|
|
|
|
v_tenant_id := v_lead.tenant_id;
|
|
|
|
-- Create opportunity
|
|
INSERT INTO sales.opportunities (
|
|
tenant_id,
|
|
name,
|
|
lead_id,
|
|
stage,
|
|
amount,
|
|
currency,
|
|
expected_close_date,
|
|
assigned_to,
|
|
contact_name,
|
|
contact_email,
|
|
contact_phone,
|
|
company_name,
|
|
notes,
|
|
created_by
|
|
) VALUES (
|
|
v_tenant_id,
|
|
COALESCE(p_opportunity_name, v_lead.company || ' - ' || v_lead.first_name || ' ' || v_lead.last_name),
|
|
p_lead_id,
|
|
'prospecting',
|
|
COALESCE(p_amount, 0),
|
|
'USD',
|
|
COALESCE(p_expected_close_date, CURRENT_DATE + INTERVAL '30 days'),
|
|
v_lead.assigned_to,
|
|
v_lead.first_name || ' ' || v_lead.last_name,
|
|
v_lead.email,
|
|
v_lead.phone,
|
|
v_lead.company,
|
|
v_lead.notes,
|
|
v_lead.created_by
|
|
)
|
|
RETURNING id INTO v_opportunity_id;
|
|
|
|
-- Update lead as converted
|
|
UPDATE sales.leads
|
|
SET status = 'converted',
|
|
converted_at = NOW(),
|
|
converted_to_opportunity_id = v_opportunity_id
|
|
WHERE id = p_lead_id;
|
|
|
|
RETURN v_opportunity_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION sales.convert_lead_to_opportunity IS 'Converts a lead to an opportunity, copying relevant data';
|
|
|
|
-- ============================================
|
|
-- Function: Update Opportunity Stage
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION sales.update_opportunity_stage(
|
|
p_opportunity_id UUID,
|
|
p_new_stage sales.opportunity_stage,
|
|
p_notes TEXT DEFAULT NULL
|
|
)
|
|
RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
v_opportunity RECORD;
|
|
BEGIN
|
|
-- Get current opportunity
|
|
SELECT * INTO v_opportunity
|
|
FROM sales.opportunities
|
|
WHERE id = p_opportunity_id
|
|
AND deleted_at IS NULL;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Opportunity not found';
|
|
END IF;
|
|
|
|
-- Update stage
|
|
UPDATE sales.opportunities
|
|
SET stage = p_new_stage,
|
|
notes = CASE
|
|
WHEN p_notes IS NOT NULL THEN COALESCE(notes, '') || E'\n\n[Stage Change] ' || p_notes
|
|
ELSE notes
|
|
END,
|
|
won_at = CASE WHEN p_new_stage = 'closed_won' THEN NOW() ELSE won_at END,
|
|
lost_at = CASE WHEN p_new_stage = 'closed_lost' THEN NOW() ELSE lost_at END,
|
|
actual_close_date = CASE
|
|
WHEN p_new_stage IN ('closed_won', 'closed_lost') THEN CURRENT_DATE
|
|
ELSE actual_close_date
|
|
END
|
|
WHERE id = p_opportunity_id;
|
|
|
|
RETURN TRUE;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION sales.update_opportunity_stage IS 'Updates opportunity stage with automatic win/loss tracking';
|
|
|
|
-- ============================================
|
|
-- Function: Calculate Lead Score
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION sales.calculate_lead_score(
|
|
p_lead_id UUID
|
|
)
|
|
RETURNS INT AS $$
|
|
DECLARE
|
|
v_lead RECORD;
|
|
v_score INT := 0;
|
|
v_activity_count INT;
|
|
BEGIN
|
|
-- Get lead data
|
|
SELECT * INTO v_lead
|
|
FROM sales.leads
|
|
WHERE id = p_lead_id
|
|
AND deleted_at IS NULL;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN 0;
|
|
END IF;
|
|
|
|
-- Base score for having email
|
|
IF v_lead.email IS NOT NULL THEN
|
|
v_score := v_score + 10;
|
|
END IF;
|
|
|
|
-- Score for phone
|
|
IF v_lead.phone IS NOT NULL THEN
|
|
v_score := v_score + 10;
|
|
END IF;
|
|
|
|
-- Score for company
|
|
IF v_lead.company IS NOT NULL THEN
|
|
v_score := v_score + 15;
|
|
END IF;
|
|
|
|
-- Score based on source
|
|
CASE v_lead.source
|
|
WHEN 'referral' THEN v_score := v_score + 20;
|
|
WHEN 'website' THEN v_score := v_score + 15;
|
|
WHEN 'event' THEN v_score := v_score + 10;
|
|
ELSE v_score := v_score + 5;
|
|
END CASE;
|
|
|
|
-- Score based on status
|
|
CASE v_lead.status
|
|
WHEN 'qualified' THEN v_score := v_score + 25;
|
|
WHEN 'contacted' THEN v_score := v_score + 15;
|
|
WHEN 'new' THEN v_score := v_score + 5;
|
|
ELSE NULL;
|
|
END CASE;
|
|
|
|
-- Score for activities
|
|
SELECT COUNT(*) INTO v_activity_count
|
|
FROM sales.activities
|
|
WHERE lead_id = p_lead_id
|
|
AND deleted_at IS NULL;
|
|
|
|
v_score := v_score + LEAST(v_activity_count * 5, 20);
|
|
|
|
-- Cap at 100
|
|
RETURN LEAST(v_score, 100);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION sales.calculate_lead_score IS 'Calculates a lead score based on completeness and engagement';
|
|
|
|
-- ============================================
|
|
-- Function: Get Pipeline Summary
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION sales.get_pipeline_summary(
|
|
p_tenant_id UUID
|
|
)
|
|
RETURNS TABLE (
|
|
stage sales.opportunity_stage,
|
|
count BIGINT,
|
|
total_amount DECIMAL(15, 2),
|
|
avg_probability NUMERIC
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
o.stage,
|
|
COUNT(*)::BIGINT,
|
|
COALESCE(SUM(o.amount), 0)::DECIMAL(15, 2),
|
|
COALESCE(AVG(o.probability), 0)::NUMERIC
|
|
FROM sales.opportunities o
|
|
WHERE o.tenant_id = p_tenant_id
|
|
AND o.deleted_at IS NULL
|
|
GROUP BY o.stage
|
|
ORDER BY
|
|
CASE o.stage
|
|
WHEN 'prospecting' THEN 1
|
|
WHEN 'qualification' THEN 2
|
|
WHEN 'proposal' THEN 3
|
|
WHEN 'negotiation' THEN 4
|
|
WHEN 'closed_won' THEN 5
|
|
WHEN 'closed_lost' THEN 6
|
|
END;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION sales.get_pipeline_summary IS 'Returns summary of opportunities by stage for a tenant';
|
|
|
|
-- ============================================
|
|
-- Function: Initialize Default Pipeline Stages
|
|
-- ============================================
|
|
CREATE OR REPLACE FUNCTION sales.initialize_default_stages(
|
|
p_tenant_id UUID
|
|
)
|
|
RETURNS VOID AS $$
|
|
BEGIN
|
|
-- Insert default stages if none exist
|
|
IF NOT EXISTS (SELECT 1 FROM sales.pipeline_stages WHERE tenant_id = p_tenant_id) THEN
|
|
INSERT INTO sales.pipeline_stages (tenant_id, name, position, color, is_won, is_lost)
|
|
VALUES
|
|
(p_tenant_id, 'Prospecting', 1, '#94A3B8', FALSE, FALSE),
|
|
(p_tenant_id, 'Qualification', 2, '#3B82F6', FALSE, FALSE),
|
|
(p_tenant_id, 'Proposal', 3, '#8B5CF6', FALSE, FALSE),
|
|
(p_tenant_id, 'Negotiation', 4, '#F59E0B', FALSE, FALSE),
|
|
(p_tenant_id, 'Closed Won', 5, '#10B981', TRUE, FALSE),
|
|
(p_tenant_id, 'Closed Lost', 6, '#EF4444', FALSE, TRUE);
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON FUNCTION sales.initialize_default_stages IS 'Creates default pipeline stages for a new tenant';
|