feat(db): capture reporting.* map-dashboard schema as migration 11
Some checks are pending
Static Analysis / static (push) Waiting to run
Tests / test (push) Waiting to run

The reporting schema (fn_live_positions/fn_vehicle_track/fn_trips_for_map,
the v_trips materialized view + indexes, filter/summary views, refresh_log)
backs the dashboard_api map endpoints but existed only on the prod DB, in no
migration — a rebuild would have lost it. Captured the live DDL into
migrations/11_reporting_schema.sql (idempotent: IF NOT EXISTS / CREATE OR
REPLACE, search_path set for unqualified base-table refs, guarded grants) and
registered it in run_migrations.py. Verified it applies cleanly against prod
inside a rolled-back transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-05 12:32:44 +03:00
parent 831f683b83
commit 00e81a063b
2 changed files with 555 additions and 0 deletions

View file

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

View file

@ -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 ─────────────