2185 lines
91 KiB
PL/PgSQL
Executable File
2185 lines
91 KiB
PL/PgSQL
Executable File
-- ==========================================
|
||
-- Инициализация расширений
|
||
-- ==========================================
|
||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||
|
||
-- ===============================
|
||
-- Справочники высшего уровня
|
||
-- ===============================
|
||
CREATE TABLE IF NOT EXISTS departments (
|
||
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
code BIGINT UNIQUE NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
INSERT INTO departments (name, code) VALUES
|
||
('Кафедра ИБ', 1),
|
||
('Кафедра ВТ', 2),
|
||
('Кафедра КТ', 3);
|
||
|
||
CREATE TABLE IF NOT EXISTS specialties (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(255) NOT NULL,
|
||
specialty_code VARCHAR(255) UNIQUE NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
INSERT INTO specialties (name, specialty_code) VALUES
|
||
('Информационная безопасность', '10.03.01'),
|
||
('Информатика и вычислительная техника', '09.03.01'),
|
||
('Программная инженерия', '09.03.04')
|
||
ON CONFLICT (specialty_code) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
||
name VARCHAR(500) NOT NULL,
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT,
|
||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||
);
|
||
|
||
INSERT INTO specialty_profiles (specialty_id, name)
|
||
SELECT id, 'Без профиля'
|
||
FROM specialties
|
||
ON CONFLICT (specialty_id, name) DO NOTHING;
|
||
|
||
-- ==========================================
|
||
-- Пользователи и роли
|
||
-- ==========================================
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
username VARCHAR(50) UNIQUE NOT NULL,
|
||
password VARCHAR(255) NOT NULL,
|
||
role VARCHAR(30) NOT NULL DEFAULT 'STUDENT',
|
||
full_name VARCHAR(255) NOT NULL,
|
||
job_title VARCHAR(255) NOT NULL,
|
||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
||
ON CONFLICT (username) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
tenant VARCHAR(100) NOT NULL,
|
||
token_hash VARCHAR(64) UNIQUE NOT NULL,
|
||
issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
revoked_at TIMESTAMPTZ,
|
||
rotated_to_token_hash VARCHAR(64),
|
||
user_agent VARCHAR(512),
|
||
ip_address VARCHAR(64)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user
|
||
ON auth_refresh_tokens(user_id);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires
|
||
ON auth_refresh_tokens(tenant, expires_at);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_expires
|
||
ON auth_refresh_tokens(expires_at);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_cleanup_revoked
|
||
ON auth_refresh_tokens(revoked_at)
|
||
WHERE revoked_at IS NOT NULL;
|
||
|
||
CREATE TABLE IF NOT EXISTS auth_login_rate_limits (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant VARCHAR(100) NOT NULL,
|
||
username_normalized VARCHAR(100) NOT NULL,
|
||
client_ip VARCHAR(64) NOT NULL,
|
||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||
window_started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
last_failure_at TIMESTAMPTZ,
|
||
blocked_until TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT uq_auth_login_rate_limit_key
|
||
UNIQUE (tenant, username_normalized, client_ip),
|
||
CONSTRAINT chk_auth_login_rate_limit_failure_count
|
||
CHECK (failure_count >= 0)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_blocked_until
|
||
ON auth_login_rate_limits(blocked_until)
|
||
WHERE blocked_until IS NOT NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_login_rate_limits_updated_at
|
||
ON auth_login_rate_limits(updated_at, id);
|
||
|
||
CREATE TABLE IF NOT EXISTS auth_login_attempt_audit (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
tenant VARCHAR(100) NOT NULL,
|
||
username_normalized VARCHAR(100) NOT NULL,
|
||
client_ip VARCHAR(64) NOT NULL,
|
||
outcome VARCHAR(20) NOT NULL,
|
||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
retry_after_seconds INTEGER,
|
||
CONSTRAINT chk_auth_login_attempt_audit_outcome
|
||
CHECK (outcome IN ('FAILURE', 'BLOCKED')),
|
||
CONSTRAINT chk_auth_login_attempt_audit_retry_after
|
||
CHECK (retry_after_seconds IS NULL OR retry_after_seconds > 0)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_occurred
|
||
ON auth_login_attempt_audit(occurred_at, id);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_auth_login_attempt_audit_key
|
||
ON auth_login_attempt_audit(tenant, username_normalized, client_ip, occurred_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS teacher_department_assignments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||
valid_from DATE NOT NULL,
|
||
valid_to DATE,
|
||
is_primary BOOLEAN NOT NULL DEFAULT TRUE,
|
||
comment TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
created_by BIGINT REFERENCES users(id),
|
||
CONSTRAINT chk_teacher_department_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_teacher_department_teacher_dates
|
||
ON teacher_department_assignments(teacher_id, valid_from, valid_to);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_teacher_department_department_dates
|
||
ON teacher_department_assignments(department_id, valid_from, valid_to);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_primary
|
||
ON teacher_department_assignments(teacher_id)
|
||
WHERE is_primary = TRUE AND valid_to IS NULL;
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
|
||
ON teacher_department_assignments(teacher_id, department_id)
|
||
WHERE valid_to IS NULL;
|
||
|
||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
||
FROM users u
|
||
WHERE u.role = 'TEACHER'
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM teacher_department_assignments existing
|
||
WHERE existing.teacher_id = u.id
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||
username VARCHAR(50) NOT NULL,
|
||
full_name VARCHAR(255) NOT NULL,
|
||
job_title VARCHAR(255) NOT NULL,
|
||
comment TEXT,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||
requested_by BIGINT REFERENCES users(id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
reviewed_by BIGINT REFERENCES users(id),
|
||
reviewed_at TIMESTAMPTZ,
|
||
review_comment TEXT,
|
||
created_teacher_id BIGINT REFERENCES users(id),
|
||
CONSTRAINT chk_teacher_creation_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_department
|
||
ON teacher_creation_requests(department_id, created_at DESC);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_teacher_creation_requests_status
|
||
ON teacher_creation_requests(status, created_at DESC);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_creation_requests_pending_username
|
||
ON teacher_creation_requests(username)
|
||
WHERE status = 'PENDING';
|
||
|
||
-- ==========================================
|
||
-- Образовательные формы
|
||
-- ==========================================
|
||
CREATE TABLE IF NOT EXISTS education_forms (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(100) UNIQUE NOT NULL,
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
INSERT INTO education_forms (name) VALUES
|
||
('Бакалавриат'),
|
||
('Магистратура'),
|
||
('Специалитет')
|
||
ON CONFLICT (name) DO NOTHING;
|
||
|
||
-- ==========================================
|
||
-- Учебные группы
|
||
-- ==========================================
|
||
CREATE TABLE IF NOT EXISTS student_groups (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(100) NOT NULL,
|
||
group_size BIGINT NOT NULL
|
||
CONSTRAINT chk_student_groups_group_size_positive CHECK (group_size > 0),
|
||
education_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
||
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
|
||
year_start_study BIGINT NOT NULL
|
||
CONSTRAINT chk_student_groups_year_start_positive CHECK (year_start_study > 0),
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
-- Тестовая базовая группа для работы
|
||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
|
||
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
|
||
FROM (VALUES
|
||
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
|
||
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
|
||
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
|
||
JOIN education_forms ef ON ef.name = data.education_form_name
|
||
JOIN departments dept ON dept.name = data.department_name
|
||
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
|
||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
|
||
|
||
-- ==========================================
|
||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||
-- ==========================================
|
||
CREATE TABLE IF NOT EXISTS subgroups (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||
name VARCHAR(100) NOT NULL,
|
||
student_capacity INT NOT NULL
|
||
CONSTRAINT chk_subgroups_student_capacity_positive CHECK (student_capacity > 0),
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT,
|
||
UNIQUE(group_id, name)
|
||
);
|
||
|
||
CREATE OR REPLACE FUNCTION validate_student_group_subgroup_capacity()
|
||
RETURNS TRIGGER AS $$
|
||
DECLARE
|
||
active_capacity BIGINT;
|
||
BEGIN
|
||
SELECT COALESCE(SUM(student_capacity), 0)
|
||
INTO active_capacity
|
||
FROM subgroups
|
||
WHERE group_id = NEW.id
|
||
AND status <> 'ARCHIVED';
|
||
|
||
IF active_capacity > NEW.group_size THEN
|
||
RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
|
||
USING ERRCODE = '23514',
|
||
CONSTRAINT = 'chk_student_group_subgroup_capacity';
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER validate_student_group_subgroup_capacity
|
||
BEFORE UPDATE OF group_size ON student_groups
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION validate_student_group_subgroup_capacity();
|
||
|
||
CREATE OR REPLACE FUNCTION validate_subgroup_student_capacity()
|
||
RETURNS TRIGGER AS $$
|
||
DECLARE
|
||
group_capacity BIGINT;
|
||
allocated_capacity BIGINT;
|
||
BEGIN
|
||
SELECT group_size
|
||
INTO group_capacity
|
||
FROM student_groups
|
||
WHERE id = NEW.group_id
|
||
FOR UPDATE;
|
||
|
||
IF group_capacity IS NULL OR NEW.status = 'ARCHIVED' THEN
|
||
RETURN NEW;
|
||
END IF;
|
||
|
||
IF TG_OP = 'INSERT' THEN
|
||
SELECT COALESCE(SUM(student_capacity), 0)
|
||
INTO allocated_capacity
|
||
FROM subgroups
|
||
WHERE group_id = NEW.group_id
|
||
AND status <> 'ARCHIVED';
|
||
ELSE
|
||
SELECT COALESCE(SUM(student_capacity), 0)
|
||
INTO allocated_capacity
|
||
FROM subgroups
|
||
WHERE group_id = NEW.group_id
|
||
AND status <> 'ARCHIVED'
|
||
AND id <> OLD.id;
|
||
END IF;
|
||
|
||
IF allocated_capacity + NEW.student_capacity > group_capacity THEN
|
||
RAISE EXCEPTION 'Сумма численностей активных подгрупп не может превышать численность группы'
|
||
USING ERRCODE = '23514',
|
||
CONSTRAINT = 'chk_student_group_subgroup_capacity';
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER validate_subgroup_student_capacity
|
||
BEFORE INSERT OR UPDATE OF group_id, student_capacity, status ON subgroups
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
||
|
||
INSERT INTO subgroups (group_id, name, student_capacity)
|
||
SELECT
|
||
sg.id,
|
||
data.name,
|
||
CASE
|
||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
||
END
|
||
FROM student_groups sg
|
||
CROSS JOIN (VALUES
|
||
('Подгруппа 1', 1),
|
||
('Подгруппа 2', 2)
|
||
) AS data(name, part_number)
|
||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||
ON CONFLICT (group_id, name) DO NOTHING;
|
||
|
||
-- ==========================================
|
||
-- Справочники
|
||
-- ==========================================
|
||
|
||
-- Дисциплины
|
||
CREATE TABLE IF NOT EXISTS subjects (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(200) NOT NULL,
|
||
code VARCHAR(20),
|
||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
|
||
ON subjects (LOWER(name));
|
||
COMMENT ON INDEX uq_subjects_name_ci IS
|
||
'Глобально уникальное название дисциплины без учёта регистра';
|
||
|
||
INSERT INTO subjects (name, department_id) VALUES
|
||
('Высшая математика', 1),
|
||
('Философия', 1),
|
||
('Информатика', 1),
|
||
('Базы данных', 1),
|
||
('Английский язык', 1)
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS subject_comments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||
author_id BIGINT REFERENCES users(id),
|
||
comment TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_subject_comments_subject
|
||
ON subject_comments(subject_id, created_at DESC);
|
||
|
||
-- Типы занятий
|
||
CREATE TABLE IF NOT EXISTS lesson_types (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(50) UNIQUE NOT NULL,
|
||
color_code VARCHAR(7) DEFAULT '#3788d8',
|
||
duration_minutes INT DEFAULT 90
|
||
);
|
||
|
||
INSERT INTO lesson_types (name, color_code) VALUES
|
||
('Лекция', '#FF6B6B'),
|
||
('Практика', '#4ECDC4'),
|
||
('Лабораторная работа', '#45B7D1')
|
||
ON CONFLICT (name) DO NOTHING;
|
||
|
||
-- Оборудование
|
||
CREATE TABLE IF NOT EXISTS equipments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(50) UNIQUE NOT NULL,
|
||
description TEXT,
|
||
inventory_number VARCHAR(50),
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
INSERT INTO equipments (name) VALUES
|
||
('Проектор'),
|
||
('ПК'),
|
||
('Лаборатория'),
|
||
('Интерактивная доска'),
|
||
('Документ-камера'),
|
||
('Аудиосистема')
|
||
ON CONFLICT (name) DO NOTHING;
|
||
|
||
-- Аудитории
|
||
CREATE TABLE IF NOT EXISTS classrooms (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name VARCHAR(50) UNIQUE NOT NULL,
|
||
capacity INT NOT NULL CHECK (capacity > 0),
|
||
building VARCHAR(50),
|
||
floor INT,
|
||
is_available BOOLEAN DEFAULT TRUE,
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
active_from DATE,
|
||
active_to DATE,
|
||
archived_at TIMESTAMPTZ,
|
||
archived_by BIGINT,
|
||
archive_reason TEXT
|
||
);
|
||
|
||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
||
('101 Ленинская', 120, 'Главный корпус', 1),
|
||
('202 IT Lab', 20, 'Корпус IT', 2),
|
||
('303 Обычная', 30, 'Главный корпус', 3)
|
||
ON CONFLICT (name) DO NOTHING;
|
||
|
||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||
equipment_id BIGINT NOT NULL REFERENCES equipments(id) ON DELETE CASCADE,
|
||
quantity INT DEFAULT 1 CHECK (quantity > 0),
|
||
notes TEXT,
|
||
PRIMARY KEY (classroom_id, equipment_id)
|
||
);
|
||
|
||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
||
SELECT c.id, e.id,
|
||
CASE
|
||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
||
WHEN e.name = 'ПК' THEN 1
|
||
ELSE 1
|
||
END
|
||
FROM classrooms c, equipments e
|
||
WHERE
|
||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||
CREATE INDEX IF NOT EXISTS idx_education_forms_status ON education_forms(status);
|
||
CREATE INDEX IF NOT EXISTS idx_student_groups_status ON student_groups(status);
|
||
CREATE INDEX IF NOT EXISTS idx_subgroups_status ON subgroups(status);
|
||
CREATE INDEX IF NOT EXISTS idx_subjects_status ON subjects(status);
|
||
CREATE INDEX IF NOT EXISTS idx_equipments_status ON equipments(status);
|
||
CREATE INDEX IF NOT EXISTS idx_classrooms_status ON classrooms(status);
|
||
|
||
-- ==========================================
|
||
-- Связи для преподавателей
|
||
-- ==========================================
|
||
|
||
CREATE TABLE IF NOT EXISTS teacher_subjects (
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||
qualification_level VARCHAR(50),
|
||
experience_years INT,
|
||
PRIMARY KEY(user_id, subject_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS teacher_lesson_types (
|
||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
subject_id BIGINT NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (user_id, subject_id, lesson_type_id)
|
||
);
|
||
|
||
-- ==========================================
|
||
-- Динамическое расписание: календарь, слоты и правила
|
||
-- ==========================================
|
||
|
||
CREATE TABLE IF NOT EXISTS time_slot_scopes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) NOT NULL UNIQUE,
|
||
name VARCHAR(120) NOT NULL,
|
||
apply_mode VARCHAR(20) NOT NULL,
|
||
day_of_week INT NULL,
|
||
system_scope BOOLEAN NOT NULL DEFAULT FALSE,
|
||
display_order INT NOT NULL DEFAULT 100,
|
||
CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')),
|
||
CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7),
|
||
CONSTRAINT chk_time_slot_scopes_weekday CHECK (
|
||
(apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL)
|
||
OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL)
|
||
)
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default
|
||
ON time_slot_scopes(apply_mode)
|
||
WHERE apply_mode = 'DEFAULT';
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
||
ON time_slot_scopes(day_of_week)
|
||
WHERE apply_mode = 'WEEKDAY';
|
||
|
||
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
||
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
||
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
||
ON CONFLICT (code) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS time_slots (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||
order_number INT NOT NULL,
|
||
start_time TIME NOT NULL,
|
||
end_time TIME NOT NULL,
|
||
duration_minutes INT NOT NULL,
|
||
CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
|
||
CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
|
||
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0)
|
||
);
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
||
ON time_slots(time_slot_scope_id, order_number);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||
|
||
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
||
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
||
FROM (VALUES
|
||
(1, '08:00', '09:30', 90),
|
||
(2, '09:40', '11:10', 90),
|
||
(3, '11:40', '13:10', 90),
|
||
(4, '13:20', '14:50', 90),
|
||
(5, '15:00', '16:30', 90),
|
||
(6, '16:50', '18:20', 90),
|
||
(7, '18:30', '20:00', 90)
|
||
) AS data(order_number, start_time, end_time, duration_minutes)
|
||
CROSS JOIN time_slot_scopes scope
|
||
WHERE scope.code IN ('default', 'saturday')
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
assignment_date DATE NOT NULL UNIQUE,
|
||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope
|
||
ON time_slot_date_assignments(time_slot_scope_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS academic_years (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
title VARCHAR(20) NOT NULL UNIQUE,
|
||
start_date DATE NOT NULL,
|
||
end_date DATE NOT NULL,
|
||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||
);
|
||
|
||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
||
('2024-2025', '2024-09-01', '2025-06-30'),
|
||
('2025-2026', '2025-09-01', '2026-06-30')
|
||
ON CONFLICT (title) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS semesters (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||
semester_type VARCHAR(20) NOT NULL,
|
||
start_date DATE NOT NULL,
|
||
end_date DATE NOT NULL,
|
||
CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')),
|
||
CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date),
|
||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||
);
|
||
|
||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
||
FROM academic_years
|
||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||
|
||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
||
FROM academic_years
|
||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||
|
||
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(10) UNIQUE NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
allow_schedule BOOLEAN NOT NULL DEFAULT FALSE,
|
||
color_code VARCHAR(7) NOT NULL DEFAULT '#64748b',
|
||
display_order INT NOT NULL DEFAULT 100,
|
||
description TEXT
|
||
);
|
||
|
||
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
|
||
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
|
||
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
|
||
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
|
||
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
|
||
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
|
||
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
|
||
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
|
||
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
|
||
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
|
||
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
|
||
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
|
||
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
|
||
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
|
||
ON CONFLICT (code) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS academic_calendars (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
title VARCHAR(255) NOT NULL,
|
||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||
specialty_id BIGINT NOT NULL REFERENCES specialties(id),
|
||
specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id),
|
||
study_form_id BIGINT NOT NULL REFERENCES education_forms(id),
|
||
course_count INT NOT NULL DEFAULT 4,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8),
|
||
CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
||
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
||
|
||
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
|
||
SELECT
|
||
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
|
||
ay.id,
|
||
specialty.id,
|
||
profile.id,
|
||
study_form.id,
|
||
4
|
||
FROM academic_years ay
|
||
CROSS JOIN education_forms study_form
|
||
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
|
||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
||
WHERE ay.title = '2025-2026'
|
||
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||
course_number INT NOT NULL,
|
||
start_date DATE NOT NULL,
|
||
end_date DATE NOT NULL,
|
||
activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id),
|
||
CONSTRAINT chk_calendar_periods_course_positive CHECK (course_number > 0),
|
||
CONSTRAINT chk_calendar_periods_dates CHECK (end_date >= start_date),
|
||
CONSTRAINT ex_calendar_periods_no_overlap
|
||
EXCLUDE USING gist (
|
||
calendar_id WITH =,
|
||
course_number WITH =,
|
||
(daterange(start_date, end_date, '[]')) WITH &&
|
||
)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_periods_schedule
|
||
ON academic_calendar_periods(calendar_id, course_number, start_date, end_date);
|
||
|
||
CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number(
|
||
academic_year_start DATE,
|
||
calendar_date DATE
|
||
)
|
||
RETURNS INTEGER
|
||
LANGUAGE SQL
|
||
IMMUTABLE
|
||
STRICT
|
||
AS $$
|
||
SELECT (
|
||
(
|
||
calendar_date
|
||
- academic_year_start
|
||
+ EXTRACT(ISODOW FROM academic_year_start)::INTEGER
|
||
- 1
|
||
) / 7
|
||
) + 1
|
||
$$;
|
||
|
||
INSERT INTO academic_calendar_periods (
|
||
calendar_id,
|
||
course_number,
|
||
start_date,
|
||
end_date,
|
||
activity_type_id
|
||
)
|
||
SELECT
|
||
calendar.id,
|
||
course.course_number,
|
||
ay.start_date,
|
||
ay.end_date,
|
||
activity.id
|
||
FROM academic_calendars calendar
|
||
JOIN academic_years ay ON ay.id = calendar.academic_year_id
|
||
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
|
||
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
|
||
|
||
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||
semester_number INT NOT NULL,
|
||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0),
|
||
CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester
|
||
ON academic_calendar_subjects(calendar_id, semester_number);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject
|
||
ON academic_calendar_subjects(subject_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
||
ON student_group_calendar_assignments(group_id, academic_year_id);
|
||
|
||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
||
SELECT
|
||
sg.id,
|
||
ay.id,
|
||
calendar.id
|
||
FROM student_groups sg
|
||
JOIN academic_years ay ON ay.title = '2025-2026'
|
||
JOIN academic_calendars calendar
|
||
ON calendar.academic_year_id = ay.id
|
||
AND calendar.specialty_id = sg.specialty_id
|
||
AND calendar.specialty_profile_id = sg.specialty_profile_id
|
||
AND calendar.study_form_id = sg.education_form_id
|
||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
||
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||
lecture_academic_hours INT NOT NULL,
|
||
laboratory_academic_hours INT NOT NULL,
|
||
practice_academic_hours INT NOT NULL,
|
||
lecture_start_week INT NOT NULL,
|
||
laboratory_start_week INT NOT NULL,
|
||
practice_start_week INT NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
valid_from DATE,
|
||
valid_to DATE,
|
||
version_group_id BIGINT,
|
||
change_reason TEXT,
|
||
created_by BIGINT REFERENCES users(id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
|
||
lecture_academic_hours >= 0
|
||
AND laboratory_academic_hours >= 0
|
||
AND practice_academic_hours >= 0
|
||
),
|
||
CONSTRAINT chk_schedule_rules_has_type_hours CHECK (
|
||
lecture_academic_hours + laboratory_academic_hours + practice_academic_hours > 0
|
||
),
|
||
CONSTRAINT chk_schedule_rules_start_weeks_positive CHECK (
|
||
lecture_start_week > 0
|
||
AND laboratory_start_week > 0
|
||
AND practice_start_week > 0
|
||
),
|
||
CONSTRAINT chk_schedule_rules_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id);
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_status_dates
|
||
ON schedule_rules(status, valid_from, valid_to);
|
||
|
||
CREATE TABLE IF NOT EXISTS schedule_rule_groups (
|
||
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
|
||
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (schedule_rule_id, group_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS schedule_rule_slots (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE,
|
||
day_of_week INT NOT NULL,
|
||
parity VARCHAR(10) NOT NULL,
|
||
time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
|
||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
|
||
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
|
||
lesson_format VARCHAR(30) NOT NULL,
|
||
time_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||
classroom_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||
teacher_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||
locked_by BIGINT REFERENCES users(id),
|
||
locked_at TIMESTAMPTZ,
|
||
lock_comment TEXT,
|
||
CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7),
|
||
CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')),
|
||
CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
|
||
schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE,
|
||
subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (schedule_rule_slot_id, subgroup_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||
lesson_date DATE NOT NULL,
|
||
target_lesson_date DATE,
|
||
action VARCHAR(20) NOT NULL,
|
||
new_time_slot_id BIGINT REFERENCES time_slots(id),
|
||
new_classroom_id BIGINT REFERENCES classrooms(id),
|
||
new_teacher_id BIGINT REFERENCES users(id),
|
||
new_lesson_format VARCHAR(30),
|
||
comment TEXT,
|
||
created_by BIGINT REFERENCES users(id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT chk_schedule_overrides_action CHECK (action IN ('MOVE', 'CANCEL', 'REPLACE')),
|
||
CONSTRAINT uq_schedule_overrides_slot_date UNIQUE (base_rule_slot_id, lesson_date)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
|
||
ON schedule_overrides(lesson_date);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_target_date
|
||
ON schedule_overrides(target_lesson_date)
|
||
WHERE target_lesson_date IS NOT NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
|
||
ON schedule_overrides(new_teacher_id);
|
||
|
||
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
|
||
RETURNS TRIGGER AS $$
|
||
DECLARE
|
||
lesson_type_name VARCHAR(50);
|
||
rule_id BIGINT;
|
||
subgroup_group_id BIGINT;
|
||
has_group_duplicate BOOLEAN;
|
||
BEGIN
|
||
SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
|
||
FROM schedule_rule_slots slot
|
||
JOIN lesson_types type ON type.id = slot.lesson_type_id
|
||
WHERE slot.id = NEW.schedule_rule_slot_id;
|
||
|
||
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
|
||
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
|
||
END IF;
|
||
|
||
SELECT group_id INTO subgroup_group_id
|
||
FROM subgroups
|
||
WHERE id = NEW.subgroup_id;
|
||
|
||
IF subgroup_group_id IS NULL THEN
|
||
RAISE EXCEPTION 'Подгруппа не найдена';
|
||
END IF;
|
||
|
||
IF NOT EXISTS (
|
||
SELECT 1
|
||
FROM schedule_rule_groups rule_group
|
||
WHERE rule_group.schedule_rule_id = rule_id
|
||
AND rule_group.group_id = subgroup_group_id
|
||
) THEN
|
||
RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила';
|
||
END IF;
|
||
|
||
IF TG_OP = 'UPDATE' THEN
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM schedule_rule_slot_subgroups slot_subgroup
|
||
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
|
||
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
|
||
AND subgroup.group_id = subgroup_group_id
|
||
AND NOT (
|
||
slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id
|
||
AND slot_subgroup.subgroup_id = OLD.subgroup_id
|
||
)
|
||
) INTO has_group_duplicate;
|
||
ELSE
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM schedule_rule_slot_subgroups slot_subgroup
|
||
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
|
||
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
|
||
AND subgroup.group_id = subgroup_group_id
|
||
) INTO has_group_duplicate;
|
||
END IF;
|
||
|
||
IF has_group_duplicate THEN
|
||
RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
||
BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||
|
||
CREATE TEMP TABLE tmp_default_rules AS
|
||
SELECT
|
||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
||
rule_key,
|
||
subject_id,
|
||
semester_id,
|
||
lecture_academic_hours,
|
||
laboratory_academic_hours,
|
||
practice_academic_hours,
|
||
lecture_start_week,
|
||
laboratory_start_week,
|
||
practice_start_week
|
||
FROM (
|
||
SELECT
|
||
data.rule_key,
|
||
subj.id AS subject_id,
|
||
sem.id AS semester_id,
|
||
data.lecture_academic_hours,
|
||
data.laboratory_academic_hours,
|
||
data.practice_academic_hours,
|
||
data.lecture_start_week,
|
||
data.laboratory_start_week,
|
||
data.practice_start_week
|
||
FROM (VALUES
|
||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
||
) AS data(
|
||
rule_key,
|
||
subject_name,
|
||
year_title,
|
||
semester_type,
|
||
lecture_academic_hours,
|
||
laboratory_academic_hours,
|
||
practice_academic_hours,
|
||
lecture_start_week,
|
||
laboratory_start_week,
|
||
practice_start_week
|
||
)
|
||
JOIN subjects subj ON subj.name = data.subject_name
|
||
JOIN academic_years ay ON ay.title = data.year_title
|
||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
||
) prepared_rules;
|
||
|
||
INSERT INTO schedule_rules (
|
||
id,
|
||
subject_id,
|
||
semester_id,
|
||
lecture_academic_hours,
|
||
laboratory_academic_hours,
|
||
practice_academic_hours,
|
||
lecture_start_week,
|
||
laboratory_start_week,
|
||
practice_start_week
|
||
)
|
||
SELECT
|
||
schedule_rule_id,
|
||
subject_id,
|
||
semester_id,
|
||
lecture_academic_hours,
|
||
laboratory_academic_hours,
|
||
practice_academic_hours,
|
||
lecture_start_week,
|
||
laboratory_start_week,
|
||
practice_start_week
|
||
FROM tmp_default_rules;
|
||
|
||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
||
SELECT
|
||
rules.schedule_rule_id,
|
||
sg.id
|
||
FROM (VALUES
|
||
('stream_math', 'ИВТ-21-1'),
|
||
('stream_math', 'ИБ-41м'),
|
||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
||
('ivt_databases_practice', 'ИВТ-21-1'),
|
||
('ib_philosophy', 'ИБ-41м')
|
||
) AS data(rule_key, group_name)
|
||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||
JOIN student_groups sg ON sg.name = data.group_name
|
||
ON CONFLICT DO NOTHING;
|
||
|
||
INSERT INTO schedule_rule_slots (
|
||
schedule_rule_id,
|
||
day_of_week,
|
||
parity,
|
||
time_slot_id,
|
||
teacher_id,
|
||
classroom_id,
|
||
lesson_type_id,
|
||
lesson_format
|
||
)
|
||
SELECT
|
||
rules.schedule_rule_id,
|
||
data.day_of_week,
|
||
data.parity,
|
||
ts.id,
|
||
teacher.id,
|
||
classroom.id,
|
||
lt.id,
|
||
data.lesson_format
|
||
FROM (VALUES
|
||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
||
JOIN time_slot_scopes scope ON scope.code = 'default'
|
||
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
||
JOIN users teacher ON teacher.username = data.teacher_username
|
||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
||
|
||
-- ==========================================
|
||
-- Функция обновления timestamp
|
||
-- ==========================================
|
||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||
RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER update_users_updated_at
|
||
BEFORE UPDATE ON users
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION update_updated_at_column();
|
||
|
||
CREATE TRIGGER update_academic_calendars_updated_at
|
||
BEFORE UPDATE ON academic_calendars
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION update_updated_at_column();
|
||
|
||
-- ==========================================
|
||
-- Комментарии к таблицам и полям (для документации)
|
||
-- ==========================================
|
||
COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)';
|
||
COMMENT ON TABLE departments IS 'Кафедры';
|
||
COMMENT ON TABLE specialties IS 'Специальности';
|
||
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
|
||
COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки';
|
||
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
|
||
COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату';
|
||
COMMENT ON TABLE academic_years IS 'Учебные годы';
|
||
COMMENT ON TABLE semesters IS 'Семестры учебного года';
|
||
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
|
||
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
|
||
COMMENT ON TABLE academic_calendar_periods IS 'Непересекающиеся периоды активности календарного учебного графика';
|
||
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
|
||
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
|
||
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
|
||
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
|
||
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
|
||
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
|
||
COMMENT ON TABLE teacher_department_assignments IS 'История принадлежности преподавателей к кафедрам';
|
||
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
|
||
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
|
||
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
|
||
COMMENT ON COLUMN schedule_overrides.lesson_date IS 'Исходная дата занятия, сформированного базовым правилом';
|
||
COMMENT ON COLUMN schedule_overrides.target_lesson_date IS 'Новая дата единственного занятия при действии MOVE';
|
||
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
||
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
||
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
||
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
|
||
COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре';
|
||
COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре';
|
||
COMMENT ON COLUMN schedule_rules.valid_from IS 'Дата начала действия версии правила расписания';
|
||
COMMENT ON COLUMN schedule_rules.valid_to IS 'Дата окончания действия версии правила расписания';
|
||
|
||
COMMENT ON TABLE education_forms IS 'Формы обучения';
|
||
COMMENT ON TABLE subgroups IS 'Подгруппы';
|
||
COMMENT ON TABLE lesson_types IS 'Типы занятий';
|
||
COMMENT ON TABLE equipments IS 'Оборудование';
|
||
COMMENT ON TABLE classrooms IS 'Аудитории';
|
||
COMMENT ON TABLE classroom_equipments IS 'Привязка оборудования к аудиториям';
|
||
COMMENT ON TABLE teacher_subjects IS 'Привязка преподавателей к дисциплинам';
|
||
COMMENT ON TABLE teacher_lesson_types IS 'Типы занятий преподавателя';
|
||
|
||
COMMENT ON COLUMN users.id IS 'ID пользователя';
|
||
COMMENT ON COLUMN users.username IS 'Логин пользователя';
|
||
COMMENT ON COLUMN users.password IS 'Хэш пароля пользователя';
|
||
COMMENT ON COLUMN users.role IS 'Роль пользователя';
|
||
COMMENT ON COLUMN users.status IS 'Жизненный цикл пользователя: ACTIVE или ARCHIVED';
|
||
COMMENT ON COLUMN users.created_at IS 'Дата и время создания';
|
||
COMMENT ON COLUMN users.updated_at IS 'Дата и время последнего обновления';
|
||
COMMENT ON COLUMN users.full_name IS 'ФИО пользователя';
|
||
COMMENT ON COLUMN users.job_title IS 'Должность пользователя';
|
||
COMMENT ON COLUMN users.department_id IS 'ID кафедры';
|
||
|
||
COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш';
|
||
COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен';
|
||
COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен';
|
||
COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена';
|
||
COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена';
|
||
COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена';
|
||
COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена';
|
||
COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации';
|
||
COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена';
|
||
COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена';
|
||
COMMENT ON TABLE auth_login_rate_limits IS 'Общий для backend-pod счётчик неудачных попыток входа';
|
||
COMMENT ON COLUMN auth_login_rate_limits.username_normalized IS 'Нормализованное имя пользователя для ключа rate limit';
|
||
COMMENT ON COLUMN auth_login_rate_limits.client_ip IS 'IP клиента, принятый только от настроенного доверенного proxy';
|
||
COMMENT ON COLUMN auth_login_rate_limits.blocked_until IS 'Момент окончания временной блокировки комбинации tenant, username и IP';
|
||
COMMENT ON TABLE auth_login_attempt_audit IS 'Аудит отклонённых и заблокированных попыток входа без паролей';
|
||
COMMENT ON COLUMN auth_login_attempt_audit.outcome IS 'Результат попытки: FAILURE или BLOCKED';
|
||
COMMENT ON COLUMN auth_login_attempt_audit.retry_after_seconds IS 'Возвращённый клиенту срок Retry-After в секундах';
|
||
|
||
COMMENT ON COLUMN education_forms.id IS 'ID формы обучения';
|
||
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
|
||
COMMENT ON COLUMN education_forms.description IS 'Описание';
|
||
COMMENT ON COLUMN education_forms.created_at IS 'Дата и время создания';
|
||
|
||
COMMENT ON COLUMN student_groups.id IS 'ID учебной группы';
|
||
COMMENT ON COLUMN student_groups.name IS 'Название группы';
|
||
COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов';
|
||
COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа';
|
||
COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры';
|
||
COMMENT ON COLUMN student_groups.specialty_id IS 'ID специальности';
|
||
COMMENT ON COLUMN student_groups.specialty_profile_id IS 'ID профиля обучения';
|
||
COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения';
|
||
COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания';
|
||
|
||
COMMENT ON COLUMN subgroups.id IS 'ID подгруппы';
|
||
COMMENT ON COLUMN subgroups.group_id IS 'ID учебной группы, к которой относится подгруппа';
|
||
COMMENT ON COLUMN subgroups.name IS 'Название подгруппы';
|
||
COMMENT ON COLUMN subgroups.student_capacity IS 'Количество студентов в подгруппе';
|
||
|
||
COMMENT ON COLUMN subjects.id IS 'ID предмета';
|
||
COMMENT ON COLUMN subjects.name IS 'Название предмета';
|
||
COMMENT ON COLUMN subjects.code IS 'Код предмета';
|
||
COMMENT ON COLUMN subjects.department_id IS 'ID кафедры';
|
||
COMMENT ON COLUMN subjects.description IS 'Описание предмета';
|
||
COMMENT ON COLUMN subjects.created_at IS 'Дата и время создания';
|
||
|
||
COMMENT ON COLUMN lesson_types.id IS 'ID урока';
|
||
COMMENT ON COLUMN lesson_types.name IS 'Название типа урока';
|
||
COMMENT ON COLUMN lesson_types.color_code IS 'Цветовой код для типа урока';
|
||
COMMENT ON COLUMN lesson_types.duration_minutes IS 'Длительность урока в минутах';
|
||
|
||
COMMENT ON COLUMN equipments.id IS 'ID оборудования';
|
||
COMMENT ON COLUMN equipments.name IS 'Название оборудования';
|
||
COMMENT ON COLUMN equipments.description IS 'Описание оборудования';
|
||
COMMENT ON COLUMN equipments.inventory_number IS 'Инвентарный номер оборудования';
|
||
|
||
COMMENT ON COLUMN classrooms.id IS 'ID аудитории';
|
||
COMMENT ON COLUMN classrooms.name IS 'Название аудитории';
|
||
COMMENT ON COLUMN classrooms.capacity IS 'Вместимость аудитории';
|
||
COMMENT ON COLUMN classrooms.building IS 'Корпус';
|
||
COMMENT ON COLUMN classrooms.floor IS 'Этаж';
|
||
COMMENT ON COLUMN classrooms.is_available IS 'Доступность аудитории';
|
||
COMMENT ON COLUMN classrooms.description IS 'Описание аудитории';
|
||
COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создания';
|
||
COMMENT ON COLUMN classrooms.status IS 'Жизненный цикл аудитории: ACTIVE или ARCHIVED';
|
||
|
||
COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории';
|
||
COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования';
|
||
COMMENT ON COLUMN classroom_equipments.quantity IS 'Количество единиц оборудования';
|
||
COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи';
|
||
|
||
COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя';
|
||
COMMENT ON COLUMN teacher_subjects.subject_id IS 'ID предмета';
|
||
COMMENT ON COLUMN teacher_subjects.qualification_level IS 'Уровень квалификации преподавателя';
|
||
COMMENT ON COLUMN teacher_subjects.experience_years IS 'Опыт преподавания';
|
||
|
||
COMMENT ON COLUMN departments.id IS 'ID кафедры';
|
||
COMMENT ON COLUMN departments.name IS 'Название кафедры';
|
||
COMMENT ON COLUMN departments.code IS 'Код кафедры';
|
||
|
||
COMMENT ON COLUMN specialties.id IS 'ID специальности';
|
||
COMMENT ON COLUMN specialties.name IS 'Название специальности';
|
||
COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности';
|
||
|
||
COMMENT ON COLUMN specialty_profiles.id IS 'ID профиля обучения';
|
||
COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальности';
|
||
COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения';
|
||
COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения';
|
||
|
||
COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика';
|
||
COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности';
|
||
COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания';
|
||
|
||
COMMENT ON COLUMN academic_calendars.title IS 'Название календарного учебного графика';
|
||
COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года';
|
||
COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности';
|
||
COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения';
|
||
COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms';
|
||
COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике';
|
||
|
||
COMMENT ON COLUMN academic_calendar_periods.calendar_id IS 'ID календарного учебного графика';
|
||
COMMENT ON COLUMN academic_calendar_periods.course_number IS 'Номер курса';
|
||
COMMENT ON COLUMN academic_calendar_periods.start_date IS 'Первая дата периода включительно';
|
||
COMMENT ON COLUMN academic_calendar_periods.end_date IS 'Последняя дата периода включительно';
|
||
COMMENT ON COLUMN academic_calendar_periods.activity_type_id IS 'ID кода активности';
|
||
COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE)
|
||
IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье';
|
||
|
||
COMMENT ON COLUMN academic_calendar_subjects.calendar_id IS 'ID календарного учебного графика';
|
||
COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...';
|
||
COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects';
|
||
COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки';
|
||
|
||
COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы';
|
||
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
|
||
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';
|
||
|
||
COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя';
|
||
COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета';
|
||
COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия';
|
||
|
||
-- ==========================================
|
||
-- Ограничения активных подгрупп
|
||
-- ==========================================
|
||
ALTER TABLE subgroups
|
||
DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key;
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name
|
||
ON subgroups (group_id, lower(name))
|
||
WHERE status <> 'ARCHIVED';
|
||
|
||
-- ==========================================
|
||
-- Инварианты точечных изменений расписания
|
||
-- ==========================================
|
||
DO $$
|
||
DECLARE
|
||
cancel_violations BIGINT;
|
||
move_violations BIGINT;
|
||
replace_violations BIGINT;
|
||
format_violations BIGINT;
|
||
target_date_violations BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO cancel_violations
|
||
FROM schedule_overrides
|
||
WHERE action = 'CANCEL'
|
||
AND (target_lesson_date IS NOT NULL
|
||
OR new_time_slot_id IS NOT NULL
|
||
OR new_classroom_id IS NOT NULL
|
||
OR new_teacher_id IS NOT NULL
|
||
OR new_lesson_format IS NOT NULL);
|
||
|
||
SELECT count(*)
|
||
INTO move_violations
|
||
FROM schedule_overrides
|
||
WHERE action = 'MOVE'
|
||
AND new_time_slot_id IS NULL;
|
||
|
||
SELECT count(*)
|
||
INTO replace_violations
|
||
FROM schedule_overrides
|
||
WHERE action = 'REPLACE'
|
||
AND (new_time_slot_id IS NOT NULL
|
||
OR (new_teacher_id IS NULL
|
||
AND new_classroom_id IS NULL
|
||
AND new_lesson_format IS NULL));
|
||
|
||
SELECT count(*)
|
||
INTO format_violations
|
||
FROM schedule_overrides
|
||
WHERE new_lesson_format IS NOT NULL
|
||
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
|
||
|
||
SELECT count(*)
|
||
INTO target_date_violations
|
||
FROM schedule_overrides
|
||
WHERE target_lesson_date IS NOT NULL
|
||
AND (target_lesson_date = lesson_date
|
||
OR action <> 'MOVE'
|
||
OR new_time_slot_id IS NULL);
|
||
|
||
IF cancel_violations > 0
|
||
OR move_violations > 0
|
||
OR replace_violations > 0
|
||
OR format_violations > 0
|
||
OR target_date_violations > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
cancel_violations,
|
||
move_violations,
|
||
replace_violations,
|
||
format_violations,
|
||
target_date_violations
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
ADD CONSTRAINT chk_schedule_overrides_cancel_payload
|
||
CHECK (
|
||
action <> 'CANCEL'
|
||
OR (
|
||
target_lesson_date IS NULL
|
||
AND new_time_slot_id IS NULL
|
||
AND new_classroom_id IS NULL
|
||
AND new_teacher_id IS NULL
|
||
AND new_lesson_format IS NULL
|
||
)
|
||
) NOT VALID,
|
||
ADD CONSTRAINT chk_schedule_overrides_move_payload
|
||
CHECK (
|
||
action <> 'MOVE'
|
||
OR new_time_slot_id IS NOT NULL
|
||
) NOT VALID,
|
||
ADD CONSTRAINT chk_schedule_overrides_replace_payload
|
||
CHECK (
|
||
action <> 'REPLACE'
|
||
OR (
|
||
new_time_slot_id IS NULL
|
||
AND (
|
||
new_teacher_id IS NOT NULL
|
||
OR new_classroom_id IS NOT NULL
|
||
OR new_lesson_format IS NOT NULL
|
||
)
|
||
)
|
||
) NOT VALID,
|
||
ADD CONSTRAINT chk_schedule_overrides_format
|
||
CHECK (
|
||
new_lesson_format IS NULL
|
||
OR new_lesson_format IN ('Очно', 'Онлайн')
|
||
) NOT VALID,
|
||
ADD CONSTRAINT chk_schedule_overrides_target_date
|
||
CHECK (
|
||
target_lesson_date IS NULL
|
||
OR (
|
||
target_lesson_date <> lesson_date
|
||
AND action = 'MOVE'
|
||
AND new_time_slot_id IS NOT NULL
|
||
)
|
||
) NOT VALID;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
VALIDATE CONSTRAINT chk_schedule_overrides_cancel_payload;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
VALIDATE CONSTRAINT chk_schedule_overrides_move_payload;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
VALIDATE CONSTRAINT chk_schedule_overrides_replace_payload;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
VALIDATE CONSTRAINT chk_schedule_overrides_format;
|
||
|
||
ALTER TABLE schedule_overrides
|
||
VALIDATE CONSTRAINT chk_schedule_overrides_target_date;
|
||
|
||
-- ==========================================
|
||
-- Инварианты правил расписания
|
||
-- ==========================================
|
||
DO $$
|
||
DECLARE
|
||
lecture_violations BIGINT;
|
||
laboratory_violations BIGINT;
|
||
practice_violations BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO lecture_violations
|
||
FROM schedule_rules
|
||
WHERE mod(lecture_academic_hours, 2) <> 0;
|
||
|
||
SELECT count(*)
|
||
INTO laboratory_violations
|
||
FROM schedule_rules
|
||
WHERE mod(laboratory_academic_hours, 2) <> 0;
|
||
|
||
SELECT count(*)
|
||
INTO practice_violations
|
||
FROM schedule_rules
|
||
WHERE mod(practice_academic_hours, 2) <> 0;
|
||
|
||
IF lecture_violations > 0
|
||
OR laboratory_violations > 0
|
||
OR practice_violations > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
lecture_violations,
|
||
laboratory_violations,
|
||
practice_violations
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
DO $$
|
||
DECLARE
|
||
duplicate_slot_groups BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO duplicate_slot_groups
|
||
FROM (
|
||
SELECT
|
||
schedule_rule_id,
|
||
day_of_week,
|
||
parity,
|
||
time_slot_id,
|
||
teacher_id,
|
||
classroom_id,
|
||
lesson_type_id,
|
||
lesson_format
|
||
FROM schedule_rule_slots
|
||
GROUP BY
|
||
schedule_rule_id,
|
||
day_of_week,
|
||
parity,
|
||
time_slot_id,
|
||
teacher_id,
|
||
classroom_id,
|
||
lesson_type_id,
|
||
lesson_format
|
||
HAVING count(*) > 1
|
||
) duplicates;
|
||
|
||
IF duplicate_slot_groups > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23505',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
duplicate_slot_groups
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE schedule_rules
|
||
ADD CONSTRAINT chk_schedule_rules_academic_hours_even
|
||
CHECK (
|
||
mod(lecture_academic_hours, 2) = 0
|
||
AND mod(laboratory_academic_hours, 2) = 0
|
||
AND mod(practice_academic_hours, 2) = 0
|
||
) NOT VALID;
|
||
|
||
ALTER TABLE schedule_rules
|
||
VALIDATE CONSTRAINT chk_schedule_rules_academic_hours_even;
|
||
|
||
ALTER TABLE schedule_rule_slots
|
||
ADD CONSTRAINT uq_schedule_rule_slots_exact_payload
|
||
UNIQUE (
|
||
schedule_rule_id,
|
||
day_of_week,
|
||
parity,
|
||
time_slot_id,
|
||
teacher_id,
|
||
classroom_id,
|
||
lesson_type_id,
|
||
lesson_format
|
||
);
|
||
|
||
-- ==========================================
|
||
-- Инварианты временных слотов
|
||
-- ==========================================
|
||
DO $$
|
||
DECLARE
|
||
duration_violations BIGINT;
|
||
overlap_violations BIGINT;
|
||
scope_violations BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO duration_violations
|
||
FROM time_slots
|
||
WHERE duration_minutes <> floor(extract(epoch FROM (end_time - start_time)) / 60)::INT;
|
||
|
||
SELECT count(*)
|
||
INTO overlap_violations
|
||
FROM time_slots first_slot
|
||
JOIN time_slots second_slot
|
||
ON second_slot.time_slot_scope_id = first_slot.time_slot_scope_id
|
||
AND second_slot.id > first_slot.id
|
||
AND first_slot.start_time < second_slot.end_time
|
||
AND second_slot.start_time < first_slot.end_time;
|
||
|
||
SELECT count(*)
|
||
INTO scope_violations
|
||
FROM schedule_rule_slots rule_slot
|
||
JOIN time_slots time_slot ON time_slot.id = rule_slot.time_slot_id
|
||
JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id
|
||
WHERE scope.apply_mode <> 'DEFAULT';
|
||
|
||
IF duration_violations > 0
|
||
OR overlap_violations > 0
|
||
OR scope_violations > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
duration_violations,
|
||
overlap_violations,
|
||
scope_violations
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE time_slots
|
||
ADD CONSTRAINT chk_time_slots_duration_matches_range
|
||
CHECK (
|
||
duration_minutes = floor(extract(epoch FROM (end_time - start_time)) / 60)::INT
|
||
) NOT VALID;
|
||
|
||
ALTER TABLE time_slots
|
||
VALIDATE CONSTRAINT chk_time_slots_duration_matches_range;
|
||
|
||
ALTER TABLE time_slots
|
||
ADD CONSTRAINT ex_time_slots_scope_no_overlap
|
||
EXCLUDE USING gist (
|
||
time_slot_scope_id WITH =,
|
||
(numrange(
|
||
extract(epoch FROM start_time),
|
||
extract(epoch FROM end_time),
|
||
'[)'
|
||
)) WITH &&
|
||
);
|
||
|
||
CREATE OR REPLACE FUNCTION enforce_schedule_rule_slot_default_scope()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
target_apply_mode VARCHAR(20);
|
||
BEGIN
|
||
SELECT scope.apply_mode
|
||
INTO target_apply_mode
|
||
FROM time_slots time_slot
|
||
JOIN time_slot_scopes scope ON scope.id = time_slot.time_slot_scope_id
|
||
WHERE time_slot.id = NEW.time_slot_id
|
||
FOR KEY SHARE OF time_slot, scope;
|
||
|
||
IF target_apply_mode IS NOT NULL AND target_apply_mode <> 'DEFAULT' THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'В правилах расписания можно использовать только слоты базовой сетки времени';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_schedule_rule_slots_default_scope
|
||
BEFORE INSERT OR UPDATE OF time_slot_id
|
||
ON schedule_rule_slots
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION enforce_schedule_rule_slot_default_scope();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_change()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
target_apply_mode VARCHAR(20);
|
||
BEGIN
|
||
SELECT apply_mode
|
||
INTO target_apply_mode
|
||
FROM time_slot_scopes
|
||
WHERE id = NEW.time_slot_scope_id;
|
||
|
||
IF target_apply_mode <> 'DEFAULT'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM schedule_rule_slots
|
||
WHERE time_slot_id = OLD.id
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Слот, используемый в правилах расписания, нельзя переносить из базовой сетки времени';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_time_slots_protect_used_scope
|
||
BEFORE UPDATE OF time_slot_scope_id
|
||
ON time_slots
|
||
FOR EACH ROW
|
||
WHEN (OLD.time_slot_scope_id IS DISTINCT FROM NEW.time_slot_scope_id)
|
||
EXECUTE FUNCTION prevent_used_time_slot_scope_change();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_used_time_slot_scope_mode_change()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
BEGIN
|
||
IF NEW.apply_mode <> 'DEFAULT'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM time_slots time_slot
|
||
JOIN schedule_rule_slots rule_slot ON rule_slot.time_slot_id = time_slot.id
|
||
WHERE time_slot.time_slot_scope_id = OLD.id
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Сетку со слотами, используемыми в правилах расписания, нельзя сделать небазовой';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_time_slot_scopes_protect_used_mode
|
||
BEFORE UPDATE OF apply_mode
|
||
ON time_slot_scopes
|
||
FOR EACH ROW
|
||
WHEN (OLD.apply_mode IS DISTINCT FROM NEW.apply_mode)
|
||
EXECUTE FUNCTION prevent_used_time_slot_scope_mode_change();
|
||
|
||
-- ==========================================
|
||
-- Инварианты учебных периодов
|
||
-- ==========================================
|
||
DO $$
|
||
DECLARE
|
||
academic_year_overlap_violations BIGINT;
|
||
semester_bounds_violations BIGINT;
|
||
semester_overlap_violations BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO academic_year_overlap_violations
|
||
FROM academic_years first_year
|
||
JOIN academic_years second_year
|
||
ON second_year.id > first_year.id
|
||
AND first_year.start_date <= second_year.end_date
|
||
AND second_year.start_date <= first_year.end_date;
|
||
|
||
SELECT count(*)
|
||
INTO semester_bounds_violations
|
||
FROM semesters semester
|
||
JOIN academic_years academic_year ON academic_year.id = semester.academic_year_id
|
||
WHERE semester.start_date < academic_year.start_date
|
||
OR semester.end_date > academic_year.end_date;
|
||
|
||
SELECT count(*)
|
||
INTO semester_overlap_violations
|
||
FROM semesters first_semester
|
||
JOIN semesters second_semester
|
||
ON second_semester.academic_year_id = first_semester.academic_year_id
|
||
AND second_semester.id > first_semester.id
|
||
AND first_semester.start_date <= second_semester.end_date
|
||
AND second_semester.start_date <= first_semester.end_date;
|
||
|
||
IF academic_year_overlap_violations > 0
|
||
OR semester_bounds_violations > 0
|
||
OR semester_overlap_violations > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
academic_year_overlap_violations,
|
||
semester_bounds_violations,
|
||
semester_overlap_violations
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE academic_years
|
||
ADD CONSTRAINT ex_academic_years_no_overlap
|
||
EXCLUDE USING gist (
|
||
(daterange(start_date, end_date, '[]')) WITH &&
|
||
);
|
||
|
||
ALTER TABLE semesters
|
||
ADD CONSTRAINT ex_semesters_year_no_overlap
|
||
EXCLUDE USING gist (
|
||
academic_year_id WITH =,
|
||
(daterange(start_date, end_date, '[]')) WITH &&
|
||
);
|
||
|
||
CREATE OR REPLACE FUNCTION enforce_semester_within_academic_year()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
academic_year_start DATE;
|
||
academic_year_end DATE;
|
||
BEGIN
|
||
SELECT start_date, end_date
|
||
INTO academic_year_start, academic_year_end
|
||
FROM academic_years
|
||
WHERE id = NEW.academic_year_id
|
||
FOR KEY SHARE;
|
||
|
||
IF academic_year_start IS NOT NULL
|
||
AND (
|
||
NEW.start_date < academic_year_start
|
||
OR NEW.end_date > academic_year_end
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Семестр должен полностью находиться в границах учебного года';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_semesters_within_academic_year
|
||
BEFORE INSERT OR UPDATE OF academic_year_id, start_date, end_date
|
||
ON semesters
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION enforce_semester_within_academic_year();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_academic_year_excluding_semesters()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM semesters semester
|
||
WHERE semester.academic_year_id = OLD.id
|
||
AND (
|
||
semester.start_date < NEW.start_date
|
||
OR semester.end_date > NEW.end_date
|
||
)
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Новые границы учебного года не включают все его семестры';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_academic_years_include_semesters
|
||
BEFORE UPDATE OF start_date, end_date
|
||
ON academic_years
|
||
FOR EACH ROW
|
||
WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date)
|
||
EXECUTE FUNCTION prevent_academic_year_excluding_semesters();
|
||
|
||
-- ==========================================
|
||
-- Инварианты зависимостей календарных графиков
|
||
-- ==========================================
|
||
DO $$
|
||
DECLARE
|
||
assignment_violations BIGINT;
|
||
calendar_period_violations BIGINT;
|
||
calendar_subject_violations BIGINT;
|
||
BEGIN
|
||
SELECT count(*)
|
||
INTO assignment_violations
|
||
FROM student_group_calendar_assignments assignment
|
||
JOIN student_groups student_group ON student_group.id = assignment.group_id
|
||
JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id
|
||
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
|
||
WHERE assignment.academic_year_id <> calendar.academic_year_id
|
||
OR student_group.specialty_id <> calendar.specialty_id
|
||
OR student_group.specialty_profile_id <> calendar.specialty_profile_id
|
||
OR student_group.education_form_id <> calendar.study_form_id
|
||
OR CASE
|
||
WHEN extract(month FROM academic_year.start_date) >= 9
|
||
THEN extract(year FROM academic_year.start_date)::INT
|
||
- student_group.year_start_study::INT + 1
|
||
ELSE extract(year FROM academic_year.start_date)::INT
|
||
- student_group.year_start_study::INT
|
||
END NOT BETWEEN 1 AND calendar.course_count;
|
||
|
||
SELECT count(*)
|
||
INTO calendar_period_violations
|
||
FROM academic_calendar_periods calendar_period
|
||
JOIN academic_calendars calendar ON calendar.id = calendar_period.calendar_id
|
||
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
|
||
WHERE calendar_period.course_number > calendar.course_count
|
||
OR calendar_period.start_date < academic_year.start_date
|
||
OR calendar_period.end_date > academic_year.end_date;
|
||
|
||
SELECT count(*)
|
||
INTO calendar_subject_violations
|
||
FROM academic_calendar_subjects calendar_subject
|
||
JOIN academic_calendars calendar ON calendar.id = calendar_subject.calendar_id
|
||
WHERE calendar_subject.semester_number > calendar.course_count * 2;
|
||
|
||
IF assignment_violations > 0
|
||
OR calendar_period_violations > 0
|
||
OR calendar_subject_violations > 0 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = format(
|
||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||
assignment_violations,
|
||
calendar_period_violations,
|
||
calendar_subject_violations
|
||
);
|
||
END IF;
|
||
END $$;
|
||
|
||
CREATE OR REPLACE FUNCTION calculate_group_course_for_academic_year(
|
||
academic_year_start DATE,
|
||
group_start_year BIGINT
|
||
)
|
||
RETURNS INTEGER
|
||
LANGUAGE SQL
|
||
IMMUTABLE
|
||
STRICT
|
||
AS $$
|
||
SELECT CASE
|
||
WHEN extract(month FROM academic_year_start) >= 9
|
||
THEN extract(year FROM academic_year_start)::INT - group_start_year::INT + 1
|
||
ELSE extract(year FROM academic_year_start)::INT - group_start_year::INT
|
||
END
|
||
$$;
|
||
|
||
CREATE OR REPLACE FUNCTION enforce_calendar_assignment_compatibility()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
calendar_academic_year_id BIGINT;
|
||
calendar_specialty_id BIGINT;
|
||
calendar_profile_id BIGINT;
|
||
calendar_study_form_id BIGINT;
|
||
calendar_course_count INTEGER;
|
||
group_specialty_id BIGINT;
|
||
group_profile_id BIGINT;
|
||
group_study_form_id BIGINT;
|
||
group_course INTEGER;
|
||
BEGIN
|
||
SELECT
|
||
calendar.academic_year_id,
|
||
calendar.specialty_id,
|
||
calendar.specialty_profile_id,
|
||
calendar.study_form_id,
|
||
calendar.course_count,
|
||
student_group.specialty_id,
|
||
student_group.specialty_profile_id,
|
||
student_group.education_form_id,
|
||
calculate_group_course_for_academic_year(
|
||
academic_year.start_date,
|
||
student_group.year_start_study
|
||
)
|
||
INTO
|
||
calendar_academic_year_id,
|
||
calendar_specialty_id,
|
||
calendar_profile_id,
|
||
calendar_study_form_id,
|
||
calendar_course_count,
|
||
group_specialty_id,
|
||
group_profile_id,
|
||
group_study_form_id,
|
||
group_course
|
||
FROM student_groups student_group
|
||
CROSS JOIN academic_calendars calendar
|
||
JOIN academic_years academic_year ON academic_year.id = NEW.academic_year_id
|
||
WHERE student_group.id = NEW.group_id
|
||
AND calendar.id = NEW.calendar_id
|
||
FOR NO KEY UPDATE OF student_group
|
||
FOR KEY SHARE OF calendar, academic_year;
|
||
|
||
IF calendar_academic_year_id IS NOT NULL
|
||
AND (
|
||
NEW.academic_year_id <> calendar_academic_year_id
|
||
OR group_specialty_id <> calendar_specialty_id
|
||
OR group_profile_id <> calendar_profile_id
|
||
OR group_study_form_id <> calendar_study_form_id
|
||
OR group_course NOT BETWEEN 1 AND calendar_course_count
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Календарный график не соответствует учебному году, параметрам или курсу группы';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_calendar_assignments_compatible
|
||
BEFORE INSERT OR UPDATE OF group_id, academic_year_id, calendar_id
|
||
ON student_group_calendar_assignments
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION enforce_calendar_assignment_compatibility();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_incompatible_academic_calendar_change()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
academic_year_start DATE;
|
||
academic_year_end DATE;
|
||
BEGIN
|
||
SELECT start_date, end_date
|
||
INTO academic_year_start, academic_year_end
|
||
FROM academic_years
|
||
WHERE id = NEW.academic_year_id
|
||
FOR KEY SHARE;
|
||
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM student_group_calendar_assignments assignment
|
||
JOIN student_groups student_group ON student_group.id = assignment.group_id
|
||
JOIN academic_years assigned_year ON assigned_year.id = assignment.academic_year_id
|
||
WHERE assignment.calendar_id = OLD.id
|
||
AND (
|
||
assignment.academic_year_id <> NEW.academic_year_id
|
||
OR student_group.specialty_id <> NEW.specialty_id
|
||
OR student_group.specialty_profile_id <> NEW.specialty_profile_id
|
||
OR student_group.education_form_id <> NEW.study_form_id
|
||
OR calculate_group_course_for_academic_year(
|
||
assigned_year.start_date,
|
||
student_group.year_start_study
|
||
) NOT BETWEEN 1 AND NEW.course_count
|
||
)
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Нельзя изменить график: сохранённые назначения групп станут несовместимыми';
|
||
END IF;
|
||
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM academic_calendar_periods calendar_period
|
||
WHERE calendar_period.calendar_id = OLD.id
|
||
AND (
|
||
calendar_period.course_number > NEW.course_count
|
||
OR calendar_period.start_date < academic_year_start
|
||
OR calendar_period.end_date > academic_year_end
|
||
)
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Нельзя изменить график: сохранённая сетка выходит за новые измерения';
|
||
END IF;
|
||
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM academic_calendar_subjects calendar_subject
|
||
WHERE calendar_subject.calendar_id = OLD.id
|
||
AND calendar_subject.semester_number > NEW.course_count * 2
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Нельзя изменить график: дисциплины выходят за новые измерения';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_academic_calendars_protect_dependencies
|
||
BEFORE UPDATE OF academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count
|
||
ON academic_calendars
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION prevent_incompatible_academic_calendar_change();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_incompatible_student_group_change()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM student_group_calendar_assignments assignment
|
||
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
|
||
JOIN academic_years academic_year ON academic_year.id = assignment.academic_year_id
|
||
WHERE assignment.group_id = OLD.id
|
||
AND (
|
||
assignment.academic_year_id <> calendar.academic_year_id
|
||
OR NEW.specialty_id <> calendar.specialty_id
|
||
OR NEW.specialty_profile_id <> calendar.specialty_profile_id
|
||
OR NEW.education_form_id <> calendar.study_form_id
|
||
OR calculate_group_course_for_academic_year(
|
||
academic_year.start_date,
|
||
NEW.year_start_study
|
||
) NOT BETWEEN 1 AND calendar.course_count
|
||
)
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Нельзя изменить группу: сохранённые назначения календарных графиков станут несовместимыми';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_student_groups_protect_calendar_assignments
|
||
BEFORE UPDATE OF education_form_id, specialty_id, specialty_profile_id, year_start_study
|
||
ON student_groups
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION prevent_incompatible_student_group_change();
|
||
|
||
CREATE OR REPLACE FUNCTION enforce_calendar_period_dimensions()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
calendar_course_count INTEGER;
|
||
academic_year_start DATE;
|
||
academic_year_end DATE;
|
||
BEGIN
|
||
SELECT calendar.course_count, academic_year.start_date, academic_year.end_date
|
||
INTO calendar_course_count, academic_year_start, academic_year_end
|
||
FROM academic_calendars calendar
|
||
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
|
||
WHERE calendar.id = NEW.calendar_id
|
||
FOR KEY SHARE OF calendar, academic_year;
|
||
|
||
IF calendar_course_count IS NOT NULL
|
||
AND (
|
||
NEW.course_number > calendar_course_count
|
||
OR NEW.start_date < academic_year_start
|
||
OR NEW.end_date > academic_year_end
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Период активности выходит за измерения календарного графика';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_calendar_periods_dimensions
|
||
BEFORE INSERT OR UPDATE OF calendar_id, course_number, start_date, end_date
|
||
ON academic_calendar_periods
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION enforce_calendar_period_dimensions();
|
||
|
||
CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
DECLARE
|
||
calendar_course_count INTEGER;
|
||
BEGIN
|
||
SELECT course_count
|
||
INTO calendar_course_count
|
||
FROM academic_calendars
|
||
WHERE id = NEW.calendar_id
|
||
FOR KEY SHARE;
|
||
|
||
IF calendar_course_count IS NOT NULL
|
||
AND NEW.semester_number > calendar_course_count * 2 THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Номер семестра дисциплины выходит за измерения календарного графика';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_calendar_subjects_dimensions
|
||
BEFORE INSERT OR UPDATE OF calendar_id, semester_number
|
||
ON academic_calendar_subjects
|
||
FOR EACH ROW
|
||
EXECUTE FUNCTION enforce_calendar_subject_dimensions();
|
||
|
||
CREATE OR REPLACE FUNCTION prevent_academic_year_breaking_calendar_dependencies()
|
||
RETURNS TRIGGER
|
||
LANGUAGE plpgsql
|
||
AS $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1
|
||
FROM academic_calendars calendar
|
||
JOIN academic_calendar_periods calendar_period ON calendar_period.calendar_id = calendar.id
|
||
WHERE calendar.academic_year_id = OLD.id
|
||
AND (
|
||
calendar_period.start_date < NEW.start_date
|
||
OR calendar_period.end_date > NEW.end_date
|
||
)
|
||
) OR EXISTS (
|
||
SELECT 1
|
||
FROM student_group_calendar_assignments assignment
|
||
JOIN student_groups student_group ON student_group.id = assignment.group_id
|
||
JOIN academic_calendars calendar ON calendar.id = assignment.calendar_id
|
||
WHERE assignment.academic_year_id = OLD.id
|
||
AND calculate_group_course_for_academic_year(
|
||
NEW.start_date,
|
||
student_group.year_start_study
|
||
) NOT BETWEEN 1 AND calendar.course_count
|
||
) THEN
|
||
RAISE EXCEPTION USING
|
||
ERRCODE = '23514',
|
||
MESSAGE = 'Новые границы учебного года нарушают календарные сетки или назначения групп';
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END $$;
|
||
|
||
CREATE TRIGGER trg_academic_years_protect_calendar_dependencies
|
||
BEFORE UPDATE OF start_date, end_date
|
||
ON academic_years
|
||
FOR EACH ROW
|
||
WHEN (OLD.start_date IS DISTINCT FROM NEW.start_date OR OLD.end_date IS DISTINCT FROM NEW.end_date)
|
||
EXECUTE FUNCTION prevent_academic_year_breaking_calendar_dependencies();
|
||
|
||
-- ==========================================
|
||
-- Историческая принадлежность преподавателей к кафедрам
|
||
-- ==========================================
|
||
ALTER TABLE teacher_department_assignments
|
||
ADD CONSTRAINT ex_teacher_primary_department_no_overlap
|
||
EXCLUDE USING gist (
|
||
teacher_id WITH =,
|
||
(daterange(valid_from, COALESCE(valid_to, 'infinity'::DATE), '[]')) WITH &&
|
||
)
|
||
WHERE (is_primary = TRUE);
|
||
|
||
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
|
||
ON teacher_department_assignments
|
||
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
|