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>
554 lines
24 KiB
PL/PgSQL
554 lines
24 KiB
PL/PgSQL
-- 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$;
|