Files
magistr/docs/DATABASE.md
2026-07-19 20:16:12 +03:00

928 lines
48 KiB
Markdown
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.
# 🗄 База данных
## Общая информация
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
- **Мультитенантность:** Каждый тенант = отдельная БД
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
---
## ER-диаграмма
```mermaid
erDiagram
departments {
BIGSERIAL id PK
VARCHAR name
BIGINT code UK
}
specialties {
BIGSERIAL id PK
VARCHAR name
VARCHAR specialty_code UK
}
specialty_profiles {
BIGSERIAL id PK
BIGINT specialty_id FK
VARCHAR name
TEXT description
}
users {
BIGSERIAL id PK
VARCHAR username UK
VARCHAR password
VARCHAR role
VARCHAR full_name
VARCHAR job_title
BIGINT department_id FK
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMPTZ archived_at
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
auth_refresh_tokens {
BIGSERIAL id PK
BIGINT user_id FK
VARCHAR tenant
VARCHAR token_hash UK
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at
TIMESTAMPTZ revoked_at
VARCHAR rotated_to_token_hash
}
auth_login_rate_limits {
BIGSERIAL id PK
VARCHAR tenant UK
VARCHAR username_normalized UK
VARCHAR client_ip UK
INTEGER failure_count
TIMESTAMPTZ window_started_at
TIMESTAMPTZ last_failure_at
TIMESTAMPTZ blocked_until
TIMESTAMPTZ updated_at
}
auth_login_attempt_audit {
BIGSERIAL id PK
VARCHAR tenant
VARCHAR username_normalized
VARCHAR client_ip
VARCHAR outcome
TIMESTAMPTZ occurred_at
INTEGER retry_after_seconds
}
education_forms {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
TIMESTAMPTZ created_at
}
student_groups {
BIGSERIAL id PK
VARCHAR name
BIGINT group_size
BIGINT education_form_id FK
BIGINT department_id FK
BIGINT specialty_id FK
BIGINT specialty_profile_id FK
BIGINT year_start_study
TIMESTAMPTZ created_at
}
subgroups {
BIGSERIAL id PK
BIGINT group_id FK
VARCHAR name
INT student_capacity
}
subjects {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR code
BIGINT department_id FK
TEXT description
TIMESTAMPTZ created_at
}
lesson_types {
BIGSERIAL id PK
VARCHAR name UK
VARCHAR color_code
INT duration_minutes
}
equipments {
BIGSERIAL id PK
VARCHAR name UK
TEXT description
VARCHAR inventory_number
}
classrooms {
BIGSERIAL id PK
VARCHAR name UK
INT capacity
VARCHAR building
INT floor
BOOLEAN is_available
VARCHAR status
DATE active_from
DATE active_to
TIMESTAMPTZ archived_at
TEXT description
TIMESTAMPTZ created_at
}
classroom_equipments {
BIGINT classroom_id FK,PK
BIGINT equipment_id FK,PK
INT quantity
TEXT notes
}
teacher_subjects {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
VARCHAR qualification_level
INT experience_years
}
teacher_department_assignments {
BIGSERIAL id PK
BIGINT teacher_id FK
BIGINT department_id FK
DATE valid_from
DATE valid_to
BOOLEAN is_primary
TEXT comment
}
teacher_creation_requests {
BIGSERIAL id PK
BIGINT department_id FK
VARCHAR username
VARCHAR full_name
VARCHAR job_title
TEXT comment
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TIMESTAMPTZ reviewed_at
TEXT review_comment
BIGINT created_teacher_id FK
}
subject_comments {
BIGSERIAL id PK
BIGINT subject_id FK
BIGINT author_id FK
TEXT comment
TIMESTAMPTZ created_at
}
teacher_lesson_types {
BIGINT user_id FK,PK
BIGINT subject_id FK,PK
BIGINT lesson_type_id FK,PK
}
time_slot_scopes {
BIGSERIAL id PK
VARCHAR code UK
VARCHAR name
VARCHAR apply_mode
INT day_of_week
BOOLEAN system_scope
INT display_order
}
time_slots {
BIGSERIAL id PK
BIGINT time_slot_scope_id FK
INT order_number
TIME start_time
TIME end_time
INT duration_minutes
}
time_slot_date_assignments {
BIGSERIAL id PK
DATE assignment_date UK
BIGINT time_slot_scope_id FK
}
academic_years {
BIGSERIAL id PK
VARCHAR title UK
DATE start_date
DATE end_date
}
semesters {
BIGSERIAL id PK
BIGINT academic_year_id FK
VARCHAR semester_type
DATE start_date
DATE end_date
}
academic_calendar_activity_types {
BIGSERIAL id PK
VARCHAR code UK
VARCHAR name
BOOLEAN allow_schedule
VARCHAR color_code
INT display_order
}
academic_calendars {
BIGSERIAL id PK
VARCHAR title
BIGINT academic_year_id FK
BIGINT specialty_id FK
BIGINT specialty_profile_id FK
BIGINT study_form_id FK
INT course_count
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
academic_calendar_days {
BIGSERIAL id PK
BIGINT calendar_id FK
INT course_number
DATE date
INT week_number
INT day_of_week
BIGINT activity_type_id FK
}
academic_calendar_subjects {
BIGSERIAL id PK
BIGINT calendar_id FK
INT semester_number
BIGINT subject_id FK
TIMESTAMPTZ created_at
}
student_group_calendar_assignments {
BIGSERIAL id PK
BIGINT group_id FK
BIGINT academic_year_id FK
BIGINT calendar_id FK
}
schedule_rules {
BIGSERIAL id PK
BIGINT subject_id FK
BIGINT semester_id FK
VARCHAR status
DATE valid_from
DATE valid_to
INT lecture_academic_hours
INT laboratory_academic_hours
INT practice_academic_hours
INT lecture_start_week
INT laboratory_start_week
INT practice_start_week
}
schedule_rule_groups {
BIGINT schedule_rule_id FK,PK
BIGINT group_id FK,PK
}
schedule_rule_slots {
BIGSERIAL id PK
BIGINT schedule_rule_id FK
INT day_of_week
VARCHAR parity
BIGINT time_slot_id FK
BIGINT teacher_id FK
BIGINT classroom_id FK
BIGINT lesson_type_id FK
VARCHAR lesson_format
BOOLEAN time_locked
BOOLEAN classroom_locked
BOOLEAN teacher_locked
}
schedule_overrides {
BIGSERIAL id PK
BIGINT base_rule_slot_id FK
DATE lesson_date
VARCHAR action
BIGINT new_time_slot_id FK
BIGINT new_classroom_id FK
BIGINT new_teacher_id FK
VARCHAR new_lesson_format
TEXT comment
}
schedule_rule_slot_subgroups {
BIGINT schedule_rule_slot_id FK,PK
BIGINT subgroup_id FK,PK
}
departments ||--o{ users : "department_id"
departments ||--o{ student_groups : "department_id"
departments ||--o{ subjects : "department_id"
education_forms ||--o{ student_groups : "education_form_id"
specialties ||--o{ specialty_profiles : "specialty_id"
specialties ||--o{ student_groups : "specialty_id"
specialty_profiles ||--o{ student_groups : "specialty_profile_id"
student_groups ||--o{ subgroups : "group_id"
student_groups ||--o{ schedule_rule_groups : "group_id"
student_groups ||--o{ student_group_calendar_assignments : "group_id"
users ||--o{ teacher_subjects : "user_id"
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id"
departments ||--o{ teacher_creation_requests : "department_id"
users ||--o{ teacher_creation_requests : "requested_by/reviewed_by/created_teacher_id"
users ||--o{ teacher_lesson_types : "user_id"
users ||--o{ subject_comments : "author_id"
subjects ||--o{ teacher_subjects : "subject_id"
subjects ||--o{ subject_comments : "subject_id"
subjects ||--o{ teacher_lesson_types : "subject_id"
subjects ||--o{ academic_calendar_subjects : "subject_id"
subjects ||--o{ schedule_rules : "subject_id"
lesson_types ||--o{ teacher_lesson_types : "lesson_type_id"
lesson_types ||--o{ schedule_rule_slots : "lesson_type_id"
classrooms ||--o{ schedule_rule_slots : "classroom_id"
classrooms ||--o{ classroom_equipments : "classroom_id"
equipments ||--o{ classroom_equipments : "equipment_id"
academic_years ||--o{ semesters : "academic_year_id"
academic_years ||--o{ academic_calendars : "academic_year_id"
academic_years ||--o{ student_group_calendar_assignments : "academic_year_id"
semesters ||--o{ schedule_rules : "semester_id"
specialties ||--o{ academic_calendars : "specialty_id"
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_id"
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
schedule_rule_slots ||--o{ schedule_overrides : "base_rule_slot_id"
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
classrooms ||--o{ schedule_overrides : "new_classroom_id"
users ||--o{ schedule_overrides : "new_teacher_id"
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
subgroups ||--o{ schedule_rule_slot_subgroups : "subgroup_id"
users ||--o{ schedule_rule_slots : "teacher_id"
```
---
## Описание таблиц
### Справочники высшего уровня
#### `departments` — Кафедры
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID кафедры |
| `name` | VARCHAR(255) | Название кафедры |
| `code` | BIGINT UNIQUE | Код кафедры |
#### `specialties` — Специальности
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID специальности |
| `name` | VARCHAR(255) | Название специальности |
| `specialty_code` | VARCHAR(255) UNIQUE | Код ФГОС (напр. `10.03.01`) |
#### `specialty_profiles` — Профили обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID профиля |
| `specialty_id` | BIGINT FK → specialties | Специальность, внутри которой существует профиль |
| `name` | VARCHAR(255) | Название профиля, уникально внутри специальности |
| `description` | TEXT | Описание профиля |
### Пользователи
#### `users` — Пользователи системы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID пользователя |
| `username` | VARCHAR(50) UNIQUE | Логин |
| `password` | VARCHAR(255) | bcrypt-хеш пароля |
| `role` | VARCHAR(20) | `ADMIN`, `TEACHER`, `STUDENT` |
| `full_name` | VARCHAR(255) | ФИО |
| `job_title` | VARCHAR(255) | Должность |
| `department_id` | BIGINT FK → departments | Кафедра |
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивный пользователь не может войти |
| `active_from` | DATE | Дата начала действия записи |
| `active_to` | DATE | Дата окончания действия записи |
| `archived_at` | TIMESTAMPTZ | Когда пользователь архивирован |
| `archive_reason` | TEXT | Причина архивирования |
| `created_at` | TIMESTAMPTZ | Дата создания |
| `updated_at` | TIMESTAMPTZ | Дата обновления (авто-триггер) |
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
#### `auth_refresh_tokens` — Refresh-сессии JWT
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID refresh-сессии |
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
| `issued_at` | TIMESTAMPTZ | Дата выдачи |
| `expires_at` | TIMESTAMPTZ | Дата истечения |
| `revoked_at` | TIMESTAMPTZ | Дата отзыва, `NULL` для активной сессии |
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
| `user_agent` | VARCHAR(512) | User-Agent клиента |
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый
refresh-токен отзывается, а клиент получает новый refresh-cookie. Фоновая tenant-aware
очистка удаляет только строки, чьи `expires_at` или `revoked_at` старше настраиваемого срока
audit retention (по умолчанию 30 дней). Индексы `idx_auth_refresh_tokens_cleanup_expires` и
`idx_auth_refresh_tokens_cleanup_revoked` обслуживают ограниченные batch-delete из V1.
#### `auth_login_rate_limits` — Общие счётчики попыток входа
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID состояния rate limit |
| `tenant` | VARCHAR(100) | Тенант запроса |
| `username_normalized` | VARCHAR(100) | NFKC-нормализованное имя в нижнем регистре |
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
| `failure_count` | INTEGER | Число отказов в текущем окне, не меньше нуля |
| `window_started_at` | TIMESTAMPTZ | Начало окна учёта попыток |
| `last_failure_at` | TIMESTAMPTZ | Время последнего отказа |
| `blocked_until` | TIMESTAMPTZ | Окончание временной блокировки либо `NULL` |
| `created_at` | TIMESTAMPTZ | Время создания состояния |
| `updated_at` | TIMESTAMPTZ | Последнее изменение состояния |
Комбинация `(tenant, username_normalized, client_ip)` уникальна. Перед проверкой пароля
backend создаёт строку через `INSERT ... ON CONFLICT DO NOTHING`, затем захватывает её
`FOR UPDATE`; одна tenant-БД поэтому является общим атомарным хранилищем для всех pod.
Индексы по `blocked_until` и `updated_at` обслуживают проверку и очистку.
#### `auth_login_attempt_audit` — Аудит неудачных входов
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID события |
| `tenant` | VARCHAR(100) | Тенант запроса |
| `username_normalized` | VARCHAR(100) | Нормализованное имя из запроса |
| `client_ip` | VARCHAR(64) | Проверенный IP клиента |
| `outcome` | VARCHAR(20) | `FAILURE` или `BLOCKED` |
| `occurred_at` | TIMESTAMPTZ | Время события |
| `retry_after_seconds` | INTEGER | Срок `Retry-After` для блокировки либо `NULL` |
Таблица принципиально не содержит пароль, его хэш из запроса или признак существования
пользователя. Записи старше настраиваемого срока (по умолчанию 90 дней), а также неактивные
счётчики удаляются tenant-aware задачей ограниченными `SKIP LOCKED` пачками.
### Учебный процесс
#### `education_forms` — Формы обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) |
| `description` | TEXT | Описание |
Эта таблица используется и группами (`student_groups.education_form_id`), и календарными учебными графиками (`academic_calendars.study_form_id`). Отдельного справочника форм обучения для календарей нет.
#### `student_groups` — Учебные группы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное |
| `group_size` | BIGINT CHECK (> 0) | Количество студентов |
| `education_form_id` | BIGINT FK → education_forms | Форма обучения |
| `department_id` | BIGINT FK → departments | Кафедра |
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT CHECK (> 0) | Год начала обучения, используется для вычисления текущего курса |
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
| `active_from` | DATE | Дата начала действия группы |
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
| `archived_at` | TIMESTAMPTZ | Дата и время архивирования |
| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `group_id` | BIGINT FK → student_groups (CASCADE) | Родительская группа |
| `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT NOT NULL CHECK (> 0) | Количество студентов |
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах
могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное
создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
CHECK-ограничения требуют положительные `student_groups.group_size`,
`student_groups.year_start_study` и `subgroups.student_capacity`. Триггеры
`validate_student_group_subgroup_capacity` и
`validate_subgroup_student_capacity` блокируют родительскую группу и не допускают,
чтобы сумма численностей активных подгрупп превышала `group_size`. Инвариант действует и
при прямой записи в БД, смене статуса/родительской группы и конкурентных транзакциях.
#### `subjects` — Дисциплины
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(200) NOT NULL | Название |
| `code` | VARCHAR(20) | Код предмета |
| `department_id` | BIGINT FK → departments | Кафедра |
| `description` | TEXT | Описание |
Уникальный функциональный индекс `uq_subjects_name_ci` на `lower(name)` гарантирует
глобальную уникальность названия без учёта регистра и защищает владение дисциплиной при
конкурентном импорте разных кафедр. Индекс входит в единую baseline-миграцию V1.
### Аудиторный фонд
#### `classrooms` — Аудитории
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название (напр. `101 Ленинская`) |
| `capacity` | INT CHECK(> 0) | Вместимость |
| `building` | VARCHAR(50) | Корпус |
| `floor` | INT | Этаж |
| `is_available` | BOOLEAN | Доступна для назначения пар |
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED`; архивные аудитории не выбираются в новых назначениях |
| `active_from` | DATE | Дата начала действия записи |
| `active_to` | DATE | Дата окончания действия записи |
| `archived_at` | TIMESTAMPTZ | Когда аудитория выведена из эксплуатации |
| `archive_reason` | TEXT | Причина архивирования |
| `description` | TEXT | Описание |
#### `equipments` — Оборудование
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название |
| `description` | TEXT | Описание |
| `inventory_number` | VARCHAR(50) | Инвентарный номер |
#### `classroom_equipments` — Привязка оборудования к аудиториям
| Колонка | Тип | Описание |
|---------|-----|----------|
| `classroom_id` | BIGINT PK, FK → classrooms (CASCADE) | Аудитория |
| `equipment_id` | BIGINT PK, FK → equipments (CASCADE) | Оборудование |
| `quantity` | INT CHECK(> 0) | Количество единиц |
| `notes` | TEXT | Примечания |
### Расписание
#### `lesson_types` — Типы занятий (справочник)
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `name` | VARCHAR(50) UNIQUE | Название типа |
| `color_code` | VARCHAR(7) | HEX-цвет для UI (напр. `#FF6B6B`) |
| `duration_minutes` | INT | Длительность (по умолчанию 90) |
### Связи «Преподаватель ↔ Дисциплина»
#### `teacher_subjects` — Квалификация преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `qualification_level` | VARCHAR(50) | Уровень квалификации |
| `experience_years` | INT | Стаж |
#### `teacher_lesson_types` — Типы занятий преподавателя
| Колонка | Тип | Описание |
|---------|-----|----------|
| `user_id` | BIGINT PK, FK → users (CASCADE) | Преподаватель |
| `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина |
| `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия |
#### `teacher_department_assignments` — История кафедр преподавателя
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID исторической записи |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `department_id` | BIGINT FK → departments | Кафедра |
| `valid_from` | DATE | Дата начала принадлежности |
| `valid_to` | DATE | Дата окончания принадлежности, `NULL` для текущей кафедры |
| `is_primary` | BOOLEAN | Основная кафедра преподавателя |
| `comment` | TEXT | Комментарий к переводу |
| `created_at` | TIMESTAMPTZ | Дата создания записи |
| `created_by` | BIGINT FK → users | Кто оформил перевод |
Индекс `uq_teacher_department_open_primary` гарантирует не больше одной открытой основной
кафедры у преподавателя. Ограничение
`ex_teacher_primary_department_no_overlap` запрещает пересечение любых закрытых или открытых
периодов основной кафедры одного преподавателя. Индекс
`uq_teacher_department_open_pair` запрещает две открытые связи одного преподавателя с одной
кафедрой, но позволяет иметь несколько открытых неосновных кафедр. Все эти объекты входят в
единую baseline-миграцию `V1__init.sql`.
#### `teacher_creation_requests` — Заявки кафедр на создание преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID заявки |
| `department_id` | BIGINT FK → departments | Кафедра, которая запрашивает преподавателя |
| `username` | VARCHAR(50) | Предлагаемый логин |
| `full_name` | VARCHAR(255) | ФИО преподавателя |
| `job_title` | VARCHAR(255) | Должность |
| `comment` | TEXT | Комментарий кафедры |
| `status` | VARCHAR(20) | `PENDING`, `APPROVED` или `REJECTED` |
| `requested_by` | BIGINT FK → users | Пользователь, создавший заявку |
| `reviewed_by` | BIGINT FK → users | Администратор, рассмотревший заявку |
| `reviewed_at` | TIMESTAMPTZ | Дата рассмотрения |
| `review_comment` | TEXT | Комментарий администратора |
| `created_teacher_id` | BIGINT FK → users | Созданный преподаватель после одобрения |
| `created_at` | TIMESTAMPTZ | Дата создания заявки |
| `updated_at` | TIMESTAMPTZ | Дата последнего изменения |
Заявка не хранит пароль. Пароль задаётся администратором только при одобрении, после чего создаётся пользователь с ролью `TEACHER` и основная запись в `teacher_department_assignments`. Частичный уникальный индекс `uq_teacher_creation_requests_pending_username` запрещает две открытые заявки с одним логином.
#### `subject_comments` — Комментарии к дисциплинам
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID комментария |
| `subject_id` | BIGINT FK → subjects | Дисциплина |
| `author_id` | BIGINT FK → users | Автор комментария |
| `comment` | TEXT | Текст комментария |
| `created_at` | TIMESTAMPTZ | Дата создания |
#### `time_slot_scopes` — Сетки времени
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `code` | VARCHAR(50) UNIQUE | Системный код сетки |
| `name` | VARCHAR(120) | Название в интерфейсе |
| `apply_mode` | VARCHAR(20) | `DEFAULT`, `WEEKDAY`, `MANUAL` |
| `day_of_week` | INT NULL | День недели для автоматической сетки |
| `system_scope` | BOOLEAN | Защищает базовую и субботнюю сетки от удаления |
| `display_order` | INT | Порядок в списках |
Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботняя сетка` (`WEEKDAY`, `day_of_week = 6`). Дополнительные сетки создаются как `MANUAL` и применяются только через ручные назначения дат.
#### `time_slots` — Временные слоты занятий
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Сетка времени |
| `order_number` | INT | Номер пары в дне |
| `start_time` | TIME | Время начала |
| `end_time` | TIME | Время окончания |
| `duration_minutes` | INT | Длительность в минутах |
Уникальность задаётся индексом `(time_slot_scope_id, order_number)`: в одной сетке может
быть только один слот с номером пары. Базовая схема V1 дополнительно требует точного равенства
`duration_minutes` разнице `end_time - start_time` в полных минутах и запрещает
пересекающиеся интервалы одной сетки через GiST exclusion constraint
`ex_time_slots_scope_no_overlap`. Интервалы трактуются как полуоткрытые `[start, end)`,
поэтому соседние пары разрешены.
Триггеры V1 сохраняют связь правил с базовой сеткой: `schedule_rule_slots` принимает только
слот области `DEFAULT`, используемый слот нельзя перенести в небазовую область, а область с
используемыми слотами нельзя сделать небазовой. Блокировки строк слота и области закрывают
гонку между созданием правила и изменением сетки.
#### `time_slot_date_assignments` — Ручные назначения сеток времени
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `assignment_date` | DATE UNIQUE | Дата ручного применения |
| `time_slot_scope_id` | BIGINT FK → time_slot_scopes | Ручная сетка времени |
#### `academic_years` — Учебные годы
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `title` | VARCHAR(20) UNIQUE | Название, например `2025-2026` |
| `start_date` | DATE | Дата начала |
| `end_date` | DATE | Дата окончания |
V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` для включительных
диапазонов дат. Поэтому два учебных года не могут содержать одну и ту же календарную дату;
следующий год может начаться на следующий день после окончания предыдущего.
#### `semesters` — Семестры
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `academic_year_id` | BIGINT FK → academic_years | Учебный год |
| `semester_type` | VARCHAR(20) | `autumn` или `spring` |
| `start_date` | DATE | Дата начала, от неё считается неделя 1 |
| `end_date` | DATE | Дата окончания |
Пара `(academic_year_id, semester_type)` уникальна. V1 дополнительно запрещает пересечение
включительных диапазонов семестров одного года через `ex_semesters_year_no_overlap`.
Триггеры требуют полного вхождения семестра в границы родительского года и запрещают
сужать учебный год так, чтобы существующий семестр оказался снаружи. Блокировка строки года
в триггере сериализует эти взаимные проверки с конкурентными insert/update.
#### `academic_calendar_activity_types` — Коды активностей графика
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `code` | VARCHAR(10) UNIQUE | Код из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `name` | VARCHAR(100) | Расшифровка кода |
| `allow_schedule` | BOOLEAN | Разрешает генерацию обычных пар |
| `color_code` | VARCHAR(7) | HEX-цвет для редактора |
| `display_order` | INT | Порядок вывода в UI |
| `description` | TEXT | Дополнительное описание |
#### `academic_calendars` — Календарные учебные графики
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `title` | VARCHAR(255) | Название графика |
| `academic_year_id` | BIGINT FK → academic_years | Учебный год |
| `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения |
| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника |
| `course_count` | INT CHECK(18) | Количество курсов в сетке |
| `created_at` | TIMESTAMPTZ | Дата создания |
| `updated_at` | TIMESTAMPTZ | Дата обновления |
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
остаются строки старших курсов или дисциплины старших семестров.
#### `academic_calendar_days` — Дневная сетка календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `course_number` | INT | Номер курса |
| `date` | DATE | Дата учебного года |
| `week_number` | INT | Номер недели от начала учебного года |
| `day_of_week` | INT CHECK(17) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал
`academic_calendars.course_count`, а дата находилась внутри учебного года графика.
#### `academic_calendar_subjects` — Дисциплины календарного графика
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `semester_number` | INT CHECK(> 0) | Номер учебного семестра внутри графика: 1, 2, 3 ... |
| `subject_id` | BIGINT FK → subjects | Дисциплина из справочника |
| `created_at` | TIMESTAMPTZ | Дата создания привязки |
Уникальность задаётся по `calendar_id + semester_number + subject_id`, поэтому одну дисциплину нельзя дважды добавить в один семестр одного графика. Верхняя граница номера семестра проверяется backend и триггером `trg_calendar_subjects_dimensions` по `academic_calendars.course_count * 2`.
#### `student_group_calendar_assignments` — Назначения графиков группам
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `group_id` | BIGINT FK → student_groups (CASCADE) | Учебная группа |
| `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график |
Назначение уникально для пары «группа + учебный год». Триггер
`trg_calendar_assignments_compatible` проверяет совпадение года, специальности, профиля и
формы обучения, а также попадание вычисленного курса группы в `1..course_count`. Обратные
триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки
ссылочных строк закрывают конкурентные записи между несколькими backend-pod.
#### `schedule_rules` — Правила расписания
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `subject_id` | BIGINT FK → subjects | Дисциплина |
| `semester_id` | BIGINT FK → semesters | Семестр |
| `lecture_academic_hours` | INT | Лимит академических часов лекций |
| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ |
| `practice_academic_hours` | INT | Лимит академических часов практик |
| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции |
| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные |
| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики |
| `status` | VARCHAR(20) | `ACTIVE` или `ARCHIVED` |
| `valid_from` | DATE | Начало действия версии правила |
| `valid_to` | DATE | Окончание действия версии правила |
| `version_group_id` | BIGINT | Группа версий одного правила |
| `change_reason` | TEXT | Причина изменения |
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
Базовая схема V1 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
нули допустимы только как лимиты неиспользуемых типов занятия.
#### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание |
|---------|-----|----------|
| `schedule_rule_id` | BIGINT PK, FK → schedule_rules | Правило |
| `group_id` | BIGINT PK, FK → student_groups | Группа |
#### `schedule_rule_slots` — Слоты правила
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID |
| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило |
| `day_of_week` | INT CHECK(17) | День недели: 1 — понедельник |
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
| `teacher_id` | BIGINT FK → users | Преподаватель |
| `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
| `time_locked` | BOOLEAN | Время закреплено учебным отделом |
| `classroom_locked` | BOOLEAN | Аудитория закреплена учебным отделом |
| `teacher_locked` | BOOLEAN | Преподаватель закреплён учебным отделом |
| `locked_by` | BIGINT FK → users | Кто выполнил закрепление |
| `locked_at` | TIMESTAMPTZ | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
одинаковые день, чётность, базовый временной слот, преподавателя, аудиторию, тип и формат
занятия. Более широкие ресурсные пересечения и семантика подгрупп проверяются сервисом,
поскольку зависят от нескольких таблиц и фактических активных недель.
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
| Колонка | Тип | Описание |
|---------|-----|----------|
| `schedule_rule_slot_id` | BIGINT PK, FK → schedule_rule_slots (CASCADE) | Слот правила |
| `subgroup_id` | BIGINT PK, FK → subgroups (CASCADE) | Подгруппа |
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`.
#### `schedule_overrides` — Точечные изменения пар
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID изменения |
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот правила |
| `lesson_date` | DATE | Дата конкретной пары |
| `action` | VARCHAR(20) | `MOVE`, `CANCEL`, `REPLACE` |
| `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот |
| `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория |
| `new_teacher_id` | BIGINT FK → users | Новый преподаватель |
| `new_lesson_format` | VARCHAR(30) | Новый формат занятия |
| `comment` | TEXT | Причина изменения |
| `created_by` | BIGINT FK → users | Автор изменения |
| `created_at` | TIMESTAMPTZ | Дата создания |
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
- `CANCEL` не содержит новых ресурсов;
- `MOVE` содержит новый временной слот или аудиторию;
- `REPLACE` содержит нового преподавателя, аудиторию или формат;
- `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`.
Фактическое существование пары, реальность изменения и ресурсные конфликты зависят от
календаря конкретной даты и проверяются транзакционным сервисом, а не SQL CHECK.
---
## Flyway миграции
### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
### Этап разработки
По прямому решению владельца проекта все миграции V2V7 объединены в V1, поскольку
клиентских tenant-БД ещё нет. После изменения контрольной суммы V1 локальную базу нужно
пересоздать целиком; накатывание этой редакции поверх БД со старой записью V1 в
`flyway_schema_history` не поддерживается.
### Полный сброс БД (локально)
```bash
docker compose down -v # Удаляет volumes (данные)
docker compose up -d # Пересоздаёт БД с нуля
```