From cdb6186dca15cbebc306e124a237d089af7688b9 Mon Sep 17 00:00:00 2001 From: david kiania Date: Thu, 18 Jun 2026 17:53:32 +0300 Subject: [PATCH] feat(reporting): add closure-by-engineer analytics to fn_inc_dashboard (migration 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CREATE OR REPLACE reporting.fn_inc_dashboard (supersedes 09) adding metrics.by_owner — a closures-by-engineer leaderboard over the windowed closed set: array of { owner, closed, breached, avg_mttr_min } sorted desc. owner is CASE-NORMALIZED (initcap(lower(...))) to merge the offline closing system's mixed-case duplicates (observed 102 -> 58 distinct engineers; e.g. 'Elikana Mabonga' + 'ELIKANA MABONGA' -> one). owner on the open/closed GeoJSON features is normalized the same way so the leaderboard and per-ticket drill-down agree. Everything else unchanged; the dashboard_api route is a passthrough so no API change is needed. Validated in a rolled-back tx against prod. Co-Authored-By: Claude Opus 4.8 --- migrations/12_inc_dashboard_by_owner.sql | 182 +++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 migrations/12_inc_dashboard_by_owner.sql diff --git a/migrations/12_inc_dashboard_by_owner.sql b/migrations/12_inc_dashboard_by_owner.sql new file mode 100644 index 0000000..4e2b88b --- /dev/null +++ b/migrations/12_inc_dashboard_by_owner.sql @@ -0,0 +1,182 @@ +-- 12_inc_dashboard_by_owner.sql — fleettickets · closure-by-engineer analytics +-- ───────────────────────────────────────────────────────────────────────────── +-- CREATE OR REPLACE of reporting.fn_inc_dashboard (supersedes migration 09) to add +-- closure-by-engineer analytics for the FleetOps Tickets dashboard: +-- +-- metrics.by_owner — over the windowed CLOSED set, who closed how many, grouped by +-- a CASE-NORMALIZED owner. The offline closing system emits the +-- same engineer in mixed case ('Elikana Mabonga' vs +-- 'ELIKANA MABONGA'); normalizing with initcap(lower(...)) merges +-- those duplicates (102 -> 58 distinct owners observed). Returns +-- an array of { owner, closed, breached, avg_mttr_min } sorted by +-- closed desc — a ready-to-render leaderboard. +-- +-- The owner field on the open/closed GeoJSON features is normalized the SAME way, so +-- the leaderboard and the per-ticket drill-down (the closed features already carry +-- owner + closed_at) agree on casing. +-- +-- Everything else is unchanged from migration 09. The dashboard_api route is a plain +-- passthrough, so no API change is needed — only this DB function. Idempotent. +-- ───────────────────────────────────────────────────────────────────────────── + +SET search_path = tickets, public; + +CREATE OR REPLACE FUNCTION reporting.fn_inc_dashboard( + p_cluster text DEFAULT NULL, + p_status text DEFAULT NULL, + p_window text DEFAULT 'today', + p_from timestamptz DEFAULT NULL, + p_to timestamptz DEFAULT NULL +) + RETURNS jsonb LANGUAGE plpgsql STABLE AS $fn$ +DECLARE + v_now_eat timestamp; + v_from timestamptz; + v_to timestamptz; + v_preset text; + v_days numeric; + v_result jsonb; +BEGIN + p_cluster := NULLIF(p_cluster, ''); + p_status := NULLIF(p_status, ''); + v_now_eat := now() AT TIME ZONE 'Africa/Nairobi'; + + -- ── resolve the window ────────────────────────────────────────────────────── + IF p_from IS NOT NULL OR p_to IS NOT NULL THEN + v_preset := 'custom'; + v_from := COALESCE(p_from, '-infinity'::timestamptz); + v_to := COALESCE(p_to, 'infinity'::timestamptz); + ELSE + v_preset := lower(COALESCE(NULLIF(p_window, ''), 'today')); + IF v_preset = 'week' THEN + v_from := date_trunc('week', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('week', v_now_eat) + interval '1 week') AT TIME ZONE 'Africa/Nairobi'; + ELSIF v_preset = 'month' THEN + v_from := date_trunc('month', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('month', v_now_eat) + interval '1 month') AT TIME ZONE 'Africa/Nairobi'; + ELSE + v_preset := 'today'; + v_from := date_trunc('day', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('day', v_now_eat) + interval '1 day') AT TIME ZONE 'Africa/Nairobi'; + END IF; + END IF; + + IF v_from > '-infinity'::timestamptz AND v_to < 'infinity'::timestamptz THEN + v_days := GREATEST(EXTRACT(EPOCH FROM (v_to - v_from)) / 86400.0, 1); + ELSE + v_days := NULL; -- open-ended custom window → per-day average not meaningful + END IF; + + -- ── build payload ─────────────────────────────────────────────────────────── + WITH open_t AS ( + SELECT * FROM tickets.inc_open_sla + WHERE (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + ), + closed_t AS ( + SELECT ticket_id, normalized_status, cluster, region, location_name, + assigned_team, owner, closed_at, mttr, sla_status, geo_source, geom + FROM tickets.inc + WHERE NOT COALESCE(is_actionable, false) + AND closed_at IS NOT NULL + AND closed_at >= v_from AND closed_at < v_to + AND (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + ) + SELECT jsonb_build_object( + 'window', jsonb_build_object('from', v_from, 'to', v_to, 'preset', v_preset), + + 'open', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'ticket_id', ticket_id, 'normalized_status', normalized_status, + 'cluster', cluster, 'region', region, 'location_name', location_name, + 'assigned_team', assigned_team, 'owner', initcap(lower(NULLIF(owner, ''))), + 'geo_source', geo_source, + 'sla_state', sla_state, 'hours_open', hours_open), + 'geometry', ST_AsGeoJSON(geom)::jsonb)) + FROM open_t WHERE geom IS NOT NULL), '[]'::jsonb) + ), + + 'closed', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'ticket_id', ticket_id, 'normalized_status', normalized_status, + 'cluster', cluster, 'region', region, 'location_name', location_name, + 'assigned_team', assigned_team, 'owner', initcap(lower(NULLIF(owner, ''))), + 'geo_source', geo_source, + 'closed_at', closed_at, 'mttr', mttr, 'sla_status', sla_status), + 'geometry', ST_AsGeoJSON(geom)::jsonb)) + FROM closed_t WHERE geom IS NOT NULL), '[]'::jsonb) + ), + + 'metrics', jsonb_build_object( + 'open_now', (SELECT count(*) FROM open_t), + 'closed_in_window', (SELECT count(*) FROM closed_t), + 'sla', jsonb_build_object( + 'open', (SELECT jsonb_build_object( + 'breached', count(*) FILTER (WHERE sla_state = 'breached'), + 'at_risk', count(*) FILTER (WHERE sla_state = 'at_risk'), + 'ok', count(*) FILTER (WHERE sla_state = 'ok'), + 'unknown', count(*) FILTER (WHERE sla_state = 'unknown')) FROM open_t), + 'closed', (SELECT jsonb_build_object( + 'compliant', count(*) FILTER (WHERE sla_status = 'Compliant'), + 'breached', count(*) FILTER (WHERE sla_status = 'Breached')) FROM closed_t) + ), + 'by_status', COALESCE((SELECT jsonb_object_agg(s, c) FROM ( + SELECT COALESCE(normalized_status, '(none)') AS s, count(*) AS c FROM ( + SELECT normalized_status FROM open_t + UNION ALL SELECT normalized_status FROM closed_t) u GROUP BY 1) z), '{}'::jsonb), + 'by_cluster', COALESCE((SELECT jsonb_object_agg(cl, c) FROM ( + SELECT COALESCE(cluster, '(none)') AS cl, count(*) AS c FROM ( + SELECT cluster FROM open_t + UNION ALL SELECT cluster FROM closed_t) u GROUP BY 1) z), '{}'::jsonb), + -- closures by engineer (CASE-NORMALIZED owner) — leaderboard for "who closed". + 'by_owner', COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'owner', o, 'closed', c, 'breached', b, 'avg_mttr_min', a) ORDER BY c DESC, o) + FROM ( + SELECT COALESCE(initcap(lower(NULLIF(owner, ''))), '(unattributed)') AS o, + count(*) AS c, + count(*) FILTER (WHERE sla_status = 'Breached') AS b, + round(avg(mttr) FILTER (WHERE mttr IS NOT NULL), 1) AS a + FROM closed_t GROUP BY 1) z), '[]'::jsonb), + 'closure_rate', jsonb_build_object( + 'per_day_avg', CASE WHEN v_days IS NULL THEN NULL + ELSE round((SELECT count(*) FROM closed_t)::numeric / v_days, 2) END, + 'series', COALESCE((SELECT jsonb_agg(jsonb_build_object('day', d, 'count', c) ORDER BY d) FROM ( + SELECT (closed_at AT TIME ZONE 'Africa/Nairobi')::date AS d, count(*) AS c + FROM closed_t GROUP BY 1) z), '[]'::jsonb) + ), + 'avg_mttr_min', (SELECT round(avg(mttr), 1) FROM closed_t WHERE mttr IS NOT NULL) + ), + + 'freshness', (SELECT jsonb_object_agg(dataset, jsonb_build_object( + 'export_type', export_type, 'exported_at', exported_at, + 'records_ingested', records_ingested, 'ingested_at', ingested_at)) + FROM tickets.import_meta) + ) INTO v_result; + + RETURN v_result; +END $fn$; + +COMMENT ON FUNCTION reporting.fn_inc_dashboard(text, text, text, timestamptz, timestamptz) IS + 'FleetOps INC operations dashboard: open (live) + closed (windowed) GeoJSON + ticket ' + 'metrics incl. by_owner closure leaderboard (case-normalized), filtered by ' + 'cluster/status/time (EAT). fleettickets 12 (supersedes 09).'; + +-- grants (guarded: roles may not exist on a fresh DB) +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_dashboard(text, text, text, timestamptz, timestamptz) TO dashboard_ro; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_dashboard(text, text, text, timestamptz, timestamptz) TO grafana_ro; + END IF; +END $grants$;