-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- Migration 06 — Business Analytics Schema Support -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -- Adds the schema objects referenced by 01_BusinessAnalytics.md: -- • tracksolid.devices.assigned_city (§3.7 Geographic Drift) -- • tracksolid.dispatch_log (§4.4, §4.5 Field-Service SLAs) -- • ops schema (external ops integration namespace) -- • ops.service_log (§10 Service-Interval Forecaster) -- • ops.odometer_readings (§3.8 Odometer Divergence) -- • ops.tickets (§2.4 Cost-per-Ticket — skeleton) -- • ops.vw_service_forecast (§10 weekly booking view) -- -- Run after migration 05. Safe to re-run (uses IF NOT EXISTS / DO NOTHING / -- CREATE OR REPLACE). -- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BEGIN; -- ── 1. City cohort column (§3.7) ───────────────────────────────────────────── ALTER TABLE tracksolid.devices ADD COLUMN IF NOT EXISTS assigned_city TEXT; COMMENT ON COLUMN tracksolid.devices.assigned_city IS 'Operating territory code: NBO (Nairobi) | MBA (Mombasa) | KLA (Kampala). ' 'Used for city-cohort analytics and geographic drift detection.'; CREATE INDEX IF NOT EXISTS idx_devices_assigned_city ON tracksolid.devices (assigned_city) WHERE assigned_city IS NOT NULL; -- ── 2. Dispatch log (§4.4, §4.5) ────────────────────────────────────────────── -- One row per ticket dispatch. Populated by n8n / ops integration at -- assignment; back-filled by nightly job using trips + live_positions. CREATE TABLE IF NOT EXISTS tracksolid.dispatch_log ( dispatch_id BIGSERIAL PRIMARY KEY, ticket_id TEXT NOT NULL, imei TEXT NOT NULL REFERENCES tracksolid.devices(imei), driver_name TEXT, job_lat DOUBLE PRECISION NOT NULL, job_lng DOUBLE PRECISION NOT NULL, job_geom geometry(Point, 4326), assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), first_movement_at TIMESTAMPTZ, on_site_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ, cancelled_at TIMESTAMPTZ, distance_km NUMERIC(8, 2), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_dispatch_log_ticket ON tracksolid.dispatch_log (ticket_id); CREATE INDEX IF NOT EXISTS idx_dispatch_log_imei_assigned ON tracksolid.dispatch_log (imei, assigned_at DESC); CREATE INDEX IF NOT EXISTS idx_dispatch_log_assigned_at ON tracksolid.dispatch_log (assigned_at DESC); CREATE INDEX IF NOT EXISTS idx_dispatch_log_job_geom ON tracksolid.dispatch_log USING GIST (job_geom); COMMENT ON TABLE tracksolid.dispatch_log IS 'Persistent record of every dispatch decision. Powers SLA metrics: ' 'dispatch latency, depart delay, time-to-site, wrench time.'; COMMENT ON COLUMN tracksolid.dispatch_log.first_movement_at IS 'First trip start after assigned_at. Back-filled nightly from trips.'; COMMENT ON COLUMN tracksolid.dispatch_log.on_site_at IS 'Time vehicle entered 150 m radius of job_geom. Back-filled nightly.'; COMMENT ON COLUMN tracksolid.dispatch_log.resolved_at IS 'Ticket close time from the ops system (ops.tickets.closed_at).'; -- ── 3. ops schema namespace ─────────────────────────────────────────────────── -- Separates Fireside operations domain (tickets, services, odometers) from -- the tracksolid telematics namespace so ownership / grants can diverge. CREATE SCHEMA IF NOT EXISTS ops; COMMENT ON SCHEMA ops IS 'Fireside operations domain: tickets, service logs, odometer readings. ' 'Distinct from tracksolid.* which holds telematics data.'; -- ── 4. Service log (§10) ────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS ops.service_log ( service_id BIGSERIAL PRIMARY KEY, imei TEXT NOT NULL REFERENCES tracksolid.devices(imei), service_date DATE NOT NULL, odometer_km INTEGER NOT NULL, service_type TEXT, cost_kes INTEGER, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_service_log_imei_date ON ops.service_log (imei, service_date DESC); COMMENT ON TABLE ops.service_log IS 'Workshop service history. Powers §10 Service-Interval Forecaster.'; COMMENT ON COLUMN ops.service_log.service_type IS 'scheduled | repair | tyre | bodywork | inspection | other'; COMMENT ON COLUMN ops.service_log.odometer_km IS 'Physical odometer reading at service time (integer km).'; -- ── 5. Odometer readings (§3.8) ─────────────────────────────────────────────── -- Periodic physical odometer captures from service events, fuel card receipts, -- or manual driver entry. Divergence vs tracker-computed distance flags -- sensor drift or tamper. CREATE TABLE IF NOT EXISTS ops.odometer_readings ( reading_id BIGSERIAL PRIMARY KEY, imei TEXT NOT NULL REFERENCES tracksolid.devices(imei), reading_date DATE NOT NULL, reading_km INTEGER NOT NULL, source TEXT, recorded_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (imei, reading_date) ); CREATE INDEX IF NOT EXISTS idx_odometer_readings_imei_date ON ops.odometer_readings (imei, reading_date DESC); COMMENT ON TABLE ops.odometer_readings IS 'Physical odometer captures from service, fuel card, or manual entry. ' 'Powers §3.8 Odometer Divergence audit.'; COMMENT ON COLUMN ops.odometer_readings.source IS 'service | fuel_card | driver_manual | workshop_form'; -- ── 6. Tickets skeleton (§2.4) ─────────────────────────────────────────────── -- MINIMAL skeleton so the Cost-per-Ticket query is runnable. In production, -- this table is expected to be populated by the Fireside ticketing system -- (Zoho/Freshdesk/job-management export) via n8n or a direct feed. Schema -- is intentionally narrow — extend with columns specific to your source. CREATE TABLE IF NOT EXISTS ops.tickets ( ticket_id TEXT PRIMARY KEY, assigned_imei TEXT REFERENCES tracksolid.devices(imei), driver_name TEXT, customer TEXT, job_type TEXT, priority TEXT, status TEXT NOT NULL DEFAULT 'open', created_at TIMESTAMPTZ NOT NULL, assigned_at TIMESTAMPTZ, closed_at TIMESTAMPTZ, job_lat DOUBLE PRECISION, job_lng DOUBLE PRECISION, job_geom geometry(Point, 4326), ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON ops.tickets (status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_tickets_assigned_imei ON ops.tickets (assigned_imei) WHERE assigned_imei IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tickets_closed_at ON ops.tickets (closed_at DESC NULLS LAST); COMMENT ON TABLE ops.tickets IS 'Skeleton for ticket data sourced from the Fireside ops system. ' 'Replace or extend to match the actual feed (Zoho Desk, Freshdesk, etc).'; COMMENT ON COLUMN ops.tickets.status IS 'open | assigned | in_progress | resolved | cancelled'; -- ── 7. Service forecast view (§10) ──────────────────────────────────────────── -- Wraps the §10 forecaster CTE so the weekly booking query in -- 01_BusinessAnalytics.md references a stable object. CREATE OR REPLACE VIEW ops.vw_service_forecast AS WITH last_service AS ( SELECT DISTINCT ON (imei) imei, service_date, odometer_km FROM ops.service_log WHERE service_type = 'scheduled' ORDER BY imei, service_date DESC ), current_odometer AS ( SELECT imei, current_mileage_km FROM tracksolid.devices ), trailing_rate AS ( SELECT imei, SUM(distance_km) / 30.0 AS km_per_day_30d FROM tracksolid.trips WHERE start_time > NOW() - INTERVAL '30 days' AND end_time IS NOT NULL GROUP BY imei ) SELECT d.imei, d.driver_name, d.vehicle_number, ls.service_date AS last_service_date, ls.odometer_km AS last_service_odo, co.current_mileage_km AS current_odo, (co.current_mileage_km - COALESCE(ls.odometer_km, 0)) AS km_since_service, GREATEST( 0, 10000 - (co.current_mileage_km - COALESCE(ls.odometer_km, 0)) ) AS km_to_next_service, ROUND(tr.km_per_day_30d, 1) AS km_per_day_30d, CASE WHEN tr.km_per_day_30d > 0 THEN CURRENT_DATE + ( GREATEST(0, 10000 - (co.current_mileage_km - COALESCE(ls.odometer_km, 0))) / tr.km_per_day_30d )::INT ELSE NULL END AS projected_service_date FROM tracksolid.devices d LEFT JOIN last_service ls ON ls.imei = d.imei LEFT JOIN current_odometer co ON co.imei = d.imei LEFT JOIN trailing_rate tr ON tr.imei = d.imei WHERE d.enabled_flag = 1; COMMENT ON VIEW ops.vw_service_forecast IS 'Projected next-service date per vehicle based on 30-day km rate. ' 'Service interval default 10,000 km — override at query time if needed.'; COMMIT;