-- ========================================== -- Инициализация расширений -- ========================================== 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 'У преподавателя не могут пересекаться периоды двух основных кафедр';