-- 01_fuel_schema.sql — fleetfuel · WhatsApp fuel-record store (raw-jsonb-first) -- ───────────────────────────────────────────────────────────────────────────── -- The `fuel` schema: one raw-jsonb row per fuel record (the source PK `id` + the -- full record as `raw`), plus DB-derived NORMALIZED columns the trigger fills from -- `raw`. The feed is WhatsApp fuel-update messages (n8n CDC of the client's -- `logistics_department.fuel_records`), which is messy — plate spacing, fuel-type -- typos, ~30 department spellings — so the normalizers below are the single source -- of truth, kept in the DB so reads never re-implement them. -- -- fuel.records id + raw + derived (plate, liters, amount, fuel_type, -- department, odometer, deleted_at, …) -- fuel.ingest_state CDC watermark for import_fuel.py --changes -- reporting.v_fuel_fills read view (joins devices by normalized plate) -- reporting.v_fuel_efficiency per-vehicle km/litre from odometer deltas -- -- Lives in the shared `tracksolid_db` so the existing dashboard_api and the -- FleetOps SPA keep working. Idempotent: safe on a fresh DB and re-appliable live. -- ───────────────────────────────────────────────────────────────────────────── CREATE SCHEMA IF NOT EXISTS fuel; CREATE SCHEMA IF NOT EXISTS reporting; -- shared read layer (the views live here for dashboard_api) -- ── safe casts (bad WhatsApp values -> NULL, never error) ──────────────────── CREATE OR REPLACE FUNCTION fuel.to_num(p text) RETURNS numeric LANGUAGE plpgsql IMMUTABLE AS $fn$ BEGIN RETURN NULLIF(btrim(p), '')::numeric; EXCEPTION WHEN others THEN RETURN NULL; END $fn$; CREATE OR REPLACE FUNCTION fuel.to_ts(p text) RETURNS timestamptz LANGUAGE plpgsql IMMUTABLE AS $fn$ BEGIN RETURN NULLIF(btrim(p), '')::timestamptz; EXCEPTION WHEN others THEN RETURN NULL; END $fn$; -- ── normalizers (the single source of truth for the messy feed) ────────────── -- Plate: upper, strip every non-alphanumeric ('KCA 542Q' -> 'KCA542Q'); drop junk -- placeholders ('ANY VEH', 'NA', …) so they don't pollute the join. CREATE OR REPLACE FUNCTION fuel.norm_plate(p text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $fn$ SELECT CASE WHEN upper(regexp_replace(coalesce(p, ''), '[^A-Za-z0-9]', '', 'g')) IN ('', 'ANYVEH', 'NA', 'NONE', 'NIL', 'NULL') THEN NULL ELSE NULLIF(upper(regexp_replace(p, '[^A-Za-z0-9]', '', 'g')), '') END $fn$; -- Fuel type: collapse the typo zoo into PETROL / DIESEL / VPOWER / OTHER / NULL. CREATE OR REPLACE FUNCTION fuel.canon_fuel_type(p text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $fn$ WITH c AS (SELECT NULLIF(upper(regexp_replace(coalesce(p, ''), '[^A-Za-z]', '', 'g')), '') AS v) SELECT CASE WHEN v IS NULL THEN NULL WHEN v LIKE '%VPOWER%' THEN 'VPOWER' WHEN v LIKE '%PET%' THEN 'PETROL' -- PETROL, PETRO, PETROLI, /PETROL WHEN v LIKE 'DI%' THEN 'DIESEL' -- DIESEL, DISEL, DISIEL, DISEIL, DIRSEL ELSE 'OTHER' END FROM c $fn$; -- Department: upper, punctuation -> space, collapse, then map known variants. CREATE OR REPLACE FUNCTION fuel.canon_department(p text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $fn$ WITH c AS (SELECT NULLIF(btrim(regexp_replace(upper(coalesce(p, '')), '[^A-Z0-9]+', ' ', 'g')), '') AS v) SELECT CASE WHEN v IS NULL THEN NULL WHEN v LIKE '%OSP%' AND v LIKE '%PATROL%' THEN 'OSP PATROL' WHEN v LIKE 'ROLL%OUT' OR v IN ('ROLLOUT','ROLOUT') THEN 'ROLLOUT' WHEN v LIKE 'ISP%' THEN 'ISP' WHEN v LIKE 'PLANNING%' THEN 'PLANNING' WHEN v LIKE 'OSP%' THEN 'OSP' WHEN v LIKE 'FDS%' THEN 'FDS' WHEN v LIKE 'DELIVER%' THEN 'DELIVERIES' WHEN v LIKE 'HUAWEI%' THEN 'HUAWEI' WHEN v LIKE 'AIRTEL%' THEN 'AIRTEL' WHEN v LIKE 'REGION%' THEN 'REGIONAL' WHEN v LIKE 'FTTH%' THEN 'FTTH' WHEN v LIKE 'QEHS%' THEN 'QEHS' WHEN v LIKE 'GENERAL%' THEN 'GENERAL' WHEN v LIKE 'LOGISTIC%' THEN 'LOGISTICS' ELSE v END FROM c $fn$; -- ── records: raw-jsonb-first + trigger-derived normalized columns ──────────── CREATE TABLE IF NOT EXISTS fuel.records ( id bigint PRIMARY KEY, raw jsonb NOT NULL, record_datetime timestamptz, car_raw text, plate text, liters numeric, amount numeric, fuel_type text, fuel_type_raw text, department text, driver text, odometer numeric, deleted_at timestamptz, message_fingerprint text, ingested_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- Derive every normalized column from `raw` on write — the loader only supplies (id, raw). CREATE OR REPLACE FUNCTION fuel.tg_records_derive() RETURNS trigger LANGUAGE plpgsql AS $fn$ BEGIN NEW.record_datetime := fuel.to_ts(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(NEW.raw->>'deleted_at'); NEW.message_fingerprint := NEW.raw->>'message_fingerprint'; NEW.updated_at := now(); RETURN NEW; END $fn$; DROP TRIGGER IF EXISTS trg_records_derive ON fuel.records; CREATE TRIGGER trg_records_derive BEFORE INSERT OR UPDATE ON fuel.records FOR EACH ROW EXECUTE FUNCTION fuel.tg_records_derive(); CREATE INDEX IF NOT EXISTS ix_fuel_records_plate ON fuel.records (plate); CREATE INDEX IF NOT EXISTS ix_fuel_records_datetime ON fuel.records (record_datetime); CREATE INDEX IF NOT EXISTS ix_fuel_records_dept ON fuel.records (department); CREATE INDEX IF NOT EXISTS ix_fuel_records_live ON fuel.records (record_datetime) WHERE deleted_at IS NULL; -- ── CDC watermark for import_fuel.py --changes ─────────────────────────────── CREATE TABLE IF NOT EXISTS fuel.ingest_state ( key text PRIMARY KEY, last_key text, updated_at timestamptz NOT NULL DEFAULT now() ); -- ── read view: live fills joined to the fleet by normalized plate ──────────── -- Joins devices so the Fuel Log tab reuses the same dims as the rest of FleetOps -- (cost_centre / assigned_city / assigned_driver / vehicle_number — the columns -- dashboard_api's _dim_filters expects). Keeps the fuel-native `department` and -- `driver` (from the WhatsApp message) for fuel-specific filtering. Soft-deleted -- rows are excluded here. Encapsulating the devices join means the read-only -- staging role only needs SELECT on this view, not on tracksolid.devices. 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.driver_name AS assigned_driver, d.imei, r.department, r.driver, r.liters, r.amount, r.fuel_type, r.odometer FROM fuel.records r LEFT JOIN tracksolid.devices d ON fuel.norm_plate(d.vehicle_number) = r.plate WHERE r.deleted_at IS NULL; -- ── per-vehicle fuel efficiency: km/litre from consecutive odometer readings ── -- Defensive: only positive, plausible (<5000 km) odometer deltas yield km/litre. 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; -- ── read-only grant (staging dashboard_api connects as grafana_ro) ─────────── -- Mirrors migrations/18_grant_reporting_ro.sql in the tracksolid repo. Guarded + -- idempotent. Only the reporting views are exposed (not fuel.records) — the views -- run with the owner's rights, so grafana_ro needs nothing on the base tables. 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$;