feat(schema): standardize fuel timestamps to Africa/Nairobi (EAT)

Source feed is UTC ("…Z"); the fleet is in Kenya (UTC+3). Storing UTC timestamptz
made record_datetime::date bucket by UTC day. Migration 03 converts record_datetime,
deleted_at, ingested_at, updated_at (+ ingest_state.updated_at) to Africa/Nairobi
wall-clock `timestamp`, updates the trigger to derive EAT, recreates the views
(fuel_date now EAT day) and re-grants grafana_ro. Verified in prod: 18:38Z -> 21:38 EAT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-11 23:58:27 +03:00
parent a5380aece6
commit 1bf7e63c62

View file

@ -0,0 +1,113 @@
-- 03_fuel_timezone_eat.sql — fleetfuel · standardize fuel times to Africa/Nairobi
-- ─────────────────────────────────────────────────────────────────────────────
-- The source feed stamps record_datetime in UTC ("…Z"). The fleet operates in
-- Kenya (EAT, UTC+3), so storing UTC `timestamptz` made record_datetime::date
-- bucket by UTC day — an evening fill (e.g. 22:00 EAT = 19:00 UTC, fine, but
-- 01:00 EAT = 22:00 prev-day UTC) lands on the wrong calendar day in the daily
-- trend. Standardize every fuel timestamp to Africa/Nairobi WALL-CLOCK stored as
-- `timestamp` (without time zone): the value you read in the table is EAT, and
-- record_datetime::date is the EAT day.
--
-- Runs only after 01/02 (columns are still timestamptz here), so the USING
-- conversion `col AT TIME ZONE 'Africa/Nairobi'` (timestamptz -> local EAT
-- timestamp) is always correct. Idempotent in effect; safe to re-apply.
-- ─────────────────────────────────────────────────────────────────────────────
-- EAT-local cast: source UTC text -> Africa/Nairobi wall-clock timestamp (or NULL).
CREATE OR REPLACE FUNCTION fuel.to_ts_eat(p text)
RETURNS timestamp LANGUAGE plpgsql STABLE AS $fn$
BEGIN
RETURN (NULLIF(btrim(p), '')::timestamptz) AT TIME ZONE 'Africa/Nairobi';
EXCEPTION WHEN others THEN
RETURN NULL;
END $fn$;
-- Views depend on record_datetime, so drop them before the column type change.
DROP VIEW IF EXISTS reporting.v_fuel_efficiency;
DROP VIEW IF EXISTS reporting.v_fuel_fills;
-- Convert existing data (timestamptz instant -> EAT wall-clock) and switch types.
ALTER TABLE fuel.records
ALTER COLUMN record_datetime TYPE timestamp USING (record_datetime AT TIME ZONE 'Africa/Nairobi'),
ALTER COLUMN deleted_at TYPE timestamp USING (deleted_at AT TIME ZONE 'Africa/Nairobi'),
ALTER COLUMN ingested_at TYPE timestamp USING (ingested_at AT TIME ZONE 'Africa/Nairobi'),
ALTER COLUMN updated_at TYPE timestamp USING (updated_at AT TIME ZONE 'Africa/Nairobi');
ALTER TABLE fuel.records
ALTER COLUMN ingested_at SET DEFAULT (now() AT TIME ZONE 'Africa/Nairobi'),
ALTER COLUMN updated_at SET DEFAULT (now() AT TIME ZONE 'Africa/Nairobi');
ALTER TABLE fuel.ingest_state
ALTER COLUMN updated_at TYPE timestamp USING (updated_at AT TIME ZONE 'Africa/Nairobi'),
ALTER COLUMN updated_at SET DEFAULT (now() AT TIME ZONE 'Africa/Nairobi');
-- Trigger now derives EAT-local timestamps from raw.
CREATE OR REPLACE FUNCTION fuel.tg_records_derive()
RETURNS trigger LANGUAGE plpgsql AS $fn$
BEGIN
NEW.record_datetime := fuel.to_ts_eat(NEW.raw->>'record_datetime');
NEW.car_raw := NEW.raw->>'car';
NEW.plate := fuel.norm_plate(NEW.raw->>'car');
NEW.liters := fuel.to_num(NEW.raw->>'liters');
NEW.amount := fuel.to_num(NEW.raw->>'amount');
NEW.fuel_type := fuel.canon_fuel_type(NEW.raw->>'fuel_type');
NEW.fuel_type_raw := NEW.raw->>'fuel_type';
NEW.department := fuel.canon_department(NEW.raw->>'department');
NEW.driver := NULLIF(btrim(NEW.raw->>'driver'), '');
NEW.odometer := fuel.to_num(NEW.raw->>'odometer');
NEW.deleted_at := fuel.to_ts_eat(NEW.raw->>'deleted_at');
NEW.message_fingerprint := NEW.raw->>'message_fingerprint';
NEW.updated_at := now() AT TIME ZONE 'Africa/Nairobi';
RETURN NEW;
END $fn$;
-- Recreate the read views (record_datetime is now EAT-local; fuel_date follows).
CREATE OR REPLACE VIEW reporting.v_fuel_fills AS
SELECT r.id,
r.record_datetime,
r.record_datetime::date AS fuel_date,
r.plate,
d.vehicle_number,
d.cost_centre,
d.assigned_city,
d.assigned_driver,
d.imei,
r.department,
r.driver,
r.liters,
r.amount,
r.fuel_type,
r.odometer
FROM fuel.records r
LEFT JOIN LATERAL (
SELECT dv.vehicle_number, dv.cost_centre, dv.assigned_city,
dv.driver_name AS assigned_driver, dv.imei
FROM tracksolid.devices dv
WHERE r.plate IS NOT NULL
AND fuel.norm_plate(dv.vehicle_number) = r.plate
ORDER BY dv.enabled_flag DESC NULLS LAST, dv.updated_at DESC NULLS LAST
LIMIT 1
) d ON true
WHERE r.deleted_at IS NULL;
CREATE OR REPLACE VIEW reporting.v_fuel_efficiency AS
WITH seq AS (
SELECT id, plate, vehicle_number, cost_centre, assigned_city,
record_datetime, fuel_date, odometer, liters, amount, fuel_type,
odometer - lag(odometer) OVER (PARTITION BY plate ORDER BY record_datetime, id) AS km_since_last
FROM reporting.v_fuel_fills
WHERE plate IS NOT NULL AND odometer IS NOT NULL AND odometer > 0
)
SELECT seq.*,
CASE WHEN liters > 0 AND km_since_last > 0 AND km_since_last < 5000
THEN round(km_since_last / liters, 2)
END AS km_per_litre
FROM seq;
-- Dropping the views dropped their grants — re-grant to the read-only role.
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 reporting.v_fuel_fills, reporting.v_fuel_efficiency TO grafana_ro;
END IF;
END $grants$;