248 lines
11 KiB
MySQL
248 lines
11 KiB
MySQL
|
|
-- 21_tickets.sql
|
||
|
|
-- Field-ops TICKET layer for FleetOps: INC (incident / customer fault) + CRQ
|
||
|
|
-- (new-installation request). Tickets are produced by the client's email
|
||
|
|
-- automation as full-snapshot files in the rustfs `tickets` bucket
|
||
|
|
-- (automations/{inc,crq}/latest.json) and ingested by tools/import_tickets.py
|
||
|
|
-- into tracksolid.tickets. The map read layer is reporting.fn_tickets_for_map,
|
||
|
|
-- served by dashboard_api GET /webhook/tickets and rendered as INC/CRQ overlay
|
||
|
|
-- layers in the FleetOps "Tickets" tab (FleetNow-style map).
|
||
|
|
--
|
||
|
|
-- Geocoding: source rows carry NO coordinates (latitude/longitude are null in
|
||
|
|
-- every row observed). geom is resolved from a small cluster gazetteer
|
||
|
|
-- (tracksolid.geo_clusters), or directly from the feed lat/lng if the upstream
|
||
|
|
-- ever starts populating them. Until the gazetteer is seeded, geom is NULL and
|
||
|
|
-- tickets are simply not mapped (fn_tickets_for_map filters geom IS NOT NULL).
|
||
|
|
--
|
||
|
|
-- Safe to re-apply (IF NOT EXISTS / CREATE OR REPLACE / DROP TRIGGER IF EXISTS).
|
||
|
|
|
||
|
|
SET search_path = tracksolid, reporting, public;
|
||
|
|
|
||
|
|
-- ── normalize helper (cluster_key) ────────────────────────────────────────────
|
||
|
|
-- upper + collapse internal whitespace + trim; '' → NULL. IMMUTABLE so it can
|
||
|
|
-- back a functional index.
|
||
|
|
CREATE OR REPLACE FUNCTION tracksolid.norm_cluster(p text)
|
||
|
|
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||
|
|
AS $function$
|
||
|
|
SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '')
|
||
|
|
$function$;
|
||
|
|
|
||
|
|
-- ── gazetteer: normalized cluster → coordinates ──────────────────────────────
|
||
|
|
CREATE TABLE IF NOT EXISTS tracksolid.geo_clusters (
|
||
|
|
cluster_key text PRIMARY KEY, -- tracksolid.norm_cluster(cluster)
|
||
|
|
region text,
|
||
|
|
lat double precision,
|
||
|
|
lng double precision,
|
||
|
|
geom geometry(Point, 4326),
|
||
|
|
source text, -- 'nominatim' | 'manual' | …
|
||
|
|
verified boolean NOT NULL DEFAULT false,
|
||
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE OR REPLACE FUNCTION tracksolid.tg_geo_clusters_geom()
|
||
|
|
RETURNS trigger LANGUAGE plpgsql
|
||
|
|
AS $function$
|
||
|
|
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 $function$;
|
||
|
|
|
||
|
|
DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tracksolid.geo_clusters;
|
||
|
|
CREATE TRIGGER trg_geo_clusters_geom
|
||
|
|
BEFORE INSERT OR UPDATE ON tracksolid.geo_clusters
|
||
|
|
FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_geo_clusters_geom();
|
||
|
|
|
||
|
|
-- ── tickets ───────────────────────────────────────────────────────────────────
|
||
|
|
-- Columns mirror the source snapshot schema (32 fields, identical for INC/CRQ)
|
||
|
|
-- 1:1, plus locally-derived geom / geo_source / raw / ingested_at.
|
||
|
|
CREATE TABLE IF NOT EXISTS tracksolid.tickets (
|
||
|
|
ticket_id text PRIMARY KEY,
|
||
|
|
source_type text,
|
||
|
|
service_type text, -- 'inc' | 'crq' (type discriminator)
|
||
|
|
bucket text,
|
||
|
|
raw_status text,
|
||
|
|
normalized_status text,
|
||
|
|
created_at_service timestamptz,
|
||
|
|
scheduled_at timestamptz,
|
||
|
|
closed_at timestamptz,
|
||
|
|
last_seen_at timestamptz,
|
||
|
|
first_seen_at timestamptz,
|
||
|
|
week_start date,
|
||
|
|
week_end date,
|
||
|
|
cluster text,
|
||
|
|
region text,
|
||
|
|
location_name text,
|
||
|
|
latitude double precision,
|
||
|
|
longitude double precision,
|
||
|
|
department text,
|
||
|
|
assigned_team text,
|
||
|
|
owner text,
|
||
|
|
sla_status text,
|
||
|
|
mttr double precision,
|
||
|
|
is_auto_created boolean,
|
||
|
|
is_auto_closed boolean,
|
||
|
|
is_alarm boolean,
|
||
|
|
is_actionable boolean,
|
||
|
|
source_s3_bucket text,
|
||
|
|
source_s3_key text,
|
||
|
|
source_snapshot_id bigint,
|
||
|
|
created_at timestamptz,
|
||
|
|
updated_at timestamptz,
|
||
|
|
-- locally derived
|
||
|
|
geom geometry(Point, 4326),
|
||
|
|
geo_source text, -- 'feed' | 'cluster' | 'none'
|
||
|
|
raw jsonb, -- full source row (hedge for new fields)
|
||
|
|
ingested_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
CONSTRAINT tickets_service_type_chk
|
||
|
|
CHECK (service_type IS NULL OR service_type IN ('inc', 'crq'))
|
||
|
|
);
|
||
|
|
|
||
|
|
-- geom resolution: feed coords first, else cluster gazetteer, else none.
|
||
|
|
CREATE OR REPLACE FUNCTION tracksolid.tg_tickets_geom()
|
||
|
|
RETURNS trigger LANGUAGE plpgsql
|
||
|
|
AS $function$
|
||
|
|
DECLARE
|
||
|
|
g geometry(Point, 4326);
|
||
|
|
BEGIN
|
||
|
|
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL
|
||
|
|
AND NEW.latitude BETWEEN -90 AND 90
|
||
|
|
AND NEW.longitude BETWEEN -180 AND 180
|
||
|
|
AND NOT (NEW.latitude = 0 AND NEW.longitude = 0) THEN
|
||
|
|
NEW.geom := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
|
||
|
|
NEW.geo_source := 'feed';
|
||
|
|
ELSE
|
||
|
|
SELECT gc.geom INTO g
|
||
|
|
FROM tracksolid.geo_clusters gc
|
||
|
|
WHERE gc.cluster_key = tracksolid.norm_cluster(NEW.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 $function$;
|
||
|
|
|
||
|
|
DROP TRIGGER IF EXISTS trg_tickets_geom ON tracksolid.tickets;
|
||
|
|
CREATE TRIGGER trg_tickets_geom
|
||
|
|
BEFORE INSERT OR UPDATE ON tracksolid.tickets
|
||
|
|
FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_tickets_geom();
|
||
|
|
|
||
|
|
-- Re-resolve geoms for already-loaded tickets after the gazetteer is (re)seeded,
|
||
|
|
-- without re-running ingestion. Skips rows already pinned by feed coordinates.
|
||
|
|
-- Returns the number of rows updated. Call: SELECT tracksolid.resolve_ticket_geoms();
|
||
|
|
CREATE OR REPLACE FUNCTION tracksolid.resolve_ticket_geoms()
|
||
|
|
RETURNS integer LANGUAGE plpgsql
|
||
|
|
AS $function$
|
||
|
|
DECLARE n integer;
|
||
|
|
BEGIN
|
||
|
|
UPDATE tracksolid.tickets t
|
||
|
|
SET geom = gc.geom, geo_source = 'cluster'
|
||
|
|
FROM tracksolid.geo_clusters gc
|
||
|
|
WHERE gc.cluster_key = tracksolid.norm_cluster(t.cluster)
|
||
|
|
AND gc.geom IS NOT NULL
|
||
|
|
AND t.geo_source IS DISTINCT FROM 'feed'
|
||
|
|
AND t.geom IS DISTINCT FROM gc.geom;
|
||
|
|
GET DIAGNOSTICS n = ROW_COUNT;
|
||
|
|
RETURN n;
|
||
|
|
END $function$;
|
||
|
|
|
||
|
|
-- ── indexes ───────────────────────────────────────────────────────────────────
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_service_type ON tracksolid.tickets (service_type);
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_status ON tracksolid.tickets (normalized_status);
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_type_status ON tracksolid.tickets (service_type, normalized_status);
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_actionable ON tracksolid.tickets (is_actionable) WHERE is_actionable;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_geom ON tracksolid.tickets USING gist (geom);
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_tickets_cluster ON tracksolid.tickets (tracksolid.norm_cluster(cluster));
|
||
|
|
|
||
|
|
-- ── map read function ─────────────────────────────────────────────────────────
|
||
|
|
-- Modeled on reporting.fn_live_positions: NULLIF-cleaned params, single filtered
|
||
|
|
-- CTE, returns { summary, geojson:FeatureCollection }. Maps only geocoded rows
|
||
|
|
-- (geom IS NOT NULL). p_open_only restricts to actionable (open) tickets — the
|
||
|
|
-- map default, since the bulk of INC are CLOSED.
|
||
|
|
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 $function$
|
||
|
|
DECLARE
|
||
|
|
v_result jsonb;
|
||
|
|
BEGIN
|
||
|
|
p_service_type := lower(NULLIF(p_service_type, ''));
|
||
|
|
p_status := NULLIF(p_status, '');
|
||
|
|
WITH filtered AS (
|
||
|
|
SELECT *
|
||
|
|
FROM tracksolid.tickets
|
||
|
|
WHERE geom IS NOT NULL
|
||
|
|
AND (p_service_type IS NULL OR service_type = p_service_type)
|
||
|
|
AND (p_status IS NULL OR normalized_status = p_status)
|
||
|
|
AND (NOT p_open_only OR is_actionable 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 is_actionable IS TRUE),
|
||
|
|
'by_status', (SELECT jsonb_object_agg(s, c)
|
||
|
|
FROM (SELECT normalized_status AS s, COUNT(*) AS c
|
||
|
|
FROM filtered GROUP BY normalized_status) z)
|
||
|
|
),
|
||
|
|
'geojson', jsonb_build_object(
|
||
|
|
'type', 'FeatureCollection',
|
||
|
|
'features', COALESCE(jsonb_agg(
|
||
|
|
jsonb_build_object(
|
||
|
|
'type', 'Feature',
|
||
|
|
'properties', jsonb_build_object(
|
||
|
|
'ticket_id', ticket_id,
|
||
|
|
'service_type', service_type,
|
||
|
|
'status', normalized_status,
|
||
|
|
'raw_status', raw_status,
|
||
|
|
'cluster', cluster,
|
||
|
|
'region', region,
|
||
|
|
'location_name', location_name,
|
||
|
|
'department', department,
|
||
|
|
'owner', owner,
|
||
|
|
'assigned_team', assigned_team,
|
||
|
|
'sla_status', sla_status,
|
||
|
|
'is_actionable', is_actionable,
|
||
|
|
'geo_source', geo_source,
|
||
|
|
'created_at', to_char(created_at_service, 'YYYY-MM-DD HH24:MI:SS'),
|
||
|
|
'scheduled_at', to_char(scheduled_at, 'YYYY-MM-DD HH24:MI:SS')
|
||
|
|
),
|
||
|
|
'geometry', ST_AsGeoJSON(geom)::jsonb
|
||
|
|
)
|
||
|
|
), '[]'::jsonb)
|
||
|
|
)
|
||
|
|
) INTO v_result FROM filtered;
|
||
|
|
|
||
|
|
RETURN v_result;
|
||
|
|
END $function$;
|
||
|
|
|
||
|
|
COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS
|
||
|
|
'INC/CRQ tickets as a GeoJSON FeatureCollection for the FleetOps Tickets map. Migration 21.';
|
||
|
|
|
||
|
|
-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
|
||
|
|
DO $grants$
|
||
|
|
BEGIN
|
||
|
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
|
||
|
|
GRANT USAGE ON SCHEMA tracksolid TO dashboard_ro;
|
||
|
|
GRANT SELECT ON tracksolid.tickets, tracksolid.geo_clusters 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 SELECT ON tracksolid.tickets, tracksolid.geo_clusters TO grafana_ro;
|
||
|
|
GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro;
|
||
|
|
END IF;
|
||
|
|
END $grants$;
|