Reports migrations

This commit is contained in:
Marc Rejohn Castillano 2026-03-03 18:15:45 +08:00
parent 1e678ea2e5
commit bfcca47353

View File

@ -0,0 +1,382 @@
-- =============================================================
-- Reports Infrastructure: pg_trgm + Server-side RPC functions
-- =============================================================
-- All RPCs accept a date range (p_start, p_end) and use
-- SECURITY INVOKER so existing RLS policies apply.
-- =============================================================
-- Enable pg_trgm for fuzzy subject clustering
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN trigram indexes for subject similarity searches
CREATE INDEX IF NOT EXISTS idx_tickets_subject_trgm
ON tickets USING gin (lower(subject) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_tasks_title_trgm
ON tasks USING gin (lower(title) gin_trgm_ops);
-- ----- 1. Tickets by status -----
CREATE OR REPLACE FUNCTION report_tickets_by_status(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(status text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT t.status, count(*)
FROM tickets t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY t.status
ORDER BY count(*) DESC;
$$;
-- ----- 2. Tasks by status -----
CREATE OR REPLACE FUNCTION report_tasks_by_status(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(status text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT t.status::text, count(*)
FROM tasks t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY t.status
ORDER BY count(*) DESC;
$$;
-- ----- 3. Tasks created by hour of day -----
CREATE OR REPLACE FUNCTION report_tasks_by_hour(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(hour int, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Asia/Manila')::int AS hour,
count(*)
FROM tasks t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY hour
ORDER BY hour;
$$;
-- ----- 4. Tickets created by hour of day -----
CREATE OR REPLACE FUNCTION report_tickets_by_hour(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(hour int, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Asia/Manila')::int AS hour,
count(*)
FROM tickets t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY hour
ORDER BY hour;
$$;
-- ----- 5. Top offices by ticket count -----
CREATE OR REPLACE FUNCTION report_top_offices_tickets(
p_start timestamptz,
p_end timestamptz,
p_limit int DEFAULT 10
)
RETURNS TABLE(office_name text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT o.name AS office_name, count(*)
FROM tickets t
JOIN offices o ON o.id = t.office_id
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY o.name
ORDER BY count(*) DESC
LIMIT p_limit;
$$;
-- ----- 6. Top offices by task count -----
CREATE OR REPLACE FUNCTION report_top_offices_tasks(
p_start timestamptz,
p_end timestamptz,
p_limit int DEFAULT 10
)
RETURNS TABLE(office_name text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT o.name AS office_name, count(*)
FROM tasks t
JOIN offices o ON o.id = t.office_id
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY o.name
ORDER BY count(*) DESC
LIMIT p_limit;
$$;
-- ----- 7. Top ticket subjects (pg_trgm clustering) -----
CREATE OR REPLACE FUNCTION report_top_ticket_subjects(
p_start timestamptz,
p_end timestamptz,
p_limit int DEFAULT 10,
p_threshold float DEFAULT 0.4
)
RETURNS TABLE(subject_group text, ticket_count bigint)
LANGUAGE plpgsql STABLE SECURITY INVOKER
AS $$
DECLARE
rec RECORD;
cluster_leaders text[] := '{}';
cluster_counts bigint[] := '{}';
i int;
matched boolean;
best_idx int;
best_sim float;
cur_sim float;
BEGIN
-- Iterate subjects ordered by frequency (greedy clustering)
FOR rec IN
SELECT lower(trim(t.subject)) AS norm_subject, count(*) AS cnt
FROM tickets t
WHERE t.created_at >= p_start
AND t.created_at < p_end
AND t.subject IS NOT NULL
AND trim(t.subject) <> ''
GROUP BY norm_subject
ORDER BY cnt DESC
LOOP
matched := false;
best_idx := 0;
best_sim := 0;
FOR i IN 1..coalesce(array_length(cluster_leaders, 1), 0) LOOP
cur_sim := similarity(rec.norm_subject, cluster_leaders[i]);
IF cur_sim >= p_threshold AND cur_sim > best_sim THEN
best_sim := cur_sim;
best_idx := i;
matched := true;
END IF;
END LOOP;
IF matched THEN
cluster_counts[best_idx] := cluster_counts[best_idx] + rec.cnt;
ELSE
cluster_leaders := array_append(cluster_leaders, rec.norm_subject);
cluster_counts := array_append(cluster_counts, rec.cnt);
END IF;
END LOOP;
-- Return top clusters
RETURN QUERY
SELECT sub.cl AS subject_group, sub.cc AS ticket_count
FROM (
SELECT unnest(cluster_leaders) AS cl,
unnest(cluster_counts) AS cc
) sub
ORDER BY sub.cc DESC
LIMIT p_limit;
END;
$$;
-- ----- 8. Top task subjects (pg_trgm clustering) -----
CREATE OR REPLACE FUNCTION report_top_task_subjects(
p_start timestamptz,
p_end timestamptz,
p_limit int DEFAULT 10,
p_threshold float DEFAULT 0.4
)
RETURNS TABLE(subject_group text, task_count bigint)
LANGUAGE plpgsql STABLE SECURITY INVOKER
AS $$
DECLARE
rec RECORD;
cluster_leaders text[] := '{}';
cluster_counts bigint[] := '{}';
i int;
matched boolean;
best_idx int;
best_sim float;
cur_sim float;
BEGIN
FOR rec IN
SELECT lower(trim(t.title)) AS norm_title, count(*) AS cnt
FROM tasks t
WHERE t.created_at >= p_start
AND t.created_at < p_end
AND t.title IS NOT NULL
AND trim(t.title) <> ''
GROUP BY norm_title
ORDER BY cnt DESC
LOOP
matched := false;
best_idx := 0;
best_sim := 0;
FOR i IN 1..coalesce(array_length(cluster_leaders, 1), 0) LOOP
cur_sim := similarity(rec.norm_title, cluster_leaders[i]);
IF cur_sim >= p_threshold AND cur_sim > best_sim THEN
best_sim := cur_sim;
best_idx := i;
matched := true;
END IF;
END LOOP;
IF matched THEN
cluster_counts[best_idx] := cluster_counts[best_idx] + rec.cnt;
ELSE
cluster_leaders := array_append(cluster_leaders, rec.norm_title);
cluster_counts := array_append(cluster_counts, rec.cnt);
END IF;
END LOOP;
RETURN QUERY
SELECT sub.cl AS subject_group, sub.cc AS task_count
FROM (
SELECT unnest(cluster_leaders) AS cl,
unnest(cluster_counts) AS cc
) sub
ORDER BY sub.cc DESC
LIMIT p_limit;
END;
$$;
-- ----- 9. Monthly overview (tickets + tasks) -----
CREATE OR REPLACE FUNCTION report_monthly_overview(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(month text, ticket_count bigint, task_count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
WITH months AS (
SELECT to_char(date_trunc('month', d), 'YYYY-MM') AS m
FROM generate_series(
date_trunc('month', p_start),
date_trunc('month', p_end - interval '1 day'),
interval '1 month'
) d
),
tk AS (
SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS m,
count(*) AS cnt
FROM tickets
WHERE created_at >= p_start AND created_at < p_end
GROUP BY m
),
ta AS (
SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS m,
count(*) AS cnt
FROM tasks
WHERE created_at >= p_start AND created_at < p_end
GROUP BY m
)
SELECT months.m AS month,
coalesce(tk.cnt, 0) AS ticket_count,
coalesce(ta.cnt, 0) AS task_count
FROM months
LEFT JOIN tk ON tk.m = months.m
LEFT JOIN ta ON ta.m = months.m
ORDER BY months.m;
$$;
-- ----- 10. Request type distribution -----
CREATE OR REPLACE FUNCTION report_request_type_distribution(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(request_type text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT coalesce(t.request_type::text, 'Unspecified') AS request_type,
count(*)
FROM tasks t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY request_type
ORDER BY count(*) DESC;
$$;
-- ----- 11. Request category distribution -----
CREATE OR REPLACE FUNCTION report_request_category_distribution(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(request_category text, count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT coalesce(t.request_category::text, 'Unspecified') AS request_category,
count(*)
FROM tasks t
WHERE t.created_at >= p_start
AND t.created_at < p_end
GROUP BY request_category
ORDER BY count(*) DESC;
$$;
-- ----- 12. Avg resolution time by office -----
CREATE OR REPLACE FUNCTION report_avg_resolution_by_office(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(office_name text, avg_hours float)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT o.name AS office_name,
avg(EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 3600.0)::float AS avg_hours
FROM tasks t
JOIN offices o ON o.id = t.office_id
WHERE t.created_at >= p_start
AND t.created_at < p_end
AND t.completed_at IS NOT NULL
AND t.status::text = 'completed'
GROUP BY o.name
ORDER BY avg_hours DESC;
$$;
-- ----- 13. Staff workload (assigned vs completed) -----
CREATE OR REPLACE FUNCTION report_staff_workload(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(staff_name text, assigned_count bigint, completed_count bigint)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT p.full_name AS staff_name,
count(DISTINCT ta.task_id) AS assigned_count,
count(DISTINCT CASE WHEN t.status::text = 'completed'
AND t.completed_at >= p_start
AND t.completed_at < p_end
THEN t.id END) AS completed_count
FROM task_assignments ta
JOIN profiles p ON p.id = ta.user_id
JOIN tasks t ON t.id = ta.task_id
WHERE ta.created_at >= p_start
AND ta.created_at < p_end
GROUP BY p.full_name
ORDER BY assigned_count DESC;
$$;
-- ----- 14. Ticket-to-task conversion rate -----
CREATE OR REPLACE FUNCTION report_ticket_to_task_rate(
p_start timestamptz,
p_end timestamptz
)
RETURNS TABLE(total_tickets bigint, promoted_tickets bigint, conversion_rate float)
LANGUAGE sql STABLE SECURITY INVOKER
AS $$
SELECT
count(*)::bigint AS total_tickets,
count(*) FILTER (WHERE promoted_at IS NOT NULL)::bigint AS promoted_tickets,
CASE WHEN count(*) > 0
THEN (count(*) FILTER (WHERE promoted_at IS NOT NULL)::float / count(*)::float * 100)
ELSE 0
END AS conversion_rate
FROM tickets
WHERE created_at >= p_start
AND created_at < p_end;
$$;