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