Ingest the RustFS fuel bucket → shared database → a new Fuel Log tab in FleetOps.
fuel bucket→
17_fleetfuel ingestion→
fuel schema · tracksolid_db→
dashboard_api→
FleetOps “Fuel Log” tab
FleetOps (15_fleetops) is the fleet operations analytics SPA. It already has a
trip-derived fuel panel (GET /analytics/fuel), but that data is effectively empty — estimated
fuel needs devices.fuel_100km (NULL fleet-wide) and actual oils is sparse.
A real fuel-spend feed now lands in the RustFS fuel bucket: WhatsApp fuel-update messages,
extracted by an n8n CDC job from logistics_department.fuel_records — 1,922 rows (Feb–Jun 2026)
of actual fills (litres, KES amount, odometer, fuel type, driver, department), keyed by number plate.
Goal (full vertical): a new 17_fleetfuel module pulls the bucket into the shared
tracksolid_db under its own fuel schema, exposes it via dashboard_api, and adds a
new “Fuel Log” tab to FleetOps. The existing trip-derived panel stays as-is — the two coexist.
This mirrors the proven 16_fleettickets module pattern exactly.
RUSTFS_* keys were pasted in chat. They go
only in a gitignored .env (never committed), and the shared secret should be
rotated after this work, since plaintext-in-chat counts as exposed.Bucket layout (s3.rahamafresh.com, path-style, region us-east-1):
fuel_records/latest.json — full snapshot, envelope { metadata, records[] }, ~1.7 MB / 1922 rows.fuel_records/changes/<ISO-ts>.json — hourly CDC deltas (same envelope, includes soft-deletes)..csv siblings exist, but we ingest the JSON (richer, typed).Record shape (stable PK id):
id, record_datetime, department, driver, car, liters, amount, fuel_type, odometer,
sender_name, sender_phone, raw_message, source, source_instance, source_message_id,
source_event_timestamp, message_fingerprint, deleted_at, deleted_by, delete_reason,
delete_source, created_at
The data is messy (WhatsApp-sourced) → normalization is essential:
car: KCA 542Q vs KCA542Q, plus junk (ANY VEH). 162 distinct.fuel_type: PETROL/DIESEL + typos (DISIEL, DISEL, PETRO, /PETROL, VPOWER, null).department: ~30 case/spelling variants of ~12 real departments (OSP/osp/Osp, ROLL-OUT/ROLLOUT).deleted_at set on 34 rows (soft-deleted — must be excluded from reporting).16_fleetticketsSelf-contained Python module → reads a RustFS bucket → upserts raw-jsonb rows into a namespaced schema in the
shared tracksolid_db → idempotent migrations with a schema_migrations ledger → a
reporting.* view consumed by dashboard_api → surfaced as a FleetOps tab. Reuse:
shared.py (DB ctx-mgr + clean), run_migrations.py (ledger runner), the
dry-run/--apply CLI convention, and the .env/pyproject/README layout.
17_fleetfuel newCreated in /Users/kianiadee/Downloads/projects/17_fleetfuel, files mirroring fleettickets:
pyproject.toml — psycopg2-binary, boto3 (the aws CLI isn’t available, and the CDC changes/ listing needs list_objects_v2 pagination, so boto3 beats CLI-subprocess). ruff dev dep.shared.py — copy verbatim (get_conn, get_logger, clean); rename logger ns to fleetfuel.run_migrations.py — copy; swap ledger to fuel.schema_migrations..env.example — DATABASE_URL, RUSTFS_ENDPOINT/ACCESS_KEY/SECRET_KEY/REGION, FUEL_BUCKET=fuel..gitignore — .env, __pycache__, .venv.README.md — what it owns vs. not (DB schema = ours; read-API = dashboard_api; frontend = fleetops).migrations/01_fuel_schema.sql — see Part B.import_fuel.py — the loader (below).s3util.py (optional) — thin boto3 client factory (endpoint_url + path-style addressing).import_fuel.pyRUSTFS_* env.--snapshot: GET fuel_records/latest.json, upsert all records on id. At 1922 rows / hourly cadence this full reconcile is trivial and self-healing (picks up edits + soft-deletes) → simplest correct design.--changes (optional, lower-latency): list fuel_records/changes/, process files newer than a watermark in fuel.ingest_state.--file <path>: local JSON for dev/testing.--apply writes; default is a dry-run logging parsed/valid/skipped counts.execute_values: INSERT … ON CONFLICT (id) DO UPDATE SET raw=EXCLUDED.raw, updated_at=now(). Derived/normalized columns populated by a DB trigger reading raw. Scrub JSON NaN → null first.migrations/01_fuel_schema.sql — the fuel schema newIdempotent, lives in shared tracksolid_db:
CREATE SCHEMA IF NOT EXISTS fuel; + reporting;fuel.norm_plate(text) → upper, strip non-alphanumeric (KCA 542Q→KCA542Q); null out junk.fuel.canon_fuel_type(text) → map typos to PETROL / DIESEL / VPOWER / OTHER / NULL.fuel.canon_department(text) → upper + collapse-ws + variant map to canonical set.fuel.records — raw-first + derived columns populated by a BEFORE INSERT/UPDATE trigger from raw: id, raw, record_datetime, plate, car_raw, liters, amount, fuel_type, department, driver, odometer, deleted_at, message_fingerprint, ingested_at, updated_at. Indexes on plate, record_datetime, department, partial WHERE deleted_at IS NULL.fuel.ingest_state — watermark for --changes mode.reporting.v_fuel_fills — read view: fuel.records (deleted_at IS NULL) LEFT JOIN tracksolid.devices d ON fuel.norm_plate(d.vehicle_number) = r.plate, exposing fuel_date, plate, vehicle_number, cost_centre, assigned_city, imei, department, driver, liters, amount, fuel_type, odometer. Same filter contract as reporting.v_daily_summary.reporting.v_fuel_efficiency (optional, high-value) — per-plate window over record_datetime: km = odometer − lag(odometer), km_per_litre = km / liters, with defensive bounds.USAGE + SELECT on the views to dashboard_ro (mirror tracksolid migration 18).dashboard_api read endpoints editIn dashboard_api_rev.py, add endpoints reusing _analytics_window + _dim_filters + RealDictCursor:
GET /analytics/fuel-fills — period/start/end + dims + optional department, fuel_type. Returns: totals (litres, spend_kes, fills, avg_price_per_litre, vehicles_fuelled), rows (per-vehicle), by_department, trend (daily litres + spend), data_status (unmatched-plate count).GET /analytics/fuel-fills/recent — recent N fills for the detail table.GET /analytics/filters to also return departments and fuel_types.No business logic in the API — it only selects from the reporting.* views.
In 15_fleetops/src/index.html (single-file SPA, inline JS + Chart.js). Leave the existing fuel panel untouched; add a “Fuel Log” tab:
/analytics/fuel-fills/recent.API_BASE mechanism.git init in 17_fleetfuel, add repo.rahamafresh.com/kianiadee/fleetfuel.git as origin, push (repo is currently empty).run_migrations.py then import_fuel.py --snapshot --apply hourly (matching the CDC cadence).dashboard_api + fleetops ride their existing Coolify pipelines (feature → staging → main).| File | Action |
|---|---|
17_fleetfuel/import_fuel.py, shared.py, run_migrations.py, pyproject.toml, .env.example, README.md | new mirror 16_fleettickets/* |
17_fleetfuel/migrations/01_fuel_schema.sql | new fuel schema + normalizers + reporting.v_fuel_fills |
tracksolid_timescale_grafana_prod/dashboard_api_rev.py | edit add /analytics/fuel-fills[/recent], extend /analytics/filters |
15_fleetops/src/index.html | edit add “Fuel Log” tab |
Reuse: 16_fleettickets/shared.py, run_migrations.py, the _scrub_nan/upsert shape; dashboard_api_rev.py:444 _dim_filters, _analytics_window; the stdlib SigV4 reader already proven this session (fallback if boto3 is undesirable).
latest.json = 1922 rows).python import_fuel.py --snapshot (no --apply): logs parsed/valid/skipped, no DB writes.python run_migrations.py then import_fuel.py --snapshot --apply. Spot-check: SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM fuel.records; (≈1922 / ≈1888) and plate-match rate in reporting.v_fuel_fills.curl "$API/analytics/fuel-fills?period=90d" → totals/rows/by_department/trend non-empty; /analytics/filters includes departments./verify the fleetops change once wired.