diff --git a/migrations/03_fuel_timezone_eat.sql b/migrations/03_fuel_timezone_eat.sql new file mode 100644 index 0000000..a16ebd1 --- /dev/null +++ b/migrations/03_fuel_timezone_eat.sql @@ -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$;