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

2146 lines
89 KiB
PL/PgSQL
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ==========================================
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- ===============================
-- Справочники высшего уровня
-- ===============================
CREATE TABLE IF NOT EXISTS departments (
id BIGSERIAL UNIQUE PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
code BIGINT UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
active_from DATE,
active_to DATE,
archived_at TIMESTAMPTZ,
archived_by BIGINT,
archive_reason TEXT
);
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 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';