296 lines
8.4 KiB
PL/PgSQL
296 lines
8.4 KiB
PL/PgSQL
-- =====================================================
|
|
-- Reporting & Analytics RPC Functions
|
|
--
|
|
-- Enterprise-grade reporting: demographics, retention,
|
|
-- geographic distribution, dues collection, membership
|
|
-- duration analysis.
|
|
-- =====================================================
|
|
|
|
-- 1. Age demographics by gender
|
|
CREATE OR REPLACE FUNCTION public.get_member_demographics(p_account_id uuid)
|
|
RETURNS TABLE (
|
|
age_group text,
|
|
male_count bigint,
|
|
female_count bigint,
|
|
diverse_count bigint,
|
|
unknown_count bigint,
|
|
total bigint
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
CASE
|
|
WHEN age < 18 THEN 'Unter 18'
|
|
WHEN age BETWEEN 18 AND 30 THEN '18-30'
|
|
WHEN age BETWEEN 31 AND 50 THEN '31-50'
|
|
WHEN age BETWEEN 51 AND 65 THEN '51-65'
|
|
WHEN age > 65 THEN 'Über 65'
|
|
ELSE 'Unbekannt'
|
|
END AS age_group,
|
|
count(*) FILTER (WHERE m.gender = 'male') AS male_count,
|
|
count(*) FILTER (WHERE m.gender = 'female') AS female_count,
|
|
count(*) FILTER (WHERE m.gender = 'diverse') AS diverse_count,
|
|
count(*) FILTER (WHERE m.gender IS NULL OR m.gender NOT IN ('male', 'female', 'diverse')) AS unknown_count,
|
|
count(*) AS total
|
|
FROM public.members m
|
|
LEFT JOIN LATERAL (
|
|
SELECT CASE
|
|
WHEN m.date_of_birth IS NOT NULL THEN
|
|
extract(year FROM age(current_date, m.date_of_birth))::int
|
|
ELSE NULL
|
|
END AS age
|
|
) ages ON true
|
|
WHERE m.account_id = p_account_id
|
|
AND m.status = 'active'
|
|
AND m.is_archived = false
|
|
GROUP BY age_group
|
|
ORDER BY
|
|
CASE age_group
|
|
WHEN 'Unter 18' THEN 1
|
|
WHEN '18-30' THEN 2
|
|
WHEN '31-50' THEN 3
|
|
WHEN '51-65' THEN 4
|
|
WHEN 'Über 65' THEN 5
|
|
ELSE 6
|
|
END;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_member_demographics(uuid) TO authenticated;
|
|
|
|
-- 2. Year-over-year membership retention
|
|
CREATE OR REPLACE FUNCTION public.get_member_retention(
|
|
p_account_id uuid,
|
|
p_years int DEFAULT 5
|
|
)
|
|
RETURNS TABLE (
|
|
year int,
|
|
members_start bigint,
|
|
new_members bigint,
|
|
resigned_members bigint,
|
|
members_end bigint,
|
|
retention_rate numeric
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
RETURN QUERY
|
|
WITH years AS (
|
|
SELECT generate_series(
|
|
extract(year FROM current_date)::int - p_years + 1,
|
|
extract(year FROM current_date)::int
|
|
) AS yr
|
|
),
|
|
stats AS (
|
|
SELECT
|
|
y.yr,
|
|
count(*) FILTER (WHERE m.entry_date < make_date(y.yr, 1, 1)
|
|
AND (m.exit_date IS NULL OR m.exit_date >= make_date(y.yr, 1, 1))) AS members_start,
|
|
count(*) FILTER (WHERE extract(year FROM m.entry_date) = y.yr) AS new_members,
|
|
count(*) FILTER (WHERE extract(year FROM m.exit_date) = y.yr) AS resigned_members,
|
|
count(*) FILTER (WHERE m.entry_date <= make_date(y.yr, 12, 31)
|
|
AND (m.exit_date IS NULL OR m.exit_date > make_date(y.yr, 12, 31))) AS members_end
|
|
FROM years y
|
|
CROSS JOIN public.members m
|
|
WHERE m.account_id = p_account_id AND m.is_archived = false
|
|
GROUP BY y.yr
|
|
)
|
|
SELECT
|
|
s.yr AS year,
|
|
s.members_start,
|
|
s.new_members,
|
|
s.resigned_members,
|
|
s.members_end,
|
|
CASE WHEN s.members_start > 0
|
|
THEN round((s.members_end::numeric / s.members_start) * 100, 1)
|
|
ELSE 0
|
|
END AS retention_rate
|
|
FROM stats s
|
|
ORDER BY s.yr;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_member_retention(uuid, int) TO authenticated;
|
|
|
|
-- 3. Geographic distribution by postal code prefix
|
|
CREATE OR REPLACE FUNCTION public.get_member_geographic_distribution(p_account_id uuid)
|
|
RETURNS TABLE (
|
|
postal_prefix text,
|
|
city text,
|
|
member_count bigint
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
CASE
|
|
WHEN m.postal_code IS NULL OR m.postal_code = '' THEN 'Keine Angabe'
|
|
ELSE left(m.postal_code, 2)
|
|
END AS postal_prefix,
|
|
COALESCE(NULLIF(m.city, ''), 'Keine Angabe') AS city,
|
|
count(*) AS member_count
|
|
FROM public.members m
|
|
WHERE m.account_id = p_account_id
|
|
AND m.status = 'active'
|
|
AND m.is_archived = false
|
|
GROUP BY postal_prefix, m.city
|
|
ORDER BY member_count DESC;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_member_geographic_distribution(uuid) TO authenticated;
|
|
|
|
-- 4. Dues collection rates by category
|
|
CREATE OR REPLACE FUNCTION public.get_dues_collection_report(p_account_id uuid)
|
|
RETURNS TABLE (
|
|
category_name text,
|
|
member_count bigint,
|
|
expected_amount numeric,
|
|
paid_count bigint,
|
|
collection_rate numeric
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
COALESCE(dc.name, 'Keine Kategorie') AS category_name,
|
|
count(m.id) AS member_count,
|
|
COALESCE(sum(dc.amount), 0) AS expected_amount,
|
|
count(*) FILTER (WHERE m.dues_paid = true) AS paid_count,
|
|
CASE WHEN count(m.id) > 0
|
|
THEN round((count(*) FILTER (WHERE m.dues_paid = true)::numeric / count(m.id)) * 100, 1)
|
|
ELSE 0
|
|
END AS collection_rate
|
|
FROM public.members m
|
|
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
|
|
WHERE m.account_id = p_account_id
|
|
AND m.status = 'active'
|
|
AND m.is_archived = false
|
|
GROUP BY dc.name, dc.sort_order
|
|
ORDER BY dc.sort_order NULLS LAST;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_dues_collection_report(uuid) TO authenticated;
|
|
|
|
-- 5. Membership duration analysis
|
|
CREATE OR REPLACE FUNCTION public.get_membership_duration_analysis(p_account_id uuid)
|
|
RETURNS TABLE (
|
|
duration_bucket text,
|
|
member_count bigint,
|
|
percentage numeric
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
v_total bigint;
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
SELECT count(*) INTO v_total
|
|
FROM public.members
|
|
WHERE account_id = p_account_id AND status = 'active' AND is_archived = false;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
CASE
|
|
WHEN years < 1 THEN 'Unter 1 Jahr'
|
|
WHEN years BETWEEN 1 AND 5 THEN '1-5 Jahre'
|
|
WHEN years BETWEEN 6 AND 10 THEN '6-10 Jahre'
|
|
WHEN years BETWEEN 11 AND 25 THEN '11-25 Jahre'
|
|
WHEN years > 25 THEN 'Über 25 Jahre'
|
|
ELSE 'Unbekannt'
|
|
END AS duration_bucket,
|
|
count(*) AS member_count,
|
|
CASE WHEN v_total > 0
|
|
THEN round((count(*)::numeric / v_total) * 100, 1)
|
|
ELSE 0
|
|
END AS percentage
|
|
FROM (
|
|
SELECT
|
|
CASE WHEN m.entry_date IS NOT NULL
|
|
THEN extract(year FROM age(current_date, m.entry_date))::int
|
|
ELSE NULL
|
|
END AS years
|
|
FROM public.members m
|
|
WHERE m.account_id = p_account_id
|
|
AND m.status = 'active'
|
|
AND m.is_archived = false
|
|
) sub
|
|
GROUP BY duration_bucket
|
|
ORDER BY
|
|
CASE duration_bucket
|
|
WHEN 'Unter 1 Jahr' THEN 1
|
|
WHEN '1-5 Jahre' THEN 2
|
|
WHEN '6-10 Jahre' THEN 3
|
|
WHEN '11-25 Jahre' THEN 4
|
|
WHEN 'Über 25 Jahre' THEN 5
|
|
ELSE 6
|
|
END;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_membership_duration_analysis(uuid) TO authenticated;
|
|
|
|
-- 6. Department distribution
|
|
CREATE OR REPLACE FUNCTION public.get_department_distribution(p_account_id uuid)
|
|
RETURNS TABLE (
|
|
department_name text,
|
|
member_count bigint,
|
|
percentage numeric
|
|
)
|
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
v_total bigint;
|
|
BEGIN
|
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
|
RAISE EXCEPTION 'Access denied';
|
|
END IF;
|
|
|
|
SELECT count(DISTINCT m.id) INTO v_total
|
|
FROM public.members m
|
|
WHERE m.account_id = p_account_id AND m.status = 'active' AND m.is_archived = false;
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
d.name AS department_name,
|
|
count(DISTINCT mda.member_id) AS member_count,
|
|
CASE WHEN v_total > 0
|
|
THEN round((count(DISTINCT mda.member_id)::numeric / v_total) * 100, 1)
|
|
ELSE 0
|
|
END AS percentage
|
|
FROM public.member_departments d
|
|
LEFT JOIN public.member_department_assignments mda ON mda.department_id = d.id
|
|
LEFT JOIN public.members m ON m.id = mda.member_id
|
|
AND m.status = 'active' AND m.is_archived = false
|
|
WHERE d.account_id = p_account_id
|
|
GROUP BY d.name, d.sort_order
|
|
ORDER BY member_count DESC;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_department_distribution(uuid) TO authenticated;
|