feat(db): capture reporting.* map-dashboard schema as migration 11
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:
parent
831f683b83
commit
00e81a063b
2 changed files with 555 additions and 0 deletions
554
migrations/11_reporting_schema.sql
Normal file
554
migrations/11_reporting_schema.sql
Normal 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$;
|
||||
|
|
@ -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 ─────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue