diff --git a/migrations/11_reporting_schema.sql b/migrations/11_reporting_schema.sql new file mode 100644 index 0000000..911b45f --- /dev/null +++ b/migrations/11_reporting_schema.sql @@ -0,0 +1,554 @@ +-- 11_reporting_schema.sql +-- Map-dashboard read layer consumed by dashboard_api_rev.py: +-- reporting.fn_live_positions / fn_vehicle_track / fn_trips_for_map +-- + the v_trips materialized view, filter/summary views, and refresh_log. +-- Captured from prod 2026-06-05 to close the reproducibility gap (these objects +-- lived only on the live DB, in no migration). Every object uses IF NOT EXISTS / +-- CREATE OR REPLACE so the file is safe to re-apply. +-- +-- NOTE: the v_trips materialized view is created WITH DATA (populated once). On +-- prod it is owned by role `reporting_refresher` and kept current by an external +-- REFRESH job; that role + refresh schedule are infrastructure, NOT created here. +-- On a fresh rebuild v_trips is populated at creation but will go stale until a +-- refresh job is wired (see docs). The dashboard map functions still work, just +-- against the snapshot until then. + +CREATE SCHEMA IF NOT EXISTS reporting; + +-- Bodies reference base tables unqualified (devices, trips, …) + PostGIS; +-- resolve via search_path so this applies cleanly on a fresh DB. +SET search_path = tracksolid, reporting, public; + +-- ── helper ─────────────────────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION reporting.normalize_plate(p text) + RETURNS text + LANGUAGE sql + IMMUTABLE PARALLEL SAFE +AS $function$ + SELECT regexp_replace( + regexp_replace(trim(p), '\s+', ' ', 'g'), + '(\d) ([A-Z])$', '\1\2' + ) +$function$; + +-- ── refresh audit table ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS reporting.refresh_log ( + refreshed_at timestamptz DEFAULT now() NOT NULL, + source text DEFAULT 'n8n'::text NOT NULL, + duration_ms integer, + row_count integer, + notes text +); + +-- ── v_trips materialized view (+ indexes) ──────────────────────────────────── +CREATE MATERIALIZED VIEW IF NOT EXISTS reporting.v_trips AS + WITH device_trip_counts AS ( + SELECT trips.imei, + count(*) AS trip_count + FROM trips + GROUP BY trips.imei + ), primary_device AS ( + SELECT DISTINCT ON ((reporting.normalize_plate(d_1.vehicle_number))) reporting.normalize_plate(d_1.vehicle_number) AS vehicle_number_norm, + d_1.imei AS primary_imei + FROM devices d_1 + LEFT JOIN device_trip_counts c USING (imei) + WHERE d_1.vehicle_number IS NOT NULL AND d_1.enabled_flag = 1 + ORDER BY (reporting.normalize_plate(d_1.vehicle_number)), (COALESCE(c.trip_count, 0::bigint)) DESC, d_1.activation_time, d_1.imei + ) + SELECT t.id AS trip_id, + t.imei, + d.device_name, + reporting.normalize_plate(d.vehicle_number) AS vehicle_number, + d.vehicle_models, + d.vehicle_category, + d.cost_centre, + d.assigned_city, + d.driver_name AS assigned_driver, + (t.start_time AT TIME ZONE 'Africa/Nairobi'::text) AS start_time, + (t.end_time AT TIME ZONE 'Africa/Nairobi'::text) AS end_time, + (t.start_time AT TIME ZONE 'Africa/Nairobi'::text)::date AS trip_date, + EXTRACT(hour FROM (t.start_time AT TIME ZONE 'Africa/Nairobi'::text))::integer AS start_hour, + EXTRACT(dow FROM (t.start_time AT TIME ZONE 'Africa/Nairobi'::text))::integer AS start_dow, + row_number() OVER (PARTITION BY t.imei, ((t.start_time AT TIME ZONE 'Africa/Nairobi'::text)::date) ORDER BY t.start_time) AS daily_seq, + t.distance_km, + t.avg_speed_kmh, + t.max_speed_kmh, + t.idle_time_s, + t.driving_time_s, + t.fuel_consumed_l, + t.waypoints_count, + t.start_address, + t.end_address, + t.start_geom, + t.end_geom, + t.route_geom, + st_asgeojson(st_simplifypreservetopology(t.route_geom, 0.00005::double precision))::json AS route_geojson, + st_numpoints(t.route_geom) >= 2 AND st_length(t.route_geom::geography) > 50::double precision AS is_meaningful_route, + (t.updated_at AT TIME ZONE 'Africa/Nairobi'::text) AS updated_at + FROM trips t + LEFT JOIN devices d USING (imei) + LEFT JOIN primary_device pd ON pd.vehicle_number_norm = reporting.normalize_plate(d.vehicle_number) + WHERE d.vehicle_number IS NULL OR pd.primary_imei IS NULL OR t.imei = pd.primary_imei +WITH DATA; + +CREATE INDEX IF NOT EXISTS ix_v_trips_city_trip_date ON reporting.v_trips USING btree (assigned_city, trip_date); +CREATE INDEX IF NOT EXISTS ix_v_trips_cost_centre_trip_date ON reporting.v_trips USING btree (cost_centre, trip_date); +CREATE INDEX IF NOT EXISTS ix_v_trips_driver_trip_date ON reporting.v_trips USING btree (assigned_driver, trip_date); +CREATE INDEX IF NOT EXISTS ix_v_trips_imei_trip_date ON reporting.v_trips USING btree (imei, trip_date); +CREATE INDEX IF NOT EXISTS ix_v_trips_meaningful_date ON reporting.v_trips USING btree (trip_date) WHERE is_meaningful_route; +CREATE INDEX IF NOT EXISTS ix_v_trips_trip_date ON reporting.v_trips USING btree (trip_date); +CREATE UNIQUE INDEX IF NOT EXISTS ix_v_trips_trip_id ON reporting.v_trips USING btree (trip_id); + +-- ── views (dependency-ordered) ─────────────────────────────────────────────── +CREATE OR REPLACE VIEW reporting.v_live_positions AS + WITH primary_device AS ( + SELECT DISTINCT ON ((reporting.normalize_plate(d_1.vehicle_number))) reporting.normalize_plate(d_1.vehicle_number) AS vehicle_number, + d_1.imei AS primary_imei + FROM devices d_1 + LEFT JOIN live_positions lp_1 ON lp_1.imei = d_1.imei + WHERE d_1.vehicle_number IS NOT NULL AND d_1.enabled_flag = 1 + ORDER BY (reporting.normalize_plate(d_1.vehicle_number)), ( + CASE + WHEN (d_1.mc_type = ANY (ARRAY['GT06E'::text, 'X3'::text, 'AT4'::text])) AND lp_1.gps_time >= (now() - '24:00:00'::interval) THEN 0 + ELSE 1 + END), lp_1.gps_time DESC NULLS LAST, ( + CASE d_1.mc_type + WHEN 'GT06E'::text THEN 1 + WHEN 'X3'::text THEN 2 + WHEN 'AT4'::text THEN 3 + WHEN 'JC400P'::text THEN 4 + ELSE 5 + END), d_1.activation_time, d_1.imei + ) + SELECT lp.imei, + pd.vehicle_number, + d.driver_name AS assigned_driver, + d.cost_centre, + d.assigned_city, + d.vehicle_category, + d.vehicle_models, + d.mc_type, + CASE d.mc_type + WHEN 'GT06E'::text THEN 'tracker'::text + WHEN 'X3'::text THEN 'tracker'::text + WHEN 'AT4'::text THEN 'tracker'::text + WHEN 'JC400P'::text THEN 'camera'::text + ELSE 'other'::text + END AS device_kind, + lp.lat, + lp.lng, + lp.speed, + lp.direction, + lp.acc_status, + lp.device_status, + lp.gps_signal, + lp.gps_num, + lp.current_mileage, + lp.loc_desc, + lp.gps_time, + lp.updated_at, + (lp.gps_time AT TIME ZONE 'Africa/Nairobi'::text) AS gps_time_eat, + (lp.updated_at AT TIME ZONE 'Africa/Nairobi'::text) AS updated_at_eat, + round(EXTRACT(epoch FROM now() - lp.gps_time) / 3600::numeric, 2) AS source_age_hours + FROM live_positions lp + JOIN primary_device pd ON pd.primary_imei = lp.imei + JOIN devices d ON d.imei = lp.imei; + +CREATE OR REPLACE VIEW reporting.v_trips_today AS + SELECT trip_id, + imei, + device_name, + vehicle_number, + vehicle_models, + vehicle_category, + cost_centre, + assigned_city, + assigned_driver, + start_time, + end_time, + trip_date, + start_hour, + start_dow, + daily_seq, + distance_km, + avg_speed_kmh, + max_speed_kmh, + idle_time_s, + driving_time_s, + fuel_consumed_l, + waypoints_count, + start_address, + end_address, + start_geom, + end_geom, + route_geom, + route_geojson, + is_meaningful_route, + updated_at + FROM reporting.v_trips + WHERE trip_date = (now() AT TIME ZONE 'Africa/Nairobi'::text)::date; + +CREATE OR REPLACE VIEW reporting.v_filter_drivers AS + SELECT DISTINCT assigned_driver AS driver + FROM reporting.v_trips + WHERE assigned_driver IS NOT NULL + ORDER BY assigned_driver; + +CREATE OR REPLACE VIEW reporting.v_filter_cost_centres AS + SELECT DISTINCT cost_centre + FROM reporting.v_trips + WHERE cost_centre IS NOT NULL + ORDER BY cost_centre; + +CREATE OR REPLACE VIEW reporting.v_filter_vehicles AS + SELECT vehicle_number, + string_agg(DISTINCT assigned_driver, ', '::text ORDER BY assigned_driver) AS drivers, + (array_agg(cost_centre ORDER BY start_time DESC NULLS LAST))[1] AS cost_centre, + (array_agg(assigned_city ORDER BY start_time DESC NULLS LAST))[1] AS assigned_city + FROM reporting.v_trips + WHERE vehicle_number IS NOT NULL + GROUP BY vehicle_number + ORDER BY vehicle_number; + +CREATE OR REPLACE VIEW reporting.v_filter_cities AS + SELECT DISTINCT assigned_city + FROM reporting.v_trips + WHERE assigned_city IS NOT NULL + ORDER BY assigned_city; + +CREATE OR REPLACE VIEW reporting.v_daily_summary AS + SELECT trip_date, + cost_centre, + assigned_city, + vehicle_number, + assigned_driver, + count(*) AS trip_count, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct, + min(start_time) AS first_trip_start, + max(end_time) AS last_trip_end, + avg(avg_speed_kmh) AS avg_speed_kmh, + max(max_speed_kmh) AS max_speed_kmh, + st_asgeojson(st_simplifypreservetopology(st_collect(route_geom), 0.00005::double precision))::json AS day_routes_geojson + FROM reporting.v_trips + WHERE is_meaningful_route + GROUP BY trip_date, cost_centre, assigned_city, vehicle_number, assigned_driver; + +CREATE OR REPLACE VIEW reporting.v_weekly_summary AS + SELECT date_trunc('week'::text, trip_date::timestamp with time zone)::date AS week_start, + cost_centre, + assigned_city, + vehicle_number, + assigned_driver, + count(*) AS trip_count, + count(DISTINCT trip_date) AS active_days, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + avg(distance_km) AS avg_trip_km + FROM reporting.v_trips + WHERE is_meaningful_route + GROUP BY (date_trunc('week'::text, trip_date::timestamp with time zone)::date), cost_centre, assigned_city, vehicle_number, assigned_driver; + +CREATE OR REPLACE VIEW reporting.v_monthly_summary AS + SELECT date_trunc('month'::text, trip_date::timestamp with time zone)::date AS month_start, + to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text) AS month_label, + cost_centre, + assigned_city, + vehicle_category, + vehicle_number, + assigned_driver, + count(*) AS trip_count, + count(DISTINCT trip_date) AS active_days, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct, + round(sum(distance_km) / NULLIF(count(DISTINCT trip_date), 0)::numeric, 1) AS km_per_active_day, + round(sum(distance_km) / NULLIF(count(*), 0)::numeric, 1) AS km_per_trip, + avg(avg_speed_kmh) AS avg_speed_kmh, + max(max_speed_kmh) AS peak_speed_kmh + FROM reporting.v_trips + WHERE is_meaningful_route + GROUP BY (date_trunc('month'::text, trip_date::timestamp with time zone)::date), (to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text)), cost_centre, assigned_city, vehicle_category, vehicle_number, assigned_driver; + +CREATE OR REPLACE VIEW reporting.v_daily_cost_centre AS + SELECT trip_date, + cost_centre, + count(DISTINCT imei) AS active_vehicles, + count(DISTINCT assigned_driver) AS active_drivers, + count(*) AS trip_count, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct, + round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle + FROM reporting.v_trips + WHERE is_meaningful_route AND cost_centre IS NOT NULL + GROUP BY trip_date, cost_centre; + +CREATE OR REPLACE VIEW reporting.v_weekly_cost_centre AS + SELECT date_trunc('week'::text, trip_date::timestamp with time zone)::date AS week_start, + cost_centre, + count(DISTINCT imei) AS active_vehicles, + count(DISTINCT assigned_driver) AS active_drivers, + count(DISTINCT trip_date) AS active_days, + count(*) AS trip_count, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct, + round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle + FROM reporting.v_trips + WHERE is_meaningful_route AND cost_centre IS NOT NULL + GROUP BY (date_trunc('week'::text, trip_date::timestamp with time zone)::date), cost_centre; + +CREATE OR REPLACE VIEW reporting.v_monthly_cost_centre AS + SELECT date_trunc('month'::text, trip_date::timestamp with time zone)::date AS month_start, + to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text) AS month_label, + cost_centre, + count(DISTINCT imei) AS active_vehicles, + count(DISTINCT assigned_driver) AS active_drivers, + count(DISTINCT trip_date) AS active_days, + count(*) AS trip_count, + sum(distance_km) AS total_km, + sum(driving_time_s)::numeric / 3600.0 AS driving_hours, + sum(idle_time_s)::numeric / 3600.0 AS idle_hours, + round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct, + round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle + FROM reporting.v_trips + WHERE is_meaningful_route AND cost_centre IS NOT NULL + GROUP BY (date_trunc('month'::text, trip_date::timestamp with time zone)::date), (to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text)), cost_centre; + +-- ── refresh_log index ──────────────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS ix_refresh_log_refreshed_at ON reporting.refresh_log USING btree (refreshed_at DESC); + +-- ── map functions ──────────────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION reporting.fn_live_positions(p_cost_centre text DEFAULT NULL::text, p_acc_status text DEFAULT NULL::text) + RETURNS jsonb + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_result jsonb; +BEGIN + p_cost_centre := NULLIF(p_cost_centre, ''); + p_acc_status := NULLIF(p_acc_status, ''); + WITH filtered AS ( + SELECT * FROM reporting.v_live_positions + WHERE (p_cost_centre IS NULL OR cost_centre = p_cost_centre) + AND (p_acc_status IS NULL OR acc_status = p_acc_status) + ) + SELECT jsonb_build_object( + 'summary', jsonb_build_object( + 'vehicle_count', COUNT(*), + -- "moving" and "parked" both restrict to devices that have reported + -- within the OFFLINE_THRESHOLD (24 h) so they represent the live + -- fleet, not equipment-failure stragglers. "offline" is its own + -- counter for the > 24 h tail. + 'moving', COUNT(*) FILTER (WHERE acc_status = '1' + AND source_age_hours < 24), + 'parked', COUNT(*) FILTER (WHERE acc_status = '0' + AND source_age_hours < 24), + 'offline', COUNT(*) FILTER (WHERE source_age_hours >= 24), + 'median_speed_moving', percentile_cont(0.5) WITHIN GROUP (ORDER BY speed) + FILTER (WHERE acc_status = '1' + AND source_age_hours < 24 + AND speed > 0), + 'last_batch_at', to_char(MAX(updated_at) AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS'), + 'oldest_fix_at', to_char(MIN(gps_time) AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS'), + 'newest_fix_at', to_char(MAX(gps_time) AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS'), + 'last_batch_utc', MAX(updated_at), + 'newest_fix_utc', MAX(gps_time) + ), + 'geojson', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(jsonb_agg( + jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'imei', imei, + 'vehicle_number', vehicle_number, + 'driver', assigned_driver, + 'cost_centre', cost_centre, + 'assigned_city', assigned_city, + 'vehicle_category', vehicle_category, + 'mc_type', mc_type, + 'device_kind', device_kind, + 'source_age_hours', source_age_hours, + 'speed', speed, + 'direction', direction, + 'acc_status', acc_status, + 'device_status', device_status, + 'gps_signal', gps_signal, + 'gps_num', gps_num, + 'current_mileage', current_mileage, + 'loc_desc', loc_desc, + 'gps_time', to_char(gps_time AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS'), + 'updated_at', to_char(updated_at AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS'), + 'gps_time_utc', gps_time, + 'updated_at_utc', updated_at + ), + 'geometry', jsonb_build_object( + 'type', 'Point', + 'coordinates', jsonb_build_array(lng, lat) + ) + ) + ), '[]'::jsonb) + ) + ) INTO v_result FROM filtered; + + RETURN v_result; +END $function$; + +CREATE OR REPLACE FUNCTION reporting.fn_vehicle_track(p_vehicle_number text, p_hours integer DEFAULT 1) + RETURNS jsonb + LANGUAGE sql + STABLE +AS $function$ + -- IMEI lookup reuses the already-deduped reporting.v_live_positions instead + -- of re-running the primary_device CTE against tracksolid.trips. That keeps + -- reporting_reader off tracksolid.trips entirely. + WITH pts AS ( + SELECT ph.gps_time, ph.lat, ph.lng, ph.speed, ph.direction + FROM tracksolid.position_history ph + JOIN reporting.v_live_positions lv ON lv.imei = ph.imei + WHERE lv.vehicle_number = reporting.normalize_plate(p_vehicle_number) + AND ph.gps_time >= NOW() - make_interval(hours => GREATEST(p_hours, 1)) + ORDER BY ph.gps_time + ) + SELECT jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'vehicle_number', reporting.normalize_plate(p_vehicle_number), + 'hours', p_hours, + 'points', (SELECT COUNT(*) FROM pts), + 'first_fix', (SELECT to_char(MIN(gps_time) AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS') FROM pts), + 'last_fix', (SELECT to_char(MAX(gps_time) AT TIME ZONE 'Africa/Nairobi', + 'YYYY-MM-DD HH24:MI:SS') FROM pts) + ), + 'geometry', jsonb_build_object( + 'type', 'LineString', + 'coordinates', COALESCE( + (SELECT jsonb_agg(jsonb_build_array(lng, lat) ORDER BY gps_time) FROM pts), + '[]'::jsonb) + ) + ); +$function$; + +CREATE OR REPLACE FUNCTION reporting.fn_trips_for_map(p_vehicle_numbers text[] DEFAULT NULL::text[], p_driver text DEFAULT NULL::text, p_cost_centre text DEFAULT NULL::text, p_assigned_city text DEFAULT NULL::text, p_start_date date DEFAULT NULL::date, p_end_date date DEFAULT NULL::date) + RETURNS jsonb + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_start date := COALESCE(p_start_date, (NOW() AT TIME ZONE 'Africa/Nairobi')::date); + v_end date := COALESCE(p_end_date, (NOW() AT TIME ZONE 'Africa/Nairobi')::date); + v_days int := v_end - v_start + 1; + v_result jsonb; +BEGIN + p_driver := NULLIF(p_driver, ''); + p_cost_centre := NULLIF(p_cost_centre, ''); + p_assigned_city := NULLIF(p_assigned_city, ''); + -- 31-day guardrail: tripped only when NO filter is set AND range > 31 days. + -- Vehicle list (non-empty), driver, cost-centre, OR city each waives it. + IF (p_vehicle_numbers IS NULL OR cardinality(p_vehicle_numbers) = 0) + AND p_driver IS NULL + AND p_cost_centre IS NULL + AND p_assigned_city IS NULL + AND v_days > 31 THEN + RAISE EXCEPTION + 'Range too wide for trip-grain map (% days). Pick a vehicle, driver, cost centre, or city — or narrow the period to 31 days or fewer.', + v_days + USING ERRCODE = 'check_violation'; + END IF; + + WITH filtered AS ( + SELECT * + FROM reporting.v_trips + WHERE is_meaningful_route + AND (p_vehicle_numbers IS NULL + OR cardinality(p_vehicle_numbers) = 0 + OR vehicle_number = ANY(p_vehicle_numbers)) + AND (p_driver IS NULL OR assigned_driver = p_driver) + AND (p_cost_centre IS NULL OR cost_centre = p_cost_centre) + AND (p_assigned_city IS NULL OR assigned_city = p_assigned_city) + AND (p_start_date IS NULL OR trip_date >= p_start_date) + AND (p_end_date IS NULL OR trip_date <= p_end_date) + ) + SELECT jsonb_build_object( + 'summary', jsonb_build_object( + 'trip_count', COUNT(*), + 'total_km', ROUND(COALESCE(SUM(distance_km), 0)::numeric, 1), + 'driving_hours', ROUND((COALESCE(SUM(driving_time_s), 0) / 3600.0)::numeric, 1), + 'idle_hours', ROUND((COALESCE(SUM(idle_time_s), 0) / 3600.0)::numeric, 1), + 'unique_vehicles', COUNT(DISTINCT vehicle_number), + 'unique_drivers', COUNT(DISTINCT assigned_driver), + 'date_min', MIN(trip_date), + 'date_max', MAX(trip_date), + -- First trip's start (chronologically first) + reverse-geocoded location + 'first_trip_start_time', + (array_agg(to_char(start_time, 'YYYY-MM-DD HH24:MI:SS') ORDER BY start_time))[1], + 'first_trip_start_address', + (array_agg(start_address ORDER BY start_time))[1], + 'first_trip_vehicle', + (array_agg(vehicle_number ORDER BY start_time))[1], + -- Last trip's end (chronologically latest) + reverse-geocoded location + 'last_trip_end_time', + (array_agg(to_char(end_time, 'YYYY-MM-DD HH24:MI:SS') ORDER BY end_time DESC NULLS LAST))[1], + 'last_trip_end_address', + (array_agg(end_address ORDER BY end_time DESC NULLS LAST))[1], + 'last_trip_vehicle', + (array_agg(vehicle_number ORDER BY end_time DESC NULLS LAST))[1] + ), + 'geojson', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(jsonb_agg( + jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'trip_id', trip_id, + 'vehicle_number', vehicle_number, + 'driver', assigned_driver, + 'cost_centre', cost_centre, + 'assigned_city', assigned_city, + 'trip_date', trip_date, + 'daily_seq', daily_seq, + 'start_time', to_char(start_time, 'YYYY-MM-DD HH24:MI:SS'), + 'end_time', to_char(end_time, 'YYYY-MM-DD HH24:MI:SS'), + 'distance_km', ROUND(COALESCE(distance_km, 0)::numeric, 2), + 'duration_min', ROUND((COALESCE(driving_time_s, 0) / 60.0)::numeric, 0) + ), + 'geometry', route_geojson + ) + ORDER BY vehicle_number, trip_date, daily_seq + ), '[]'::jsonb) + ) + ) + INTO v_result + FROM filtered; + + RETURN v_result; +END +$function$; + +-- ── grants (guarded: roles may not exist on a fresh DB) ─────────────────────── +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='grafana_ro') THEN + GRANT USAGE ON SCHEMA reporting TO grafana_ro; + GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO grafana_ro; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO grafana_ro; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='tracksolid_owner') THEN + GRANT USAGE ON SCHEMA reporting TO tracksolid_owner; + END IF; +END $grants$; diff --git a/run_migrations.py b/run_migrations.py index 2192f66..69129c4 100644 --- a/run_migrations.py +++ b/run_migrations.py @@ -34,6 +34,7 @@ MIGRATIONS = [ "08_analytics_config.sql", # ops.cost_rates, ops.kpi_targets + seed data "09_trips_enrichment.sql", # trips.route_geom + addresses + plate + v_trips_enriched "10_pgbouncer_auth.sql", # pgbouncer role + user_lookup() for SCRAM passthrough + "11_reporting_schema.sql", # reporting.* map-dashboard read layer (dashboard_api) ] # ── Tables that must exist before the service is allowed to start ─────────────