tracksolid_timescale_grafan.../08_analytics_config.sql

122 lines
7 KiB
MySQL
Raw Permalink Normal View History

feat(analytics): Phase 0 — analytics-config migration and CSV importer rewrite 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>
2026-04-27 20:42:37 +00:00
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-- 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;