sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
-- =============================================================================
-- CRUD PATTERNS — Sáng kiến application system
-- =============================================================================
-- =============================================================================
-- CREATE: Submit a new application with multiple authors (atomic)
-- =============================================================================
BEGIN;
-- Set audit context
SELECT set_config('my.user_id', '42', true);
-- 1. Main record
INSERT INTO applications(code, title, registration_year, status, purpose,
is_technical_solution, primary_unit_id, created_by)
VALUES ('SK-2025-007',
'Hệ thống tự động điền hồ sơ sáng kiến',
2025, 'DRAFT',
'Tự động hoá việc điền các mẫu số 0104',
TRUE, 2, 42)
RETURNING application_id \gset
-- 2. Authors (defer contribution-sum check until COMMIT)
SET CONSTRAINTS trg_contribution_total DEFERRED;
INSERT INTO application_authors(application_id, user_id, contribution_pct, role, display_order) VALUES
(:application_id, 42, 60.00, 'PRIMARY', 1),
(:application_id, 13, 25.00, 'CO_AUTHOR', 2),
(:application_id, 27, 15.00, 'CO_AUTHOR', 3);
-- 3. Orgs that tested it
INSERT INTO application_adopters(application_id, org_name, address, field) VALUES
(:application_id, 'Phòng KHCN', '217 Hồng Bàng, Q.5', 'Cải cách hành chính');
COMMIT;
-- =============================================================================
-- READ: Dashboard — paginated list with filters
-- =============================================================================
SELECT * FROM v_application_summary
WHERE registration_year = 2025
AND status = ANY(ARRAY['UNDER_REVIEW','EVALUATED']::text[])
AND title ILIKE '%động vật%' -- uses trigram index
ORDER BY avg_score DESC NULLS LAST, submitted_at DESC
LIMIT 20 OFFSET 0;
-- Read: full application with nested data (app layer usually does this as N queries
-- or one JSON aggregate — here's the aggregate version)
SELECT jsonb_build_object(
'application', to_jsonb(a.*),
'authors', (SELECT jsonb_agg(jsonb_build_object(
'user_id', u.user_id,
'name', u.full_name,
'pct', aa.contribution_pct,
'role', aa.role
) ORDER BY aa.display_order)
FROM application_authors aa
JOIN users u USING (user_id)
WHERE aa.application_id = a.application_id),
'evaluations',(SELECT jsonb_agg(to_jsonb(e.*))
FROM evaluations e WHERE e.application_id = a.application_id),
'attachments',(SELECT jsonb_agg(to_jsonb(att.*))
FROM attachments att WHERE att.application_id = a.application_id)
) AS document
FROM applications a
WHERE a.application_id = 1 AND a.deleted_at IS NULL;
-- Full-text search (Vietnamese-friendly; combine with unaccent for better recall)
SELECT application_id, code, title
FROM applications
WHERE to_tsvector('simple', title || ' ' || coalesce(introduction,''))
@@ plainto_tsquery('simple', 'đạo đức động vật')
ORDER BY registration_year DESC
LIMIT 10;
-- =============================================================================
-- UPDATE: Progress an application through the workflow
-- =============================================================================
-- Submit (DRAFT → SUBMITTED). Triggers populate submitted_at automatically.
UPDATE applications SET status = 'SUBMITTED' WHERE application_id = 7;
-- Assign to review panel
UPDATE applications SET status = 'UNDER_REVIEW' WHERE application_id = 7;
-- Upsert an evaluation (same evaluator re-scores)
INSERT INTO evaluations (application_id, evaluator_id, novelty_score, effectiveness_score, conclusion)
VALUES (7, 99, 32, 48, 'Đề nghị công nhận')
ON CONFLICT (application_id, evaluator_id)
DO UPDATE SET
novelty_score = EXCLUDED.novelty_score,
effectiveness_score = EXCLUDED.effectiveness_score,
conclusion = EXCLUDED.conclusion,
evaluated_at = NOW();
-- Update JSONB field: patch a single effectiveness sub-field
UPDATE applications
SET effectiveness = effectiveness || jsonb_build_object(
'economic',
'Tiết kiệm ~30% thời gian xét duyệt'
)
WHERE application_id = 7;
-- Partial update (PATCH-style) — only update provided fields. The app layer
-- generates SET clauses from the non-null fields in the request body.
UPDATE applications
SET title = COALESCE($1, title),
purpose = COALESCE($2, purpose),
updated_at = NOW()
WHERE application_id = $3 AND deleted_at IS NULL
RETURNING *;
-- =============================================================================
-- DELETE: Soft delete + restore
-- =============================================================================
-- Soft delete
UPDATE applications SET deleted_at = NOW() WHERE application_id = 7;
-- Restore
UPDATE applications SET deleted_at = NULL WHERE application_id = 7;
-- Hard delete (only for drafts, cascades to authors/evaluations/etc.)
DELETE FROM applications
WHERE application_id = 7
AND status = 'DRAFT';
-- =============================================================================
-- ANALYTICS: Materialized-view refresh (run nightly via cron/pgAgent)
-- =============================================================================
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_annual_stats;
-- Leaderboard: top-scoring approved innovations
SELECT code, title, avg_score
FROM v_application_summary
WHERE status = 'APPROVED'
ORDER BY avg_score DESC
LIMIT 10;
-- =============================================================================
-- REVIEW JSON: Persist / retrieve ReviewPanel bundle
-- =============================================================================
BEGIN;
SELECT set_config('my.user_id', '42', true);
-- Latest app version number for this application
WITH v AS (
SELECT COALESCE(MAX(document_version), 0) + 1 AS next_ver
FROM application_review_documents
WHERE application_id = 7
)
INSERT INTO application_review_documents(
application_id, case_id, document_version, official_bieu_mau, template_data, full_bundle, created_by
)
SELECT
7,
'CASE-2026-0007',
v.next_ver,
$${"TRANG BÌA":{"Tên sáng kiến (Tiếng Việt)":"Ví dụ"}}$$::jsonb,
$${"initiativeName":"Ví dụ"}$$::jsonb,
$${"meta":{"caseId":"CASE-2026-0007"}}$$::jsonb,
42
FROM v;
COMMIT;
-- Load latest ReviewPanel bundle by case id
SELECT *
FROM application_review_documents
WHERE case_id = 'CASE-2026-0007'
ORDER BY document_version DESC, created_at DESC
LIMIT 1;
+441
View File
@@ -0,0 +1,441 @@
-- =============================================================================
-- SÁNG KIẾN (INNOVATION APPLICATION) DATABASE SCHEMA
-- PostgreSQL 14+
--
-- Domain: Manage innovation applications at ĐHYD TP.HCM (Vietnamese medical
-- university). Supports the full lifecycle: draft → submit → evaluate → approve.
--
-- Design principles:
-- - 3NF for entities, JSONB for semi-structured/optional narrative
-- - Soft delete (deleted_at) — legal/audit requires historical retention
-- - State machine on applications.status enforced by trigger
-- - Full audit_log via trigger on all CUD operations
-- - Contribution % sums to 100 enforced by DEFERRABLE trigger
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy matching
CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese diacritics in search
-- Convenience: updated_at auto-maintenance
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at := NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
-- =============================================================================
-- REFERENCE: UNITS (departments, faculties, centers)
-- =============================================================================
CREATE TABLE units (
unit_id SERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL, -- full Vietnamese name
parent_unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL,
type VARCHAR(32) NOT NULL
CHECK (type IN ('TRUONG','KHOA','PHONG','BO_MON','TRUNG_TAM','KHAC')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER trg_units_touch BEFORE UPDATE ON units
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- =============================================================================
-- USERS (unified: authors, evaluators, admins — a user can wear many hats)
-- =============================================================================
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
full_name VARCHAR(255) NOT NULL,
title VARCHAR(64), -- PGS.TS, TS., GS., CN., ThS.
date_of_birth DATE,
email VARCHAR(255) UNIQUE,
phone VARCHAR(32),
id_number VARCHAR(32) UNIQUE, -- CCCD / hộ chiếu
unit_id INT REFERENCES units(unit_id) ON DELETE SET NULL,
position VARCHAR(255), -- chức danh: Trưởng phòng, GV cao cấp
qualification VARCHAR(64), -- trình độ: Tiến sĩ, Thạc sĩ, Cử nhân
user_type VARCHAR(32) NOT NULL DEFAULT 'AUTHOR'
CHECK (user_type IN ('AUTHOR','COUNCIL','ADMIN','STUDENT','EXTERNAL')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
deleted_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_unit ON users(unit_id);
CREATE INDEX idx_users_active ON users(is_active) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_name_trgm ON users USING GIN (full_name gin_trgm_ops);
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- =============================================================================
-- APPLICATIONS (sáng kiến) — the core entity
-- =============================================================================
CREATE TABLE applications (
application_id SERIAL PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL, -- e.g., 'SK-2025-001'
title TEXT NOT NULL,
title_en TEXT,
registration_year INT NOT NULL CHECK (registration_year BETWEEN 2000 AND 2100),
field_of_application TEXT, -- lĩnh vực áp dụng
-- Workflow state (enforced by trigger below)
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN (
'DRAFT','SUBMITTED','UNDER_REVIEW',
'EVALUATED','APPROVED','REJECTED','WITHDRAWN'
)),
-- Mẫu 01 narrative (long text)
introduction TEXT, -- 1. Mở đầu
current_state TEXT, -- 4.1 Tình trạng đã biết
purpose TEXT, -- Mục đích
implementation_steps TEXT, -- Các bước thực hiện
required_conditions TEXT, -- Điều kiện cần thiết
results_achieved TEXT, -- Kết quả thu được
novelty_description TEXT, -- Tính mới
confidential_info TEXT, -- Thông tin cần bảo mật
-- 10 effectiveness sub-fields (all optional narrative) → JSONB
effectiveness JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Shape: { "economic":"...", "teaching":"...", "productivity":"...",
-- "work_efficiency":"...", "quality":"...", "cost_reduction":"...",
-- "environment":"...", "health":"...", "safety":"...", "awareness":"..." }
-- Mẫu 02 fields
owner_org VARCHAR(255), -- chủ đầu tư
first_applied_date DATE, -- ngày áp dụng lần đầu
content_summary TEXT, -- nội dung sáng kiến (short)
author_assessment TEXT, -- đánh giá theo tác giả
org_assessment TEXT, -- đánh giá theo tổ chức
-- Mẫu 02 classification (mutually exclusive in form, but stored as flags)
is_technical_solution BOOLEAN NOT NULL DEFAULT FALSE,
is_from_research_article BOOLEAN NOT NULL DEFAULT FALSE,
is_from_book_material BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_exactly_one_classification CHECK (
status = 'DRAFT' OR
(is_technical_solution::int + is_from_research_article::int + is_from_book_material::int) = 1
),
-- Workflow timestamps
submitted_at TIMESTAMPTZ,
decided_at TIMESTAMPTZ,
primary_unit_id INT REFERENCES units(unit_id),
created_by INT REFERENCES users(user_id),
deleted_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_apps_status ON applications(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_apps_year ON applications(registration_year);
CREATE INDEX idx_apps_unit ON applications(primary_unit_id);
CREATE INDEX idx_apps_title_trgm ON applications USING GIN (title gin_trgm_ops);
CREATE INDEX idx_apps_fts ON applications USING GIN (
to_tsvector('simple',
coalesce(title,'') || ' ' ||
coalesce(introduction,'') || ' ' ||
coalesce(novelty_description,'')
)
);
CREATE INDEX idx_apps_effectiveness ON applications USING GIN (effectiveness);
CREATE TRIGGER trg_apps_touch BEFORE UPDATE ON applications
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- =============================================================================
-- APPLICATION_AUTHORS (M:N with contribution %)
-- =============================================================================
CREATE TABLE application_authors (
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
user_id INT NOT NULL REFERENCES users(user_id),
contribution_pct NUMERIC(5,2) NOT NULL CHECK (contribution_pct > 0 AND contribution_pct <= 100),
role VARCHAR(32) NOT NULL DEFAULT 'CO_AUTHOR'
CHECK (role IN ('PRIMARY','CO_AUTHOR')),
display_order INT NOT NULL DEFAULT 0,
PRIMARY KEY (application_id, user_id)
);
CREATE INDEX idx_app_authors_user ON application_authors(user_id);
-- At most one PRIMARY author per application
CREATE UNIQUE INDEX uq_primary_per_app
ON application_authors(application_id) WHERE role = 'PRIMARY';
-- Deferrable check: contribution % must total 100 per application
CREATE OR REPLACE FUNCTION check_contribution_total() RETURNS TRIGGER AS $$
DECLARE v_total NUMERIC; v_app INT;
BEGIN
v_app := COALESCE(NEW.application_id, OLD.application_id);
SELECT COALESCE(SUM(contribution_pct),0) INTO v_total
FROM application_authors WHERE application_id = v_app;
-- Only enforce when application has left DRAFT
IF (SELECT status FROM applications WHERE application_id = v_app) <> 'DRAFT'
AND v_total <> 100 THEN
RAISE EXCEPTION 'Contribution % for application % must sum to 100 (got %)',
'%', v_app, v_total;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER trg_contribution_total
AFTER INSERT OR UPDATE OR DELETE ON application_authors
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_contribution_total();
-- =============================================================================
-- ORGS that tested / adopted the innovation (Mẫu 01 inner table)
-- =============================================================================
CREATE TABLE application_adopters (
adopter_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
display_order INT NOT NULL DEFAULT 0,
org_name VARCHAR(255) NOT NULL,
address TEXT,
field TEXT
);
CREATE INDEX idx_adopters_app ON application_adopters(application_id);
-- =============================================================================
-- PARTICIPANTS in first application (Mẫu 02 inner table)
-- =============================================================================
CREATE TABLE application_participants (
participant_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
user_id INT REFERENCES users(user_id), -- optional link
display_order INT NOT NULL DEFAULT 0,
full_name VARCHAR(255) NOT NULL,
date_of_birth DATE,
work_unit VARCHAR(255),
position VARCHAR(255),
qualification VARCHAR(64),
support_content TEXT
);
CREATE INDEX idx_participants_app ON application_participants(application_id);
-- =============================================================================
-- EVALUATIONS (Mẫu 04) — council members score applications
-- =============================================================================
CREATE TABLE evaluations (
evaluation_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
evaluator_id INT NOT NULL REFERENCES users(user_id),
novelty_comments TEXT,
novelty_score INT NOT NULL DEFAULT 0
CHECK (novelty_score BETWEEN 0 AND 40),
effectiveness_comments TEXT,
effectiveness_score INT NOT NULL DEFAULT 0
CHECK (effectiveness_score BETWEEN 0 AND 60),
total_score INT GENERATED ALWAYS AS (novelty_score + effectiveness_score) STORED,
conclusion TEXT,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (application_id, evaluator_id)
);
CREATE INDEX idx_eval_app ON evaluations(application_id);
CREATE INDEX idx_eval_evaluator ON evaluations(evaluator_id);
-- =============================================================================
-- COMMITMENTS (Bản cam kết) — for paper-based innovations
-- =============================================================================
CREATE TABLE commitments (
commitment_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
user_id INT NOT NULL REFERENCES users(user_id),
paper_title TEXT,
role_type VARCHAR(32) NOT NULL
CHECK (role_type IN ('PRIMARY_AUTHOR','CO_AUTHOR')),
-- 5 commitment checkboxes
is_legal_owner BOOLEAN NOT NULL DEFAULT FALSE,
is_authorized_by_owner BOOLEAN NOT NULL DEFAULT FALSE,
has_coauthor_consent BOOLEAN NOT NULL DEFAULT FALSE,
not_predatory_journal BOOLEAN NOT NULL DEFAULT FALSE,
complies_with_ip_law BOOLEAN NOT NULL DEFAULT FALSE,
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (application_id, user_id)
);
CREATE INDEX idx_commit_app ON commitments(application_id);
-- =============================================================================
-- ATTACHMENTS (uploaded files — figures, flowcharts, annexes)
-- =============================================================================
CREATE TABLE attachments (
attachment_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
file_name VARCHAR(255) NOT NULL,
file_path TEXT NOT NULL, -- S3/MinIO key
file_size BIGINT,
mime_type VARCHAR(128),
kind VARCHAR(32) -- 'LUU_DO', 'PHU_LUC', 'KY_SO', 'KHAC'
CHECK (kind IS NULL OR kind IN ('LUU_DO','PHU_LUC','KY_SO','KHAC')),
uploaded_by INT REFERENCES users(user_id),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_attach_app ON attachments(application_id);
-- =============================================================================
-- AUDIT LOG — single table, populated by triggers on all CUD operations
-- =============================================================================
CREATE TABLE audit_log (
log_id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(64) NOT NULL,
record_id TEXT NOT NULL,
action VARCHAR(16) NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')),
changed_by INT, -- set from app via SET LOCAL my.user_id
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
old_data JSONB,
new_data JSONB
);
CREATE INDEX idx_audit_table_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_user_time ON audit_log(changed_by, changed_at DESC);
-- Generic audit trigger function
CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$
DECLARE
v_user INT;
v_pk TEXT;
BEGIN
-- Get user_id from session var if app sets it; else NULL
BEGIN v_user := current_setting('my.user_id')::INT;
EXCEPTION WHEN OTHERS THEN v_user := NULL; END;
v_pk := COALESCE(
(row_to_json(NEW)::jsonb->>TG_ARGV[0]),
(row_to_json(OLD)::jsonb->>TG_ARGV[0])
);
INSERT INTO audit_log(table_name, record_id, action, changed_by, old_data, new_data)
VALUES (
TG_TABLE_NAME,
v_pk,
TG_OP,
v_user,
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Attach audit trigger to the important tables (pass PK column name as arg)
CREATE TRIGGER trg_audit_applications AFTER INSERT OR UPDATE OR DELETE ON applications
FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id');
CREATE TRIGGER trg_audit_authors AFTER INSERT OR UPDATE OR DELETE ON application_authors
FOR EACH ROW EXECUTE FUNCTION audit_trigger('application_id');
CREATE TRIGGER trg_audit_evaluations AFTER INSERT OR UPDATE OR DELETE ON evaluations
FOR EACH ROW EXECUTE FUNCTION audit_trigger('evaluation_id');
CREATE TRIGGER trg_audit_commitments AFTER INSERT OR UPDATE OR DELETE ON commitments
FOR EACH ROW EXECUTE FUNCTION audit_trigger('commitment_id');
-- =============================================================================
-- WORKFLOW STATE MACHINE ENFORCEMENT
-- =============================================================================
CREATE OR REPLACE FUNCTION enforce_application_transitions() RETURNS TRIGGER AS $$
DECLARE
allowed BOOLEAN := FALSE;
BEGIN
IF OLD.status = NEW.status THEN RETURN NEW; END IF;
-- Allowed transitions
allowed := CASE
WHEN OLD.status = 'DRAFT' AND NEW.status IN ('SUBMITTED','WITHDRAWN') THEN TRUE
WHEN OLD.status = 'SUBMITTED' AND NEW.status IN ('UNDER_REVIEW','WITHDRAWN','DRAFT') THEN TRUE
WHEN OLD.status = 'UNDER_REVIEW' AND NEW.status IN ('EVALUATED','WITHDRAWN') THEN TRUE
WHEN OLD.status = 'EVALUATED' AND NEW.status IN ('APPROVED','REJECTED') THEN TRUE
ELSE FALSE
END;
IF NOT allowed THEN
RAISE EXCEPTION 'Invalid status transition: % → %', OLD.status, NEW.status;
END IF;
-- Auto-set timestamps
IF NEW.status = 'SUBMITTED' AND OLD.status = 'DRAFT' THEN
NEW.submitted_at := NOW();
END IF;
IF NEW.status IN ('APPROVED','REJECTED') THEN
NEW.decided_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_app_state_machine
BEFORE UPDATE OF status ON applications
FOR EACH ROW EXECUTE FUNCTION enforce_application_transitions();
-- =============================================================================
-- CONVENIENCE VIEWS
-- =============================================================================
-- Dashboard: applications with author names and current evaluation average
CREATE VIEW v_application_summary AS
SELECT
a.application_id,
a.code,
a.title,
a.status,
a.registration_year,
u.name AS primary_unit_name,
(SELECT string_agg(usr.full_name, ', ' ORDER BY aa.display_order)
FROM application_authors aa
JOIN users usr ON usr.user_id = aa.user_id
WHERE aa.application_id = a.application_id) AS author_names,
(SELECT ROUND(AVG(total_score),2)
FROM evaluations WHERE application_id = a.application_id) AS avg_score,
(SELECT COUNT(*) FROM evaluations WHERE application_id = a.application_id) AS num_evaluations,
a.submitted_at,
a.decided_at
FROM applications a
LEFT JOIN units u ON u.unit_id = a.primary_unit_id
WHERE a.deleted_at IS NULL;
-- Materialized view: annual approval statistics (refresh nightly)
CREATE MATERIALIZED VIEW mv_annual_stats AS
SELECT
registration_year,
COUNT(*) FILTER (WHERE status = 'APPROVED') AS approved,
COUNT(*) FILTER (WHERE status = 'REJECTED') AS rejected,
COUNT(*) FILTER (WHERE status NOT IN ('APPROVED','REJECTED')) AS pending,
COUNT(*) AS total
FROM applications
WHERE deleted_at IS NULL
GROUP BY registration_year;
CREATE UNIQUE INDEX ON mv_annual_stats(registration_year);
-- =============================================================================
-- REVIEW DOCUMENT JSON SNAPSHOTS (ReviewPanel bundle persistence)
-- =============================================================================
CREATE TABLE IF NOT EXISTS application_review_documents (
review_document_id SERIAL PRIMARY KEY,
application_id INT NOT NULL REFERENCES applications(application_id) ON DELETE CASCADE,
case_id VARCHAR(128) NOT NULL,
document_version INT NOT NULL DEFAULT 1,
official_bieu_mau JSONB NOT NULL DEFAULT '{}'::jsonb,
template_data JSONB,
full_bundle JSONB,
created_by INT REFERENCES users(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (application_id, document_version)
);
CREATE INDEX idx_review_docs_app_time ON application_review_documents(application_id, created_at DESC);
CREATE INDEX idx_review_docs_case_time ON application_review_documents(case_id, created_at DESC);
+83
View File
@@ -0,0 +1,83 @@
-- Validation tests: run in a single transaction per block
-- ===========================================================
-- 1. SEED: units + users
INSERT INTO units(code, name, type) VALUES
('DHYD', 'Đại học Y Dược TP.HCM', 'TRUONG'),
('KHCN', 'Phòng Khoa học Công nghệ', 'PHONG');
INSERT INTO users(full_name, title, email, id_number, unit_id, qualification, user_type) VALUES
('Trần Hùng', 'PGS.TS', 'tranhung@ump.edu.vn', '001001', 1, 'Tiến sĩ', 'AUTHOR'),
('Đỗ Quốc Vũ', 'CN.', 'doquocvu@ump.edu.vn', '001002', 2, 'Cử nhân', 'AUTHOR'),
('Nguyễn Hội đồng A', 'PGS.TS', 'hdA@ump.edu.vn', '002001', 1, 'Tiến sĩ', 'COUNCIL');
-- 2. CREATE an application in DRAFT state
INSERT INTO applications(code, title, registration_year, status, purpose, primary_unit_id, created_by)
VALUES ('SK-2025-001',
'Quy trình xét duyệt Đạo đức trong nghiên cứu trên động vật',
2025, 'DRAFT',
'Chuẩn hoá quy trình xét duyệt hồ sơ',
2, 2);
-- 3. ADD authors with DEFERRED constraint (sums to 100 at COMMIT)
BEGIN;
INSERT INTO application_authors(application_id, user_id, contribution_pct, role) VALUES
(1, 1, 50, 'CO_AUTHOR'),
(1, 2, 50, 'PRIMARY');
-- At this point sum=100, but app is DRAFT so constraint doesn't even care yet
COMMIT;
-- Verify
SELECT 'Authors inserted:' AS step, count(*) FROM application_authors;
-- 4. TRY to submit the application (DRAFT → SUBMITTED): needs classification
-- This should FAIL the check constraint because no classification flag is set
\echo 'Test 4: should FAIL (missing classification)'
UPDATE applications SET status='SUBMITTED' WHERE application_id=1;
\echo ''
-- Fix and retry
UPDATE applications
SET is_technical_solution = TRUE,
status = 'SUBMITTED'
WHERE application_id = 1;
SELECT 'After submit:' AS step, status, submitted_at FROM applications WHERE application_id=1;
-- 5. TRY invalid transition SUBMITTED → APPROVED (should FAIL)
\echo 'Test 5: should FAIL (illegal transition)'
UPDATE applications SET status='APPROVED' WHERE application_id=1;
\echo ''
-- Valid transitions
UPDATE applications SET status='UNDER_REVIEW' WHERE application_id=1;
-- 6. EVALUATOR scores the application
INSERT INTO evaluations(application_id, evaluator_id, novelty_score, effectiveness_score, conclusion)
VALUES (1, 3, 35, 50, 'Đề xuất công nhận');
SELECT 'Evaluation:' AS step, novelty_score, effectiveness_score, total_score FROM evaluations;
-- 7. Move to EVALUATED → APPROVED
UPDATE applications SET status='EVALUATED' WHERE application_id=1;
UPDATE applications SET status='APPROVED' WHERE application_id=1;
SELECT 'Final status:' AS step, status, decided_at IS NOT NULL AS has_decision_time
FROM applications WHERE application_id=1;
-- 8. READ: summary view
SELECT code, title, status, author_names, avg_score, num_evaluations
FROM v_application_summary;
-- 9. AUDIT trail: who changed what?
SELECT table_name, action, changed_at,
(new_data->>'status') AS new_status
FROM audit_log
WHERE table_name = 'applications'
ORDER BY log_id;
-- 10. Bad contribution sum should fail at COMMIT
\echo 'Test 10: should FAIL (sum != 100 on submitted app)'
BEGIN;
UPDATE application_authors SET contribution_pct = 30 WHERE application_id=1 AND user_id=1;
-- sum is now 30+50=80, but app is APPROVED so trigger will reject at commit
COMMIT;