Phase 0 of the three-stakeholder analytics redesign: - 08_analytics_config.sql: ops.cost_rates + ops.kpi_targets with seed fuel rates (KES 195/L NBO+MBA, UGX 5200/L KLA) and 6 seed KPI targets (utilisation_pct, idle_pct global+osp-patrol, fuel_kes_per_100km, mttr_hours, alarms_per_100km). Granted SELECT to grafana_ro. Wired into run_migrations.py MIGRATIONS. - import_drivers_csv.py: full rewrite for the new Mitieng CSV (20260427_FSG_Vehicles_mitieng.csv). Snake_case columns, drops _infer_city() plate-prefix logic in favour of reading assigned_city directly. Adds cost_centre, assigned_route, vehicle_category, vehicle_brand, fuel_100km, depot_address. Treats the literal "NULL" string as missing. Reuses clean(), clean_num(), clean_ts(), get_conn(), get_logger() from ts_shared_rev. Special-cases numeric and timestamptz columns in the UPDATE clause. - audit_device_reconciliation.py: read-only audit comparing the CSV against tracksolid.devices. Reports per-account row counts, IMEIs on one side only, and devices on both sides whose metadata is still NULL. - 260427_device_reconciliation.md + 260427_audit_output.txt: Phase 0.2 reconciliation record. First run: DB has 172 devices, CSV has 162, delta +10 (10 IMEIs in DB-only, mostly fireside-account auto-syncs). Importer run with --only-null --apply filled 154 rows; coverage now assigned_city 152/172, cost_centre 150/172. Applied to stage on 2026-04-27 23:35 UTC. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
121 lines
7 KiB
PL/PgSQL
121 lines
7 KiB
PL/PgSQL
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
-- Migration 08 — Analytics Configuration Tables
|
|
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
-- Adds reference data driving the three-stakeholder analytics redesign
|
|
-- (Phase 0.3 of the plan). These tables let downstream views monetise idle
|
|
-- and fuel costs, and apply traffic-light targets to KPIs without hard-coding
|
|
-- thresholds in SQL.
|
|
--
|
|
-- • ops.cost_rates — fuel price per litre by city, labour rate by role.
|
|
-- • ops.kpi_targets — green / amber / red thresholds per KPI per scope.
|
|
--
|
|
-- Run after migration 07. Safe to re-run (CREATE TABLE IF NOT EXISTS,
|
|
-- INSERT ... ON CONFLICT DO NOTHING).
|
|
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
BEGIN;
|
|
|
|
-- ── 1. ops.cost_rates ───────────────────────────────────────────────────────
|
|
-- Reference rates that power monetisation in analytics views. Lookup pattern
|
|
-- in views: WHERE scope_type = 'city' AND scope_value = <city> AND metric = ...
|
|
-- ORDER BY effective_from DESC LIMIT 1.
|
|
|
|
CREATE TABLE IF NOT EXISTS ops.cost_rates (
|
|
rate_key TEXT PRIMARY KEY,
|
|
scope_type TEXT NOT NULL, -- 'city' | 'role' | 'global'
|
|
scope_value TEXT, -- 'nairobi' | 'driver' | NULL for global
|
|
metric TEXT NOT NULL, -- 'fuel_per_litre' | 'labour_per_hour'
|
|
amount NUMERIC(12,2) NOT NULL,
|
|
currency TEXT NOT NULL, -- 'KES' | 'UGX'
|
|
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_cost_rates_lookup
|
|
ON ops.cost_rates (scope_type, scope_value, metric, effective_from DESC);
|
|
|
|
COMMENT ON TABLE ops.cost_rates
|
|
IS 'Reference rates for analytics monetisation: fuel price per litre by city, '
|
|
'labour cost per hour by role. Resolution order in views: scope_type=city '
|
|
'> scope_type=role > scope_type=global.';
|
|
COMMENT ON COLUMN ops.cost_rates.metric
|
|
IS 'fuel_per_litre | labour_per_hour';
|
|
COMMENT ON COLUMN ops.cost_rates.scope_type
|
|
IS 'city | role | global';
|
|
|
|
-- ── 2. ops.kpi_targets ──────────────────────────────────────────────────────
|
|
-- Traffic-light thresholds per KPI. Same KPI can have global + per-CC + per-city
|
|
-- rows; views use a CASE / COALESCE chain to pick the most specific match.
|
|
|
|
CREATE TABLE IF NOT EXISTS ops.kpi_targets (
|
|
target_id BIGSERIAL PRIMARY KEY,
|
|
kpi_key TEXT NOT NULL, -- e.g. 'utilisation_pct'
|
|
scope_type TEXT NOT NULL, -- 'global' | 'city' | 'cost_centre' | 'vehicle_category'
|
|
scope_value TEXT, -- NULL for global
|
|
target_value NUMERIC(12,2) NOT NULL,
|
|
amber_threshold NUMERIC(12,2), -- between target and red
|
|
red_threshold NUMERIC(12,2), -- worse than amber
|
|
direction TEXT NOT NULL DEFAULT 'higher_is_better',
|
|
-- 'higher_is_better' | 'lower_is_better'
|
|
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (kpi_key, scope_type, scope_value, effective_from)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_kpi_targets_lookup
|
|
ON ops.kpi_targets (kpi_key, scope_type, scope_value, effective_from DESC);
|
|
|
|
COMMENT ON TABLE ops.kpi_targets
|
|
IS 'Traffic-light targets per KPI per scope. Resolution order in views: '
|
|
'cost_centre > vehicle_category > city > global.';
|
|
COMMENT ON COLUMN ops.kpi_targets.direction
|
|
IS 'higher_is_better -> green when value >= target. '
|
|
'lower_is_better -> green when value <= target.';
|
|
|
|
-- ── 3. Seed cost rates ──────────────────────────────────────────────────────
|
|
-- Placeholder values — confirm with Finance and update via a follow-up insert
|
|
-- with a later effective_from date (do NOT mutate historical rows).
|
|
|
|
INSERT INTO ops.cost_rates
|
|
(rate_key, scope_type, scope_value, metric, amount, currency, notes)
|
|
VALUES
|
|
('fuel.nairobi', 'city', 'nairobi', 'fuel_per_litre', 195.00, 'KES',
|
|
'Placeholder pump price — confirm with Finance.'),
|
|
('fuel.mombasa', 'city', 'mombasa', 'fuel_per_litre', 195.00, 'KES',
|
|
'Placeholder pump price — confirm with Finance.'),
|
|
('fuel.kampala', 'city', 'kampala', 'fuel_per_litre', 5200.00, 'UGX',
|
|
'Placeholder pump price — confirm with Finance.')
|
|
ON CONFLICT (rate_key) DO NOTHING;
|
|
|
|
-- ── 4. Seed KPI targets ─────────────────────────────────────────────────────
|
|
-- Initial Exco-relevant targets. Calibrate after one month of clean data.
|
|
|
|
INSERT INTO ops.kpi_targets
|
|
(kpi_key, scope_type, scope_value, target_value, amber_threshold,
|
|
red_threshold, direction, notes)
|
|
VALUES
|
|
('utilisation_pct', 'global', NULL, 70, 60, 50, 'higher_is_better',
|
|
'Fleet utilisation: drive_hours / engine_on_hours.'),
|
|
('idle_pct', 'global', NULL, 15, 20, 25, 'lower_is_better',
|
|
'Idle as % of engine-on time.'),
|
|
('idle_pct', 'cost_centre', 'osp patrol', 15, 20, 25, 'lower_is_better',
|
|
'OSP patrol idle target — same as global until calibrated.'),
|
|
('fuel_kes_per_100km', 'global', NULL, 12, 14, 16, 'lower_is_better',
|
|
'Fuel litres per 100km equivalent — uses fuel_100km on devices.'),
|
|
('mttr_hours', 'global', NULL, 4, 6, 8, 'lower_is_better',
|
|
'Mean Time To Resolve, field-service ticket.'),
|
|
('alarms_per_100km', 'global', NULL, 2, 3, 5, 'lower_is_better',
|
|
'Safety event density.')
|
|
ON CONFLICT (kpi_key, scope_type, scope_value, effective_from) DO NOTHING;
|
|
|
|
-- ── 5. Read access for Grafana ──────────────────────────────────────────────
|
|
|
|
GRANT USAGE ON SCHEMA ops TO grafana_ro;
|
|
GRANT SELECT ON ops.cost_rates TO grafana_ro;
|
|
GRANT SELECT ON ops.kpi_targets TO grafana_ro;
|
|
|
|
COMMIT;
|