-- ========================================== -- Инициализация расширений -- ========================================== 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 ); 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 ); 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) ); -- ========================================== -- Пользователи и роли -- ========================================== 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 ); 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; 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 ); -- ========================================== -- Учебные группы -- ========================================== 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 ); -- ========================================== -- Подгруппы (например: "ИВТ-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(); -- ========================================== -- Справочники -- ========================================== -- Дисциплины 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 'Глобально уникальное название дисциплины без учёта регистра'; 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 ); -- Оборудование 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 ); -- Аудитории 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 ); -- Привязка оборудования к аудиториям (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) ); 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'; 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); 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) ); 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) ); 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 ); 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); 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 $$; 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); 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(); -- ========================================== -- Функция обновления 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. Исправьте исходные данные и повторите создание схемы.', 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. Исправьте исходные данные и повторите создание схемы.', 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. Исправьте исходные данные и повторите создание схемы.', 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. Исправьте исходные данные и повторите создание схемы.', 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. Исправьте исходные данные и повторите создание схемы.', 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. Исправьте исходные данные и повторите создание схемы.', 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 'У преподавателя не могут пересекаться периоды двух основных кафедр'; -- ========================================== -- Отсутствия преподавателей и мастер замены -- ========================================== CREATE TABLE teacher_absences ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), start_date DATE NOT NULL, end_date DATE NOT NULL, reason TEXT NOT NULL, status VARCHAR(20) NOT NULL, requested_by BIGINT REFERENCES users(id), reviewed_by BIGINT REFERENCES users(id), review_comment TEXT, reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date), CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000), CONSTRAINT chk_teacher_absences_status CHECK ( status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED') ) ); CREATE INDEX idx_teacher_absences_teacher_period ON teacher_absences(teacher_id, start_date, end_date); CREATE INDEX idx_teacher_absences_status ON teacher_absences(status); CREATE TABLE teacher_absence_decisions ( id BIGSERIAL PRIMARY KEY, absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE, base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), lesson_date DATE NOT NULL, resolution VARCHAR(30) NOT NULL, decision_status VARCHAR(20) NOT NULL, schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL, comment TEXT, decided_by BIGINT REFERENCES users(id), decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE ( absence_id, base_rule_slot_id, lesson_date ), CONSTRAINT chk_teacher_absence_decision_resolution CHECK ( resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT') ), CONSTRAINT chk_teacher_absence_decision_status CHECK ( decision_status IN ('APPLIED', 'REJECTED') ) ); CREATE INDEX idx_teacher_absence_decisions_absence ON teacher_absence_decisions(absence_id, lesson_date); COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой'; COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED'; COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены'; COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия'; -- ========================================== -- Пожелания и заявки преподавателей -- ========================================== CREATE TABLE teacher_preferences ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), semester_id BIGINT NOT NULL REFERENCES semesters(id), preference_type VARCHAR(30) NOT NULL, day_of_week SMALLINT, time_slot_id BIGINT REFERENCES time_slots(id), preference_date DATE, comment TEXT, status VARCHAR(20) NOT NULL, requested_by BIGINT REFERENCES users(id), reviewed_by BIGINT REFERENCES users(id), review_comment TEXT, reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_teacher_preferences_type CHECK ( preference_type IN ( 'HARD_UNAVAILABLE', 'SOFT_PREFERRED', 'SOFT_UNWANTED', 'CONSECUTIVE', 'NO_GAPS' ) ), CONSTRAINT chk_teacher_preferences_status CHECK ( status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') ), CONSTRAINT chk_teacher_preferences_day CHECK ( day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7 ), CONSTRAINT chk_teacher_preferences_payload CHECK ( ( preference_type = 'HARD_UNAVAILABLE' AND ( (preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL) OR (preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL) ) ) OR ( preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED') AND preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL ) OR ( preference_type IN ('CONSECUTIVE', 'NO_GAPS') AND preference_date IS NULL AND day_of_week IS NULL AND time_slot_id IS NULL ) ), CONSTRAINT chk_teacher_preferences_comment CHECK ( comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000 ) ); CREATE INDEX idx_teacher_preferences_teacher_semester ON teacher_preferences(teacher_id, semester_id, status); CREATE INDEX idx_teacher_preferences_slot ON teacher_preferences(semester_id, day_of_week, time_slot_id) WHERE status = 'APPROVED'; CREATE INDEX idx_teacher_preferences_date ON teacher_preferences(teacher_id, preference_date) WHERE status = 'APPROVED' AND preference_date IS NOT NULL; CREATE TABLE teacher_change_requests ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), lesson_date DATE NOT NULL, request_type VARCHAR(30) NOT NULL, target_lesson_date DATE, requested_time_slot_id BIGINT REFERENCES time_slots(id), requested_classroom_id BIGINT REFERENCES classrooms(id), reason TEXT NOT NULL, status VARCHAR(20) NOT NULL, reviewed_by BIGINT REFERENCES users(id), review_comment TEXT, reviewed_at TIMESTAMPTZ, applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_teacher_change_requests_type CHECK ( request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL') ), CONSTRAINT chk_teacher_change_requests_status CHECK ( status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') ), CONSTRAINT chk_teacher_change_requests_reason CHECK ( length(trim(reason)) BETWEEN 1 AND 2000 ), CONSTRAINT chk_teacher_change_requests_payload CHECK ( ( request_type = 'MOVE' AND requested_time_slot_id IS NOT NULL AND requested_classroom_id IS NULL ) OR ( request_type = 'CHANGE_CLASSROOM' AND target_lesson_date IS NULL AND requested_time_slot_id IS NULL AND requested_classroom_id IS NOT NULL ) OR ( request_type = 'CANCEL' AND target_lesson_date IS NULL AND requested_time_slot_id IS NULL AND requested_classroom_id IS NULL ) ) ); CREATE INDEX idx_teacher_change_requests_teacher ON teacher_change_requests(teacher_id, created_at DESC); CREATE INDEX idx_teacher_change_requests_status ON teacher_change_requests(status, created_at); CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson ON teacher_change_requests(base_rule_slot_id, lesson_date) WHERE status = 'PENDING'; CREATE TABLE teacher_change_request_history ( id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL, comment TEXT, actor_id BIGINT REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_teacher_change_request_history_status CHECK ( status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') ) ); CREATE INDEX idx_teacher_change_request_history_request ON teacher_change_request_history(request_id, created_at, id); COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр'; COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания'; COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия'; COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя'; -- ========================================== -- Черновики, версии, публикация и аудит расписания -- ========================================== CREATE TABLE schedule_versions ( id BIGSERIAL PRIMARY KEY, semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, version_number INT NOT NULL, name VARCHAR(160) NOT NULL, status VARCHAR(20) NOT NULL, based_on_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL, restored_from_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL, change_reason TEXT, created_by BIGINT REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, published_by BIGINT REFERENCES users(id), published_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, CONSTRAINT uq_schedule_versions_number UNIQUE (semester_id, version_number), CONSTRAINT chk_schedule_versions_number CHECK (version_number > 0), CONSTRAINT chk_schedule_versions_name CHECK (length(trim(name)) BETWEEN 1 AND 160), CONSTRAINT chk_schedule_versions_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), CONSTRAINT chk_schedule_versions_publish_fields CHECK ( status <> 'PUBLISHED' OR published_at IS NOT NULL ) ); CREATE UNIQUE INDEX uq_schedule_versions_published_semester ON schedule_versions(semester_id) WHERE status = 'PUBLISHED'; CREATE INDEX idx_schedule_versions_semester_history ON schedule_versions(semester_id, version_number DESC); ALTER TABLE schedule_rules ADD COLUMN schedule_version_id BIGINT; ALTER TABLE schedule_rules ALTER COLUMN schedule_version_id SET NOT NULL, ADD CONSTRAINT fk_schedule_rules_version FOREIGN KEY (schedule_version_id) REFERENCES schedule_versions(id) ON DELETE CASCADE; CREATE INDEX idx_schedule_rules_version ON schedule_rules(schedule_version_id, status); CREATE UNIQUE INDEX uq_schedule_rules_version_group ON schedule_rules(schedule_version_id, version_group_id) WHERE version_group_id IS NOT NULL; CREATE TABLE schedule_version_history ( id BIGSERIAL PRIMARY KEY, version_id BIGINT NOT NULL REFERENCES schedule_versions(id) ON DELETE CASCADE, action VARCHAR(30) NOT NULL, actor_id BIGINT REFERENCES users(id), reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_schedule_version_history_action CHECK ( action IN ('CREATED', 'PUBLISHED', 'ARCHIVED', 'RESTORED') ), CONSTRAINT chk_schedule_version_history_reason CHECK ( reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 2000 ) ); CREATE INDEX idx_schedule_version_history_version ON schedule_version_history(version_id, created_at DESC, id DESC); COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания'; COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED'; COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик'; COMMENT ON COLUMN schedule_versions.restored_from_version_id IS 'Предыдущая опубликованная версия при восстановлении'; COMMENT ON COLUMN schedule_rules.schedule_version_id IS 'Версия расписания, которой принадлежит правило'; COMMENT ON COLUMN schedule_rules.version_group_id IS 'Стабильный идентификатор правила между версиями'; COMMENT ON TABLE schedule_version_history IS 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';