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