Changes between Initial Version and Version 1 of AdvancedDatabaseDevelopment


Ignore:
Timestamp:
09/19/26 02:00:05 (12 days ago)
Author:
183164
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedDatabaseDevelopment

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