Standalone module extracted from the tracksolid repo (was migrations 21-23 + tools/import_tickets.py). Owns the `tickets` schema in the shared tracksolid_db. - migrations/01_tickets_schema.sql: consolidated final-state schema (tickets.inc/ crq raw-jsonb-first, geo_clusters + geo_locations gazetteers, geom trigger, reporting.fn_tickets_for_map) - import_tickets.py: rustfs bucket ingest + cluster/location geocoding (LocationIQ/OpenCage, viewbox-bounded + cluster-distance guard) - run_migrations.py, shared.py (self-contained), pyproject, .env.example, README The DB stays in tracksolid_db; dashboard_api keeps serving /webhook/tickets; the Tickets map stays a FleetOps tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
14 KiB
PL/PgSQL
276 lines
14 KiB
PL/PgSQL
-- 01_tickets_schema.sql — fleettickets · INC/CRQ ticket store (raw-jsonb-first)
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- Consolidated final-state schema for the field-ops ticket layer. This supersedes
|
|
-- the historical tracksolid migrations 21→23 (where this feature was first built);
|
|
-- fleettickets now owns the `tickets` schema. The schema lives in the shared
|
|
-- database (`tracksolid_db` today) so the existing dashboard_api read-API and the
|
|
-- FleetOps Tickets map keep working unchanged.
|
|
--
|
|
-- tickets.inc / tickets.crq one raw-jsonb row per ticket (ticket_id + raw +
|
|
-- derived geom/geo_source). INC = incident/fault,
|
|
-- CRQ = new-installation.
|
|
-- tickets.geo_clusters cluster -> coordinate gazetteer (coarse fallback)
|
|
-- tickets.geo_locations cleaned-location -> coordinate cache (precise)
|
|
-- reporting.fn_tickets_for_map GeoJSON read function consumed by dashboard_api
|
|
--
|
|
-- geom resolution: feed coords (raw lat/lng) -> location cache -> cluster centroid
|
|
-- -> none. Idempotent: safe on a fresh DB and re-appliable on the live DB.
|
|
-- Requires PostGIS (present on the shared DB; on a brand-new DB a superuser must
|
|
-- run CREATE EXTENSION postgis first).
|
|
-- ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
CREATE SCHEMA IF NOT EXISTS tickets;
|
|
CREATE SCHEMA IF NOT EXISTS reporting; -- shared read layer (the fn lives here for dashboard_api)
|
|
SET search_path = tickets, public;
|
|
|
|
-- ── normalize helper (generic upper/collapse/trim key) ───────────────────────
|
|
CREATE OR REPLACE FUNCTION tickets.norm_cluster(p text)
|
|
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
|
AS $fn$ SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '') $fn$;
|
|
|
|
-- ── gazetteer: cluster -> coordinates (coarse fallback) ──────────────────────
|
|
CREATE TABLE IF NOT EXISTS tickets.geo_clusters (
|
|
cluster_key text PRIMARY KEY,
|
|
region text,
|
|
lat double precision,
|
|
lng double precision,
|
|
geom geometry(Point, 4326),
|
|
source text,
|
|
verified boolean NOT NULL DEFAULT false,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE OR REPLACE FUNCTION tickets.tg_geo_clusters_geom()
|
|
RETURNS trigger LANGUAGE plpgsql AS $fn$
|
|
BEGIN
|
|
IF NEW.lat IS NOT NULL AND NEW.lng IS NOT NULL
|
|
AND NEW.lat BETWEEN -90 AND 90 AND NEW.lng BETWEEN -180 AND 180
|
|
AND NOT (NEW.lat = 0 AND NEW.lng = 0) THEN
|
|
NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lng, NEW.lat), 4326);
|
|
ELSE
|
|
NEW.geom := NULL;
|
|
END IF;
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END $fn$;
|
|
DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tickets.geo_clusters;
|
|
CREATE TRIGGER trg_geo_clusters_geom BEFORE INSERT OR UPDATE ON tickets.geo_clusters
|
|
FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_clusters_geom();
|
|
|
|
-- ── location geocode cache: cleaned location_name -> coordinates (precise) ───
|
|
-- query_key = tickets.norm_cluster(location_name); resolve joins on it without
|
|
-- re-deriving the place-extraction regex (that lives in the loader).
|
|
CREATE TABLE IF NOT EXISTS tickets.geo_locations (
|
|
query_key text PRIMARY KEY,
|
|
location_name text,
|
|
cluster text,
|
|
region text,
|
|
query text,
|
|
lat double precision,
|
|
lng double precision,
|
|
geom geometry(Point, 4326),
|
|
confidence numeric,
|
|
provider text,
|
|
verified boolean NOT NULL DEFAULT false,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE OR REPLACE FUNCTION tickets.tg_geo_locations_geom()
|
|
RETURNS trigger LANGUAGE plpgsql AS $fn$
|
|
BEGIN
|
|
IF NEW.lat IS NOT NULL AND NEW.lng IS NOT NULL
|
|
AND NEW.lat BETWEEN -90 AND 90 AND NEW.lng BETWEEN -180 AND 180
|
|
AND NOT (NEW.lat = 0 AND NEW.lng = 0) THEN
|
|
NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lng, NEW.lat), 4326);
|
|
ELSE
|
|
NEW.geom := NULL;
|
|
END IF;
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END $fn$;
|
|
DROP TRIGGER IF EXISTS trg_geo_locations_geom ON tickets.geo_locations;
|
|
CREATE TRIGGER trg_geo_locations_geom BEFORE INSERT OR UPDATE ON tickets.geo_locations
|
|
FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_locations_geom();
|
|
|
|
-- ── per-type ticket tables (raw-jsonb-first) ─────────────────────────────────
|
|
CREATE TABLE IF NOT EXISTS tickets.inc (
|
|
ticket_id text PRIMARY KEY,
|
|
raw jsonb NOT NULL,
|
|
geom geometry(Point, 4326),
|
|
geo_source text, -- 'feed' | 'location' | 'cluster' | 'none'
|
|
ingested_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tickets.crq (LIKE tickets.inc INCLUDING ALL);
|
|
|
|
-- ── geom trigger — read from raw; never clobber a deliberate geom-only update ─
|
|
CREATE OR REPLACE FUNCTION tickets.tg_ticket_geom()
|
|
RETURNS trigger LANGUAGE plpgsql AS $fn$
|
|
DECLARE
|
|
v_lat double precision := NULLIF(NEW.raw->>'latitude','')::double precision;
|
|
v_lng double precision := NULLIF(NEW.raw->>'longitude','')::double precision;
|
|
g geometry(Point, 4326);
|
|
BEGIN
|
|
IF TG_OP = 'UPDATE' AND NEW.raw IS NOT DISTINCT FROM OLD.raw THEN
|
|
RETURN NEW; -- geom/geo_source-only update — keep caller's value
|
|
END IF;
|
|
IF v_lat IS NOT NULL AND v_lng IS NOT NULL
|
|
AND v_lat BETWEEN -90 AND 90 AND v_lng BETWEEN -180 AND 180
|
|
AND NOT (v_lat = 0 AND v_lng = 0) THEN
|
|
NEW.geom := ST_SetSRID(ST_MakePoint(v_lng, v_lat), 4326);
|
|
NEW.geo_source := 'feed';
|
|
ELSE
|
|
SELECT gc.geom INTO g FROM tickets.geo_clusters gc
|
|
WHERE gc.cluster_key = tickets.norm_cluster(NEW.raw->>'cluster') AND gc.geom IS NOT NULL LIMIT 1;
|
|
IF g IS NOT NULL THEN NEW.geom := g; NEW.geo_source := 'cluster';
|
|
ELSE NEW.geom := NULL; NEW.geo_source := 'none'; END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END $fn$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_inc_geom ON tickets.inc;
|
|
CREATE TRIGGER trg_inc_geom BEFORE INSERT OR UPDATE ON tickets.inc
|
|
FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom();
|
|
DROP TRIGGER IF EXISTS trg_crq_geom ON tickets.crq;
|
|
CREATE TRIGGER trg_crq_geom BEFORE INSERT OR UPDATE ON tickets.crq
|
|
FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom();
|
|
|
|
-- ── resolve — prefer location cache, else cluster centroid (non-feed rows) ───
|
|
CREATE OR REPLACE FUNCTION tickets.resolve_ticket_geoms()
|
|
RETURNS integer LANGUAGE plpgsql AS $fn$
|
|
DECLARE n integer; m integer;
|
|
BEGIN
|
|
UPDATE tickets.inc t
|
|
SET geom = COALESCE(loc.geom, gc.geom),
|
|
geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location'
|
|
WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END
|
|
FROM tickets.inc base
|
|
LEFT JOIN tickets.geo_locations loc
|
|
ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL
|
|
LEFT JOIN tickets.geo_clusters gc
|
|
ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL
|
|
WHERE t.ticket_id = base.ticket_id
|
|
AND t.geo_source IS DISTINCT FROM 'feed'
|
|
AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom)
|
|
OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location'
|
|
WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END);
|
|
GET DIAGNOSTICS n = ROW_COUNT;
|
|
|
|
UPDATE tickets.crq t
|
|
SET geom = COALESCE(loc.geom, gc.geom),
|
|
geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location'
|
|
WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END
|
|
FROM tickets.crq base
|
|
LEFT JOIN tickets.geo_locations loc
|
|
ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL
|
|
LEFT JOIN tickets.geo_clusters gc
|
|
ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL
|
|
WHERE t.ticket_id = base.ticket_id
|
|
AND t.geo_source IS DISTINCT FROM 'feed'
|
|
AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom)
|
|
OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location'
|
|
WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END);
|
|
GET DIAGNOSTICS m = ROW_COUNT;
|
|
RETURN n + m;
|
|
END $fn$;
|
|
|
|
-- ── indexes ───────────────────────────────────────────────────────────────────
|
|
CREATE INDEX IF NOT EXISTS ix_inc_status_raw ON tickets.inc ((raw->>'normalized_status'));
|
|
CREATE INDEX IF NOT EXISTS ix_inc_actionable_raw ON tickets.inc (((raw->>'is_actionable')::boolean))
|
|
WHERE (raw->>'is_actionable')::boolean;
|
|
CREATE INDEX IF NOT EXISTS ix_inc_cluster_raw ON tickets.inc (tickets.norm_cluster(raw->>'cluster'));
|
|
CREATE INDEX IF NOT EXISTS ix_inc_loc_raw ON tickets.inc (tickets.norm_cluster(raw->>'location_name'));
|
|
CREATE INDEX IF NOT EXISTS ix_inc_geom ON tickets.inc USING gist (geom);
|
|
CREATE INDEX IF NOT EXISTS ix_crq_status_raw ON tickets.crq ((raw->>'normalized_status'));
|
|
CREATE INDEX IF NOT EXISTS ix_crq_actionable_raw ON tickets.crq (((raw->>'is_actionable')::boolean))
|
|
WHERE (raw->>'is_actionable')::boolean;
|
|
CREATE INDEX IF NOT EXISTS ix_crq_cluster_raw ON tickets.crq (tickets.norm_cluster(raw->>'cluster'));
|
|
CREATE INDEX IF NOT EXISTS ix_crq_loc_raw ON tickets.crq (tickets.norm_cluster(raw->>'location_name'));
|
|
CREATE INDEX IF NOT EXISTS ix_crq_geom ON tickets.crq USING gist (geom);
|
|
|
|
-- ── read function (GeoJSON; consumed by dashboard_api GET /webhook/tickets) ──
|
|
CREATE OR REPLACE FUNCTION reporting.fn_tickets_for_map(
|
|
p_service_type text DEFAULT NULL,
|
|
p_status text DEFAULT NULL,
|
|
p_open_only boolean DEFAULT true
|
|
)
|
|
RETURNS jsonb LANGUAGE plpgsql STABLE AS $fn$
|
|
DECLARE v_result jsonb;
|
|
BEGIN
|
|
p_service_type := lower(NULLIF(p_service_type, ''));
|
|
p_status := NULLIF(p_status, '');
|
|
WITH filtered AS (
|
|
SELECT 'inc'::text AS service_type, raw, geom, geo_source FROM tickets.inc
|
|
WHERE geom IS NOT NULL
|
|
AND (p_service_type IS NULL OR p_service_type = 'inc')
|
|
AND (p_status IS NULL OR raw->>'normalized_status' = p_status)
|
|
AND (NOT p_open_only OR (raw->>'is_actionable')::boolean IS TRUE)
|
|
UNION ALL
|
|
SELECT 'crq'::text AS service_type, raw, geom, geo_source FROM tickets.crq
|
|
WHERE geom IS NOT NULL
|
|
AND (p_service_type IS NULL OR p_service_type = 'crq')
|
|
AND (p_status IS NULL OR raw->>'normalized_status' = p_status)
|
|
AND (NOT p_open_only OR (raw->>'is_actionable')::boolean IS TRUE)
|
|
)
|
|
SELECT jsonb_build_object(
|
|
'summary', jsonb_build_object(
|
|
'ticket_count', COUNT(*),
|
|
'inc', COUNT(*) FILTER (WHERE service_type = 'inc'),
|
|
'crq', COUNT(*) FILTER (WHERE service_type = 'crq'),
|
|
'open', COUNT(*) FILTER (WHERE (raw->>'is_actionable')::boolean IS TRUE),
|
|
'by_status', (SELECT jsonb_object_agg(s, c)
|
|
FROM (SELECT raw->>'normalized_status' AS s, COUNT(*) AS c
|
|
FROM filtered GROUP BY raw->>'normalized_status') z)
|
|
),
|
|
'geojson', jsonb_build_object(
|
|
'type', 'FeatureCollection',
|
|
'features', COALESCE(jsonb_agg(
|
|
jsonb_build_object(
|
|
'type', 'Feature',
|
|
'properties', jsonb_build_object(
|
|
'ticket_id', raw->>'ticket_id',
|
|
'service_type', service_type,
|
|
'status', raw->>'normalized_status',
|
|
'raw_status', raw->>'raw_status',
|
|
'cluster', raw->>'cluster',
|
|
'region', raw->>'region',
|
|
'location_name', raw->>'location_name',
|
|
'department', raw->>'department',
|
|
'owner', raw->>'owner',
|
|
'assigned_team', raw->>'assigned_team',
|
|
'sla_status', raw->>'sla_status',
|
|
'is_actionable', (raw->>'is_actionable')::boolean,
|
|
'geo_source', geo_source,
|
|
'created_at', raw->>'created_at_service',
|
|
'scheduled_at', raw->>'scheduled_at'
|
|
),
|
|
'geometry', ST_AsGeoJSON(geom)::jsonb
|
|
)
|
|
), '[]'::jsonb)
|
|
)
|
|
) INTO v_result FROM filtered;
|
|
RETURN v_result;
|
|
END $fn$;
|
|
|
|
COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS
|
|
'INC/CRQ tickets (tickets.inc + tickets.crq, raw-jsonb-first) as GeoJSON. fleettickets 01.';
|
|
|
|
-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
|
|
DO $grants$
|
|
BEGIN
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tracksolid_owner') THEN
|
|
GRANT USAGE, CREATE ON SCHEMA tickets TO tracksolid_owner;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tickets TO tracksolid_owner;
|
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tickets TO tracksolid_owner;
|
|
END IF;
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
|
GRANT USAGE ON SCHEMA tickets TO dashboard_ro;
|
|
GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations TO dashboard_ro;
|
|
GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO dashboard_ro;
|
|
END IF;
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
|
|
GRANT USAGE ON SCHEMA tickets TO grafana_ro;
|
|
GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations TO grafana_ro;
|
|
GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro;
|
|
END IF;
|
|
END $grants$;
|