| 1 | -- =============================================================
|
|---|
| 2 | -- CityFix - advanced_db.sql (фаза P7)
|
|---|
| 3 | -- Домени, функции, тригери, процедури, погледи и позадинска задача.
|
|---|
| 4 | --
|
|---|
| 5 | -- Редослед на извршување:
|
|---|
| 6 | -- 1. schema_creation.sql
|
|---|
| 7 | -- 2. advanced_db.sql (оваа скрипта)
|
|---|
| 8 | -- 3. data_load.sql
|
|---|
| 9 | -- Скриптата може да се извршува повеќепати.
|
|---|
| 10 | -- =============================================================
|
|---|
| 11 |
|
|---|
| 12 | SET search_path TO project;
|
|---|
| 13 | SET client_min_messages TO warning;
|
|---|
| 14 |
|
|---|
| 15 | -- Тригерите и погледите зависат од колоните што се менуваат подолу, па прво се бришат.
|
|---|
| 16 | DROP TRIGGER IF EXISTS reports_before_insert ON reports;
|
|---|
| 17 | DROP TRIGGER IF EXISTS reports_before_status_update ON reports;
|
|---|
| 18 | DROP TRIGGER IF EXISTS status_logs_before_insert ON status_logs;
|
|---|
| 19 | DROP TRIGGER IF EXISTS status_logs_after_insert ON status_logs;
|
|---|
| 20 | DROP TRIGGER IF EXISTS reports_status_consistency ON reports;
|
|---|
| 21 | DROP TRIGGER IF EXISTS status_logs_immutable ON status_logs;
|
|---|
| 22 | DROP TRIGGER IF EXISTS assignments_before_insert ON assignments;
|
|---|
| 23 | DROP TRIGGER IF EXISTS comments_before_insert ON comments;
|
|---|
| 24 | DROP MATERIALIZED VIEW IF EXISTS mv_report_times; -- се креира повторно во other_topics.sql (P9)
|
|---|
| 25 | DROP VIEW IF EXISTS v_public_reports;
|
|---|
| 26 | DROP VIEW IF EXISTS v_report_overview;
|
|---|
| 27 | DROP VIEW IF EXISTS v_worker_workload;
|
|---|
| 28 |
|
|---|
| 29 |
|
|---|
| 30 | -- =============================================================
|
|---|
| 31 | -- 1. ДОМЕНИ
|
|---|
| 32 | -- =============================================================
|
|---|
| 33 | DO $$
|
|---|
| 34 | BEGIN
|
|---|
| 35 | IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|---|
| 36 | WHERE n.nspname = 'project' AND t.typname = 'email_address') THEN
|
|---|
| 37 | CREATE DOMAIN project.email_address AS VARCHAR(150)
|
|---|
| 38 | CHECK (VALUE ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');
|
|---|
| 39 | END IF;
|
|---|
| 40 | IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|---|
| 41 | WHERE n.nspname = 'project' AND t.typname = 'phone_number') THEN
|
|---|
| 42 | CREATE DOMAIN project.phone_number AS VARCHAR(20)
|
|---|
| 43 | CHECK (VALUE ~ '^\+?[0-9]{6,19}$');
|
|---|
| 44 | END IF;
|
|---|
| 45 | IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|---|
| 46 | WHERE n.nspname = 'project' AND t.typname = 'report_status') THEN
|
|---|
| 47 | CREATE DOMAIN project.report_status AS VARCHAR(20)
|
|---|
| 48 | CHECK (VALUE IN ('submitted', 'received', 'in_progress', 'resolved', 'rejected'));
|
|---|
| 49 | END IF;
|
|---|
| 50 | IF NOT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|---|
| 51 | WHERE n.nspname = 'project' AND t.typname = 'priority_level') THEN
|
|---|
| 52 | CREATE DOMAIN project.priority_level AS VARCHAR(10)
|
|---|
| 53 | CHECK (VALUE IN ('low', 'medium', 'high', 'urgent'));
|
|---|
| 54 | END IF;
|
|---|
| 55 | END $$;
|
|---|
| 56 |
|
|---|
| 57 | -- Колоните ги користат домените наместо посебните CHECK ограничувања од P2.
|
|---|
| 58 | ALTER TABLE admins DROP CONSTRAINT IF EXISTS ck_admins_email;
|
|---|
| 59 | ALTER TABLE workers DROP CONSTRAINT IF EXISTS ck_workers_email;
|
|---|
| 60 | ALTER TABLE citizens DROP CONSTRAINT IF EXISTS ck_citizens_email;
|
|---|
| 61 | ALTER TABLE citizens DROP CONSTRAINT IF EXISTS ck_citizens_phone;
|
|---|
| 62 | ALTER TABLE reports DROP CONSTRAINT IF EXISTS ck_reports_status;
|
|---|
| 63 | ALTER TABLE reports DROP CONSTRAINT IF EXISTS ck_reports_priority;
|
|---|
| 64 | ALTER TABLE status_logs DROP CONSTRAINT IF EXISTS ck_status_logs_status;
|
|---|
| 65 |
|
|---|
| 66 | ALTER TABLE admins ALTER COLUMN email TYPE email_address;
|
|---|
| 67 | ALTER TABLE workers ALTER COLUMN email TYPE email_address;
|
|---|
| 68 | ALTER TABLE citizens ALTER COLUMN email TYPE email_address;
|
|---|
| 69 | ALTER TABLE citizens ALTER COLUMN phone TYPE phone_number;
|
|---|
| 70 | ALTER TABLE reports ALTER COLUMN status TYPE report_status;
|
|---|
| 71 | ALTER TABLE reports ALTER COLUMN priority TYPE priority_level;
|
|---|
| 72 | ALTER TABLE status_logs ALTER COLUMN status TYPE report_status;
|
|---|
| 73 |
|
|---|
| 74 |
|
|---|
| 75 | -- =============================================================
|
|---|
| 76 | -- 2. ПОМОШНИ ФУНКЦИИ
|
|---|
| 77 | -- =============================================================
|
|---|
| 78 |
|
|---|
| 79 | -- Дозволени премини помеѓу статусите на пријавата.
|
|---|
| 80 | CREATE OR REPLACE FUNCTION status_transition_allowed(p_from TEXT, p_to TEXT)
|
|---|
| 81 | RETURNS BOOLEAN LANGUAGE sql IMMUTABLE AS $$
|
|---|
| 82 | SELECT (p_from, p_to) IN (('submitted', 'received'),
|
|---|
| 83 | ('submitted', 'rejected'),
|
|---|
| 84 | ('received', 'in_progress'),
|
|---|
| 85 | ('received', 'rejected'),
|
|---|
| 86 | ('in_progress', 'resolved'),
|
|---|
| 87 | ('in_progress', 'rejected'));
|
|---|
| 88 | $$;
|
|---|
| 89 |
|
|---|
| 90 | -- Нумерички ранг на приоритетот (low = 1 ... urgent = 4) и обратно.
|
|---|
| 91 | CREATE OR REPLACE FUNCTION priority_rank(p TEXT)
|
|---|
| 92 | RETURNS INTEGER LANGUAGE sql IMMUTABLE AS $$
|
|---|
| 93 | SELECT CASE p WHEN 'low' THEN 1 WHEN 'medium' THEN 2
|
|---|
| 94 | WHEN 'high' THEN 3 WHEN 'urgent' THEN 4 END;
|
|---|
| 95 | $$;
|
|---|
| 96 |
|
|---|
| 97 | CREATE OR REPLACE FUNCTION priority_from_rank(r INTEGER)
|
|---|
| 98 | RETURNS TEXT LANGUAGE sql IMMUTABLE AS $$
|
|---|
| 99 | SELECT (ARRAY['low', 'medium', 'high', 'urgent'])[GREATEST(1, LEAST(4, r))];
|
|---|
| 100 | $$;
|
|---|
| 101 |
|
|---|
| 102 | -- Рок за реакција (SLA) во денови според приоритетот.
|
|---|
| 103 | CREATE OR REPLACE FUNCTION sla_days(p TEXT)
|
|---|
| 104 | RETURNS INTEGER LANGUAGE sql IMMUTABLE AS $$
|
|---|
| 105 | SELECT CASE p WHEN 'urgent' THEN 1 WHEN 'high' THEN 3
|
|---|
| 106 | WHEN 'medium' THEN 7 ELSE 14 END;
|
|---|
| 107 | $$;
|
|---|
| 108 |
|
|---|
| 109 | -- Растојание во метри помеѓу две точки (формула на Haversine).
|
|---|
| 110 | CREATE OR REPLACE FUNCTION distance_m(lat1 NUMERIC, lon1 NUMERIC, lat2 NUMERIC, lon2 NUMERIC)
|
|---|
| 111 | RETURNS DOUBLE PRECISION LANGUAGE sql IMMUTABLE AS $$
|
|---|
| 112 | SELECT 2 * 6371000 * asin(sqrt(
|
|---|
| 113 | power(sin(radians(lat2 - lat1) / 2), 2)
|
|---|
| 114 | + cos(radians(lat1)) * cos(radians(lat2))
|
|---|
| 115 | * power(sin(radians(lon2 - lon1) / 2), 2)));
|
|---|
| 116 | $$;
|
|---|
| 117 |
|
|---|
| 118 | -- Активни пријави од иста категорија во даден радиус (за проверка на дупликати).
|
|---|
| 119 | CREATE OR REPLACE FUNCTION find_similar_reports(p_category_id INTEGER,
|
|---|
| 120 | p_latitude NUMERIC,
|
|---|
| 121 | p_longitude NUMERIC,
|
|---|
| 122 | p_radius_m DOUBLE PRECISION DEFAULT 150,
|
|---|
| 123 | p_exclude_report_id INTEGER DEFAULT NULL)
|
|---|
| 124 | RETURNS TABLE (report_id INTEGER, description TEXT, status TEXT,
|
|---|
| 125 | created_at TIMESTAMP, distance_m DOUBLE PRECISION)
|
|---|
| 126 | LANGUAGE sql STABLE AS $$
|
|---|
| 127 | SELECT r.report_id, r.description, r.status::TEXT, r.created_at,
|
|---|
| 128 | ROUND(distance_m(p_latitude, p_longitude, r.latitude, r.longitude)::NUMERIC, 1)
|
|---|
| 129 | FROM project.reports r
|
|---|
| 130 | WHERE r.category_id = p_category_id
|
|---|
| 131 | AND r.status NOT IN ('resolved', 'rejected')
|
|---|
| 132 | AND r.latitude IS NOT NULL
|
|---|
| 133 | AND r.report_id IS DISTINCT FROM p_exclude_report_id
|
|---|
| 134 | AND distance_m(p_latitude, p_longitude, r.latitude, r.longitude) <= p_radius_m
|
|---|
| 135 | ORDER BY 5;
|
|---|
| 136 | $$;
|
|---|
| 137 |
|
|---|
| 138 |
|
|---|
| 139 | -- =============================================================
|
|---|
| 140 | -- 3. ТРИГЕРИ - животен циклус и конзистентност на статусот
|
|---|
| 141 | -- =============================================================
|
|---|
| 142 |
|
|---|
| 143 | -- 3.1 Нова пријава: статусот мора да биде 'submitted'. Ако во близина веќе има
|
|---|
| 144 | -- барем 2 активни пријави за истиот проблем, приоритетот се крева на најмалку 'high'.
|
|---|
| 145 | CREATE OR REPLACE FUNCTION trg_reports_before_insert()
|
|---|
| 146 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 147 | DECLARE
|
|---|
| 148 | v_similar INTEGER;
|
|---|
| 149 | BEGIN
|
|---|
| 150 | IF NEW.status IS DISTINCT FROM 'submitted' THEN
|
|---|
| 151 | RAISE EXCEPTION 'Новата пријава мора да има статус submitted (добиено: %)', NEW.status;
|
|---|
| 152 | END IF;
|
|---|
| 153 | IF NEW.latitude IS NOT NULL THEN
|
|---|
| 154 | SELECT COUNT(*) INTO v_similar
|
|---|
| 155 | FROM find_similar_reports(NEW.category_id, NEW.latitude, NEW.longitude, 150);
|
|---|
| 156 | IF v_similar >= 2 AND priority_rank(NEW.priority) < priority_rank('high') THEN
|
|---|
| 157 | NEW.priority := 'high';
|
|---|
| 158 | END IF;
|
|---|
| 159 | END IF;
|
|---|
| 160 | RETURN NEW;
|
|---|
| 161 | END $$;
|
|---|
| 162 |
|
|---|
| 163 | CREATE TRIGGER reports_before_insert
|
|---|
| 164 | BEFORE INSERT ON reports
|
|---|
| 165 | FOR EACH ROW EXECUTE FUNCTION trg_reports_before_insert();
|
|---|
| 166 |
|
|---|
| 167 | -- 3.2 Промена на статусот директно во reports мора да биде дозволен премин.
|
|---|
| 168 | CREATE OR REPLACE FUNCTION trg_reports_before_status_update()
|
|---|
| 169 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 170 | BEGIN
|
|---|
| 171 | IF NEW.status IS DISTINCT FROM OLD.status
|
|---|
| 172 | AND NOT status_transition_allowed(OLD.status, NEW.status) THEN
|
|---|
| 173 | RAISE EXCEPTION 'Недозволена промена на статусот на пријавата % од % во %',
|
|---|
| 174 | OLD.report_id, OLD.status, NEW.status;
|
|---|
| 175 | END IF;
|
|---|
| 176 | RETURN NEW;
|
|---|
| 177 | END $$;
|
|---|
| 178 |
|
|---|
| 179 | CREATE TRIGGER reports_before_status_update
|
|---|
| 180 | BEFORE UPDATE OF status ON reports
|
|---|
| 181 | FOR EACH ROW EXECUTE FUNCTION trg_reports_before_status_update();
|
|---|
| 182 |
|
|---|
| 183 | -- 3.3 Нов запис во историјата: првиот мора да биде 'submitted' (без работник),
|
|---|
| 184 | -- секој следен мора да биде дозволен премин, да не е постар од претходниот
|
|---|
| 185 | -- и да го направи работник кој е доделен на пријавата.
|
|---|
| 186 | CREATE OR REPLACE FUNCTION trg_status_logs_before_insert()
|
|---|
| 187 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 188 | DECLARE
|
|---|
| 189 | v_last status_logs%ROWTYPE;
|
|---|
| 190 | BEGIN
|
|---|
| 191 | SELECT * INTO v_last
|
|---|
| 192 | FROM status_logs
|
|---|
| 193 | WHERE report_id = NEW.report_id
|
|---|
| 194 | ORDER BY changed_at DESC, log_id DESC
|
|---|
| 195 | LIMIT 1;
|
|---|
| 196 |
|
|---|
| 197 | IF NOT FOUND THEN
|
|---|
| 198 | IF NEW.status <> 'submitted' OR NEW.worker_id IS NOT NULL THEN
|
|---|
| 199 | RAISE EXCEPTION 'Првиот запис за пријавата % мора да биде submitted, без работник',
|
|---|
| 200 | NEW.report_id;
|
|---|
| 201 | END IF;
|
|---|
| 202 | RETURN NEW;
|
|---|
| 203 | END IF;
|
|---|
| 204 |
|
|---|
| 205 | IF NOT status_transition_allowed(v_last.status, NEW.status) THEN
|
|---|
| 206 | RAISE EXCEPTION 'Недозволена промена на статусот на пријавата % од % во %',
|
|---|
| 207 | NEW.report_id, v_last.status, NEW.status;
|
|---|
| 208 | END IF;
|
|---|
| 209 | IF NEW.changed_at < v_last.changed_at THEN
|
|---|
| 210 | RAISE EXCEPTION 'Промената не може да биде постара од претходната (%)', v_last.changed_at;
|
|---|
| 211 | END IF;
|
|---|
| 212 | IF NEW.worker_id IS NULL THEN
|
|---|
| 213 | RAISE EXCEPTION 'Промената на статусот мора да ја направи работник';
|
|---|
| 214 | END IF;
|
|---|
| 215 | IF NOT EXISTS (SELECT 1 FROM assignments a
|
|---|
| 216 | WHERE a.report_id = NEW.report_id AND a.worker_id = NEW.worker_id) THEN
|
|---|
| 217 | RAISE EXCEPTION 'Работникот % не е доделен на пријавата %', NEW.worker_id, NEW.report_id;
|
|---|
| 218 | END IF;
|
|---|
| 219 | RETURN NEW;
|
|---|
| 220 | END $$;
|
|---|
| 221 |
|
|---|
| 222 | CREATE TRIGGER status_logs_before_insert
|
|---|
| 223 | BEFORE INSERT ON status_logs
|
|---|
| 224 | FOR EACH ROW EXECUTE FUNCTION trg_status_logs_before_insert();
|
|---|
| 225 |
|
|---|
| 226 | -- 3.4 По внесот во историјата, статусот во reports автоматски се усогласува.
|
|---|
| 227 | CREATE OR REPLACE FUNCTION trg_status_logs_after_insert()
|
|---|
| 228 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 229 | BEGIN
|
|---|
| 230 | UPDATE reports
|
|---|
| 231 | SET status = NEW.status
|
|---|
| 232 | WHERE report_id = NEW.report_id
|
|---|
| 233 | AND status IS DISTINCT FROM NEW.status;
|
|---|
| 234 | RETURN NULL;
|
|---|
| 235 | END $$;
|
|---|
| 236 |
|
|---|
| 237 | CREATE TRIGGER status_logs_after_insert
|
|---|
| 238 | AFTER INSERT ON status_logs
|
|---|
| 239 | FOR EACH ROW EXECUTE FUNCTION trg_status_logs_after_insert();
|
|---|
| 240 |
|
|---|
| 241 | -- 3.5 Одложено ограничување: на крајот на трансакцијата статусот во reports
|
|---|
| 242 | -- мора да е еднаков на последниот запис во историјата.
|
|---|
| 243 | CREATE OR REPLACE FUNCTION trg_reports_status_consistency()
|
|---|
| 244 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 245 | DECLARE
|
|---|
| 246 | v_current TEXT;
|
|---|
| 247 | v_logged TEXT;
|
|---|
| 248 | BEGIN
|
|---|
| 249 | SELECT status INTO v_current FROM reports WHERE report_id = NEW.report_id;
|
|---|
| 250 | IF NOT FOUND THEN
|
|---|
| 251 | RETURN NULL; -- пријавата е избришана во истата трансакција
|
|---|
| 252 | END IF;
|
|---|
| 253 | SELECT status INTO v_logged
|
|---|
| 254 | FROM status_logs
|
|---|
| 255 | WHERE report_id = NEW.report_id
|
|---|
| 256 | ORDER BY changed_at DESC, log_id DESC
|
|---|
| 257 | LIMIT 1;
|
|---|
| 258 | IF v_logged IS DISTINCT FROM v_current THEN
|
|---|
| 259 | RAISE EXCEPTION 'Статусот на пријавата % (%) не одговара на историјата (%)',
|
|---|
| 260 | NEW.report_id, v_current, COALESCE(v_logged, 'нема запис');
|
|---|
| 261 | END IF;
|
|---|
| 262 | RETURN NULL;
|
|---|
| 263 | END $$;
|
|---|
| 264 |
|
|---|
| 265 | CREATE CONSTRAINT TRIGGER reports_status_consistency
|
|---|
| 266 | AFTER INSERT OR UPDATE OF status ON reports
|
|---|
| 267 | DEFERRABLE INITIALLY DEFERRED
|
|---|
| 268 | FOR EACH ROW EXECUTE FUNCTION trg_reports_status_consistency();
|
|---|
| 269 |
|
|---|
| 270 | -- 3.6 Историјата на статуси не смее да се менува ни брише (ревизорска трага).
|
|---|
| 271 | -- Дозволено е само каскадно бришење заедно со пријавата.
|
|---|
| 272 | CREATE OR REPLACE FUNCTION trg_status_logs_immutable()
|
|---|
| 273 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 274 | BEGIN
|
|---|
| 275 | IF TG_OP = 'DELETE'
|
|---|
| 276 | AND NOT EXISTS (SELECT 1 FROM reports WHERE report_id = OLD.report_id) THEN
|
|---|
| 277 | RETURN OLD; -- каскадно бришење: пријавата веќе е избришана
|
|---|
| 278 | END IF;
|
|---|
| 279 | RAISE EXCEPTION 'Историјата на статусите не може да се менува ни брише';
|
|---|
| 280 | END $$;
|
|---|
| 281 |
|
|---|
| 282 | CREATE TRIGGER status_logs_immutable
|
|---|
| 283 | BEFORE UPDATE OR DELETE ON status_logs
|
|---|
| 284 | FOR EACH ROW EXECUTE FUNCTION trg_status_logs_immutable();
|
|---|
| 285 |
|
|---|
| 286 |
|
|---|
| 287 | -- =============================================================
|
|---|
| 288 | -- 4. ТРИГЕРИ - правила за доделувања и коментари
|
|---|
| 289 | -- =============================================================
|
|---|
| 290 |
|
|---|
| 291 | -- 4.1 Затворена пријава не може да се доделува; доделувањето не смее
|
|---|
| 292 | -- да биде пред поднесувањето на пријавата.
|
|---|
| 293 | CREATE OR REPLACE FUNCTION trg_assignments_before_insert()
|
|---|
| 294 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 295 | DECLARE
|
|---|
| 296 | v_report reports%ROWTYPE;
|
|---|
| 297 | BEGIN
|
|---|
| 298 | SELECT * INTO v_report FROM reports WHERE report_id = NEW.report_id;
|
|---|
| 299 | IF v_report.status IN ('resolved', 'rejected') THEN
|
|---|
| 300 | RAISE EXCEPTION 'Пријавата % е затворена (%) и не може да се доделува',
|
|---|
| 301 | NEW.report_id, v_report.status;
|
|---|
| 302 | END IF;
|
|---|
| 303 | IF NEW.assigned_at < v_report.created_at THEN
|
|---|
| 304 | RAISE EXCEPTION 'Доделувањето не може да биде пред поднесувањето на пријавата';
|
|---|
| 305 | END IF;
|
|---|
| 306 | RETURN NEW;
|
|---|
| 307 | END $$;
|
|---|
| 308 |
|
|---|
| 309 | CREATE TRIGGER assignments_before_insert
|
|---|
| 310 | BEFORE INSERT ON assignments
|
|---|
| 311 | FOR EACH ROW EXECUTE FUNCTION trg_assignments_before_insert();
|
|---|
| 312 |
|
|---|
| 313 | -- 4.2 Коментар може да пишува само работник доделен на пријавата,
|
|---|
| 314 | -- и само додека пријавата не е затворена.
|
|---|
| 315 | CREATE OR REPLACE FUNCTION trg_comments_before_insert()
|
|---|
| 316 | RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
|---|
| 317 | DECLARE
|
|---|
| 318 | v_status TEXT;
|
|---|
| 319 | BEGIN
|
|---|
| 320 | IF NOT EXISTS (SELECT 1 FROM assignments a
|
|---|
| 321 | WHERE a.report_id = NEW.report_id AND a.worker_id = NEW.worker_id) THEN
|
|---|
| 322 | RAISE EXCEPTION 'Работникот % не е доделен на пријавата %', NEW.worker_id, NEW.report_id;
|
|---|
| 323 | END IF;
|
|---|
| 324 | SELECT status INTO v_status FROM reports WHERE report_id = NEW.report_id;
|
|---|
| 325 | IF v_status IN ('resolved', 'rejected') THEN
|
|---|
| 326 | RAISE EXCEPTION 'Пријавата % е затворена и не прима нови коментари', NEW.report_id;
|
|---|
| 327 | END IF;
|
|---|
| 328 | RETURN NEW;
|
|---|
| 329 | END $$;
|
|---|
| 330 |
|
|---|
| 331 | CREATE TRIGGER comments_before_insert
|
|---|
| 332 | BEFORE INSERT ON comments
|
|---|
| 333 | FOR EACH ROW EXECUTE FUNCTION trg_comments_before_insert();
|
|---|
| 334 |
|
|---|
| 335 |
|
|---|
| 336 | -- =============================================================
|
|---|
| 337 | -- 5. СКЛАДИРАНИ ПРОЦЕДУРИ И ФУНКЦИИ ЗА АПЛИКАЦИЈАТА
|
|---|
| 338 | -- =============================================================
|
|---|
| 339 |
|
|---|
| 340 | -- 5.1 Поднесување на пријава со фотографии во една операција.
|
|---|
| 341 | CREATE OR REPLACE FUNCTION submit_report(p_citizen_id INTEGER,
|
|---|
| 342 | p_category_id INTEGER,
|
|---|
| 343 | p_description TEXT,
|
|---|
| 344 | p_location_text TEXT,
|
|---|
| 345 | p_latitude NUMERIC,
|
|---|
| 346 | p_longitude NUMERIC,
|
|---|
| 347 | p_photos TEXT[] DEFAULT '{}')
|
|---|
| 348 | RETURNS INTEGER LANGUAGE plpgsql AS $$
|
|---|
| 349 | DECLARE
|
|---|
| 350 | v_report_id INTEGER;
|
|---|
| 351 | v_created_at TIMESTAMP;
|
|---|
| 352 | BEGIN
|
|---|
| 353 | INSERT INTO reports (description, location_text, latitude, longitude, category_id, citizen_id)
|
|---|
| 354 | VALUES (p_description, p_location_text, p_latitude, p_longitude, p_category_id, p_citizen_id)
|
|---|
| 355 | RETURNING report_id, created_at INTO v_report_id, v_created_at;
|
|---|
| 356 |
|
|---|
| 357 | INSERT INTO status_logs (status, changed_at, report_id, worker_id)
|
|---|
| 358 | VALUES ('submitted', v_created_at, v_report_id, NULL);
|
|---|
| 359 |
|
|---|
| 360 | INSERT INTO photos (image_url, report_id)
|
|---|
| 361 | SELECT '/uploads/reports/' || v_report_id || '/' || f, v_report_id
|
|---|
| 362 | FROM unnest(p_photos) AS f;
|
|---|
| 363 |
|
|---|
| 364 | RETURN v_report_id;
|
|---|
| 365 | END $$;
|
|---|
| 366 |
|
|---|
| 367 | -- 5.2 Промена на статусот од страна на работник.
|
|---|
| 368 | -- Тригерите ги проверуваат правилата и го усогласуваат статусот во reports.
|
|---|
| 369 | CREATE OR REPLACE PROCEDURE change_report_status(p_report_id INTEGER,
|
|---|
| 370 | p_worker_id INTEGER,
|
|---|
| 371 | p_new_status TEXT,
|
|---|
| 372 | p_note TEXT DEFAULT NULL)
|
|---|
| 373 | LANGUAGE plpgsql AS $$
|
|---|
| 374 | BEGIN
|
|---|
| 375 | INSERT INTO status_logs (status, note, report_id, worker_id)
|
|---|
| 376 | VALUES (p_new_status, p_note, p_report_id, p_worker_id);
|
|---|
| 377 | END $$;
|
|---|
| 378 |
|
|---|
| 379 | -- 5.3 Автоматско зголемување на приоритетот на пријави кои долго чекаат.
|
|---|
| 380 | -- Потребен минимален приоритет според деновите од последната промена на статусот:
|
|---|
| 381 | -- 3+ дена -> medium, 7+ дена -> high, 14+ дена -> urgent.
|
|---|
| 382 | CREATE OR REPLACE FUNCTION escalate_overdue_reports()
|
|---|
| 383 | RETURNS TABLE (report_id INTEGER, old_priority TEXT, new_priority TEXT, days_waiting INTEGER)
|
|---|
| 384 | LANGUAGE plpgsql AS $$
|
|---|
| 385 | BEGIN
|
|---|
| 386 | RETURN QUERY
|
|---|
| 387 | WITH waiting AS (
|
|---|
| 388 | SELECT r.report_id, r.priority::TEXT AS priority,
|
|---|
| 389 | EXTRACT(DAY FROM now()::TIMESTAMP - MAX(l.changed_at))::INTEGER AS days_waiting
|
|---|
| 390 | FROM project.reports r
|
|---|
| 391 | JOIN project.status_logs l ON l.report_id = r.report_id
|
|---|
| 392 | WHERE r.status NOT IN ('resolved', 'rejected')
|
|---|
| 393 | GROUP BY r.report_id
|
|---|
| 394 | ),
|
|---|
| 395 | target AS (
|
|---|
| 396 | SELECT w.report_id, w.priority, w.days_waiting,
|
|---|
| 397 | priority_from_rank(GREATEST(
|
|---|
| 398 | priority_rank(w.priority),
|
|---|
| 399 | CASE WHEN w.days_waiting >= 14 THEN 4
|
|---|
| 400 | WHEN w.days_waiting >= 7 THEN 3
|
|---|
| 401 | WHEN w.days_waiting >= 3 THEN 2
|
|---|
| 402 | ELSE 1 END)) AS new_priority
|
|---|
| 403 | FROM waiting w
|
|---|
| 404 | ),
|
|---|
| 405 | updated AS (
|
|---|
| 406 | UPDATE project.reports r
|
|---|
| 407 | SET priority = t.new_priority
|
|---|
| 408 | FROM target t
|
|---|
| 409 | WHERE r.report_id = t.report_id
|
|---|
| 410 | AND t.new_priority <> t.priority
|
|---|
| 411 | RETURNING r.report_id, t.priority, t.new_priority, t.days_waiting
|
|---|
| 412 | )
|
|---|
| 413 | SELECT * FROM updated ORDER BY 1;
|
|---|
| 414 | END $$;
|
|---|
| 415 |
|
|---|
| 416 |
|
|---|
| 417 | -- =============================================================
|
|---|
| 418 | -- 6. ПОГЛЕДИ
|
|---|
| 419 | -- =============================================================
|
|---|
| 420 |
|
|---|
| 421 | -- 6.1 Јавен преглед за мапата: без лични податоци за граѓаните.
|
|---|
| 422 | CREATE VIEW v_public_reports AS
|
|---|
| 423 | SELECT r.report_id, c.name AS category, r.description, r.location_text,
|
|---|
| 424 | r.latitude, r.longitude, r.status, r.priority, r.created_at,
|
|---|
| 425 | (SELECT COUNT(*) FROM photos p WHERE p.report_id = r.report_id) AS photo_count
|
|---|
| 426 | FROM reports r
|
|---|
| 427 | JOIN categories c ON c.category_id = r.category_id
|
|---|
| 428 | WHERE r.status NOT IN ('resolved', 'rejected')
|
|---|
| 429 | OR r.report_id IN (SELECT l.report_id FROM status_logs l
|
|---|
| 430 | WHERE l.status = 'resolved'
|
|---|
| 431 | AND l.changed_at >= now() - INTERVAL '30 days');
|
|---|
| 432 |
|
|---|
| 433 | -- 6.2 Целосен преглед за администраторите: доделени работници, последна промена и SLA.
|
|---|
| 434 | CREATE VIEW v_report_overview AS
|
|---|
| 435 | WITH last_change AS (
|
|---|
| 436 | SELECT report_id, MAX(changed_at) AS last_changed_at
|
|---|
| 437 | FROM status_logs
|
|---|
| 438 | GROUP BY report_id
|
|---|
| 439 | )
|
|---|
| 440 | SELECT r.report_id, c.name AS category, r.description, r.status, r.priority,
|
|---|
| 441 | ci.full_name AS citizen, r.created_at, lc.last_changed_at,
|
|---|
| 442 | (SELECT string_agg(w.full_name, ', ' ORDER BY w.full_name)
|
|---|
| 443 | FROM assignments a JOIN workers w ON w.worker_id = a.worker_id
|
|---|
| 444 | WHERE a.report_id = r.report_id) AS assigned_workers,
|
|---|
| 445 | EXTRACT(DAY FROM now()::TIMESTAMP - lc.last_changed_at)::INTEGER AS days_since_change,
|
|---|
| 446 | sla_days(r.priority) AS sla_days,
|
|---|
| 447 | (r.status NOT IN ('resolved', 'rejected')
|
|---|
| 448 | AND now()::TIMESTAMP - lc.last_changed_at > sla_days(r.priority) * INTERVAL '1 day') AS is_overdue
|
|---|
| 449 | FROM reports r
|
|---|
| 450 | JOIN categories c ON c.category_id = r.category_id
|
|---|
| 451 | JOIN citizens ci ON ci.citizen_id = r.citizen_id
|
|---|
| 452 | JOIN last_change lc ON lc.report_id = r.report_id;
|
|---|
| 453 |
|
|---|
| 454 | -- 6.3 Оптовареност и учинок на работниците.
|
|---|
| 455 | CREATE VIEW v_worker_workload AS
|
|---|
| 456 | WITH resolved AS (
|
|---|
| 457 | SELECT l.worker_id, l.report_id, l.changed_at AS resolved_at, r.created_at
|
|---|
| 458 | FROM status_logs l
|
|---|
| 459 | JOIN reports r ON r.report_id = l.report_id
|
|---|
| 460 | WHERE l.status = 'resolved'
|
|---|
| 461 | )
|
|---|
| 462 | SELECT w.worker_id, w.full_name,
|
|---|
| 463 | COUNT(DISTINCT r.report_id) FILTER (WHERE r.status NOT IN ('resolved', 'rejected'))
|
|---|
| 464 | AS active_reports,
|
|---|
| 465 | COUNT(DISTINCT r.report_id) FILTER (WHERE r.status NOT IN ('resolved', 'rejected')
|
|---|
| 466 | AND r.priority IN ('high', 'urgent'))
|
|---|
| 467 | AS active_high_priority,
|
|---|
| 468 | (SELECT COUNT(*) FROM resolved rs
|
|---|
| 469 | WHERE rs.worker_id = w.worker_id
|
|---|
| 470 | AND rs.resolved_at >= now() - INTERVAL '90 days') AS resolved_last_90_days,
|
|---|
| 471 | (SELECT ROUND(AVG(EXTRACT(EPOCH FROM rs.resolved_at - rs.created_at) / 86400)::NUMERIC, 1)
|
|---|
| 472 | FROM resolved rs WHERE rs.worker_id = w.worker_id) AS avg_resolution_days
|
|---|
| 473 | FROM workers w
|
|---|
| 474 | LEFT JOIN assignments a ON a.worker_id = w.worker_id
|
|---|
| 475 | LEFT JOIN reports r ON r.report_id = a.report_id
|
|---|
| 476 | GROUP BY w.worker_id, w.full_name;
|
|---|
| 477 |
|
|---|
| 478 |
|
|---|
| 479 | -- =============================================================
|
|---|
| 480 | -- 7. ПОЗАДИНСКА ЗАДАЧА
|
|---|
| 481 | -- Секој ден во 06:00 се зголемува приоритетот на пријавите кои долго чекаат.
|
|---|
| 482 | -- Потребна е екстензијата pg_cron; ако ја нема, задачата се извршува
|
|---|
| 483 | -- однадвор (на пример cron на серверот) со:
|
|---|
| 484 | -- psql -c "SELECT * FROM project.escalate_overdue_reports();"
|
|---|
| 485 | -- =============================================================
|
|---|
| 486 | SET client_min_messages TO notice;
|
|---|
| 487 | DO $$
|
|---|
| 488 | BEGIN
|
|---|
| 489 | IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
|
|---|
| 490 | PERFORM cron.schedule('cityfix-escalate-overdue', '0 6 * * *',
|
|---|
| 491 | 'SELECT * FROM project.escalate_overdue_reports()');
|
|---|
| 492 | RAISE NOTICE 'Позадинската задача е закажана со pg_cron.';
|
|---|
| 493 | ELSE
|
|---|
| 494 | RAISE NOTICE 'pg_cron не е достапен: escalate_overdue_reports() закажете ја однадвор.';
|
|---|
| 495 | END IF;
|
|---|
| 496 | END $$;
|
|---|