Files
magistr/backend/src/main/resources/db/migration/V1__init.sql

2185 lines
91 KiB
PL/PgSQL
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ==========================================
-- Инициализация расширений
-- ==========================================
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 'У преподавателя не могут пересекаться периоды двух основных кафедр';