1243 lines
69 KiB
Markdown
1243 lines
69 KiB
Markdown
# 🗄 База данных
|
||
|
||
## Общая информация
|
||
|
||
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
|
||
- **Управление схемой:** Flyway (программный запуск)
|
||
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraints временных слотов и календарных периодов)
|
||
- **Мультитенантность:** Каждый тенант = отдельная БД
|
||
- **Абсолютное время:** `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_periods {
|
||
BIGSERIAL id PK
|
||
BIGINT calendar_id FK
|
||
INT course_number
|
||
DATE start_date
|
||
DATE end_date
|
||
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_versions {
|
||
BIGSERIAL id PK
|
||
BIGINT semester_id FK
|
||
INT version_number
|
||
VARCHAR name
|
||
VARCHAR status
|
||
BIGINT based_on_version_id FK
|
||
BIGINT restored_from_version_id FK
|
||
TEXT change_reason
|
||
BIGINT created_by FK
|
||
TIMESTAMPTZ created_at
|
||
BIGINT published_by FK
|
||
TIMESTAMPTZ published_at
|
||
TIMESTAMPTZ archived_at
|
||
}
|
||
|
||
schedule_version_history {
|
||
BIGSERIAL id PK
|
||
BIGINT version_id FK
|
||
VARCHAR action
|
||
BIGINT actor_id FK
|
||
TEXT reason
|
||
TIMESTAMPTZ created_at
|
||
}
|
||
|
||
schedule_rules {
|
||
BIGSERIAL id PK
|
||
BIGINT subject_id FK
|
||
BIGINT semester_id FK
|
||
BIGINT schedule_version_id FK
|
||
BIGINT version_group_id
|
||
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
|
||
DATE target_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
|
||
}
|
||
|
||
teacher_absences {
|
||
BIGSERIAL id PK
|
||
BIGINT teacher_id FK
|
||
DATE start_date
|
||
DATE end_date
|
||
TEXT reason
|
||
VARCHAR status
|
||
BIGINT requested_by FK
|
||
BIGINT reviewed_by FK
|
||
TEXT review_comment
|
||
TIMESTAMPTZ reviewed_at
|
||
TIMESTAMPTZ created_at
|
||
TIMESTAMPTZ updated_at
|
||
}
|
||
|
||
teacher_absence_decisions {
|
||
BIGSERIAL id PK
|
||
BIGINT absence_id FK
|
||
BIGINT base_rule_slot_id FK
|
||
DATE lesson_date
|
||
VARCHAR resolution
|
||
VARCHAR decision_status
|
||
BIGINT schedule_override_id FK
|
||
TEXT comment
|
||
BIGINT decided_by FK
|
||
TIMESTAMPTZ decided_at
|
||
}
|
||
|
||
teacher_preferences {
|
||
BIGSERIAL id PK
|
||
BIGINT teacher_id FK
|
||
BIGINT semester_id FK
|
||
VARCHAR preference_type
|
||
INT day_of_week
|
||
BIGINT time_slot_id FK
|
||
DATE preference_date
|
||
TEXT comment
|
||
VARCHAR status
|
||
BIGINT requested_by FK
|
||
BIGINT reviewed_by FK
|
||
TEXT review_comment
|
||
TIMESTAMPTZ reviewed_at
|
||
TIMESTAMPTZ created_at
|
||
TIMESTAMPTZ updated_at
|
||
}
|
||
|
||
teacher_change_requests {
|
||
BIGSERIAL id PK
|
||
BIGINT teacher_id FK
|
||
BIGINT base_rule_slot_id FK
|
||
DATE lesson_date
|
||
VARCHAR request_type
|
||
DATE target_lesson_date
|
||
BIGINT requested_time_slot_id FK
|
||
BIGINT requested_classroom_id FK
|
||
TEXT reason
|
||
VARCHAR status
|
||
BIGINT reviewed_by FK
|
||
BIGINT applied_override_id FK
|
||
TIMESTAMPTZ created_at
|
||
TIMESTAMPTZ updated_at
|
||
}
|
||
|
||
teacher_change_request_history {
|
||
BIGSERIAL id PK
|
||
BIGINT request_id FK
|
||
VARCHAR status
|
||
TEXT comment
|
||
BIGINT actor_id FK
|
||
TIMESTAMPTZ created_at
|
||
}
|
||
|
||
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_versions : "semester_id"
|
||
schedule_versions ||--o{ schedule_rules : "schedule_version_id"
|
||
schedule_versions ||--o{ schedule_version_history : "version_id"
|
||
schedule_versions o|--o{ schedule_versions : "based_on/restored_from"
|
||
users ||--o{ schedule_versions : "created_by/published_by"
|
||
users ||--o{ schedule_version_history : "actor_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_periods : "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_periods : "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"
|
||
users ||--o{ teacher_absences : "teacher_id/requested_by/reviewed_by"
|
||
teacher_absences ||--o{ teacher_absence_decisions : "absence_id"
|
||
schedule_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id"
|
||
schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id"
|
||
users ||--o{ teacher_absence_decisions : "decided_by"
|
||
users ||--o{ teacher_preferences : "teacher_id/requested_by/reviewed_by"
|
||
semesters ||--o{ teacher_preferences : "semester_id"
|
||
time_slots ||--o{ teacher_preferences : "time_slot_id"
|
||
users ||--o{ teacher_change_requests : "teacher_id/reviewed_by"
|
||
schedule_rule_slots ||--o{ teacher_change_requests : "base_rule_slot_id"
|
||
time_slots ||--o{ teacher_change_requests : "requested_time_slot_id"
|
||
classrooms ||--o{ teacher_change_requests : "requested_classroom_id"
|
||
schedule_overrides ||--o{ teacher_change_requests : "applied_override_id"
|
||
teacher_change_requests ||--o{ teacher_change_request_history : "request_id"
|
||
users ||--o{ teacher_change_request_history : "actor_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(1–8) | Количество курсов в сетке |
|
||
| `created_at` | TIMESTAMPTZ | Дата создания |
|
||
| `updated_at` | TIMESTAMPTZ | Дата обновления |
|
||
|
||
Триггер `trg_academic_calendars_protect_dependencies` не позволяет изменить учебный год,
|
||
специальность, профиль, форму обучения или количество курсов так, чтобы уже назначенная
|
||
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
|
||
остаются строки старших курсов или дисциплины старших семестров.
|
||
|
||
При создании графика backend в той же транзакции добавляет по одному периоду `Т` на весь
|
||
учебный год для каждого курса `1..course_count`. Если создание периодов завершается ошибкой,
|
||
строка `academic_calendars` также откатывается.
|
||
|
||
#### `academic_calendar_periods` — Периоды активности календарного графика
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID |
|
||
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||
| `course_number` | INT | Номер курса |
|
||
| `start_date` | DATE | Первая дата периода, включительно |
|
||
| `end_date` | DATE | Последняя дата периода, включительно |
|
||
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
||
|
||
Триггер `trg_calendar_periods_dimensions` требует, чтобы `course_number` не превышал
|
||
`academic_calendars.course_count`, обе границы находились внутри учебного года графика,
|
||
а `end_date` была не раньше `start_date`. Exclusion constraint
|
||
`ex_calendar_periods_no_overlap` запрещает пересечение периодов одного курса и графика.
|
||
Соседние даты с одинаковой активностью объединяет backend; номер недели и ISO-день
|
||
вычисляются при разворачивании периода в дневной REST-ответ и в таблице не дублируются.
|
||
|
||
#### `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_versions` — Версии расписания семестра
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID версии |
|
||
| `semester_id` | BIGINT FK → semesters (CASCADE) | Семестр |
|
||
| `version_number` | INT CHECK(> 0) | Последовательный номер внутри семестра |
|
||
| `name` | VARCHAR(160) | Пользовательское название |
|
||
| `status` | VARCHAR(20) | `DRAFT`, `PUBLISHED` или `ARCHIVED` |
|
||
| `based_on_version_id` | BIGINT FK → schedule_versions | Версия-основа черновика |
|
||
| `restored_from_version_id` | BIGINT FK → schedule_versions | Публикация, которую заменили при восстановлении |
|
||
| `change_reason` | TEXT | Причина последней публикации или восстановления |
|
||
| `created_by` | BIGINT FK → users | Автор черновика |
|
||
| `created_at` | TIMESTAMPTZ | Время создания |
|
||
| `published_by` | BIGINT FK → users | Автор публикации |
|
||
| `published_at` | TIMESTAMPTZ | Время последней публикации |
|
||
| `archived_at` | TIMESTAMPTZ | Время архивирования |
|
||
|
||
Пара `semester_id + version_number` уникальна. Частичный индекс
|
||
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
||
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
|
||
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
|
||
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
||
`Основное расписание` и две записи истории: создание и публикацию.
|
||
|
||
#### `schedule_version_history` — Аудит версий расписания
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID события |
|
||
| `version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания |
|
||
| `action` | VARCHAR(30) | `CREATED`, `PUBLISHED`, `ARCHIVED` или `RESTORED` |
|
||
| `actor_id` | BIGINT FK → users | Автор действия; `NULL` для системной инициализации |
|
||
| `reason` | TEXT | Причина или описание события, до 2000 символов |
|
||
| `created_at` | TIMESTAMPTZ | Время события |
|
||
|
||
Журнал добавляется при каждом переходе версии и выводится в обратной хронологии. Он не
|
||
заменяет данные версии, а сохраняет отдельные факты аудита.
|
||
|
||
#### `schedule_rules` — Правила расписания
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID |
|
||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||
| `schedule_version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания |
|
||
| `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_*`. Публичная генерация дополнительно требует статус `PUBLISHED` у связанной версии. Уникальный индекс по `schedule_version_id + version_group_id` не позволяет дважды скопировать одну логическую линию правила в одну версию.
|
||
|
||
Базовая схема 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(1–7) | День недели: 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 | Исходная дата конкретной пары из базового правила |
|
||
| `target_lesson_date` | DATE NULL | Новая дата единственного занятия при `MOVE` |
|
||
| `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`.
|
||
- `target_lesson_date` отличается от `lesson_date`, разрешена только для `MOVE` и требует
|
||
`new_time_slot_id`.
|
||
|
||
Частичный индекс `idx_schedule_overrides_target_date` ускоряет выборку входящих переносов,
|
||
а индекс исходной даты `idx_schedule_overrides_date` обслуживает исходящие изменения и
|
||
отмены. Диапазонный реестр и поиск используют обе даты.
|
||
|
||
Фактическое существование пары, тот же семестр, учебный календарь затронутых групп,
|
||
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
|
||
конфликты проверяются транзакционным сервисом, а не SQL CHECK.
|
||
|
||
#### `teacher_absences` — Отсутствия преподавателей
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID отсутствия |
|
||
| `teacher_id` | BIGINT FK → users | Отсутствующий преподаватель |
|
||
| `start_date`, `end_date` | DATE | Включительные границы периода |
|
||
| `reason` | TEXT | Причина длиной 1–2000 символов |
|
||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `RESOLVED`, `CANCELLED` |
|
||
| `requested_by` | BIGINT FK → users | Автор регистрации или заявки |
|
||
| `reviewed_by` | BIGINT FK → users | Подтвердивший или отклонивший сотрудник |
|
||
| `review_comment` | TEXT | Комментарий согласования |
|
||
| `reviewed_at` | TIMESTAMPTZ | Время согласования |
|
||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||
|
||
CHECK запрещает обратный период, пустую или слишком длинную причину и неизвестный статус.
|
||
Индекс `(teacher_id, start_date, end_date)` ускоряет поиск пересекающихся отсутствий и
|
||
проверку кандидатов мастера.
|
||
|
||
#### `teacher_absence_decisions` — Журнал мастера замены
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID записи журнала |
|
||
| `absence_id` | BIGINT FK → teacher_absences | Инцидент отсутствия |
|
||
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия |
|
||
| `lesson_date` | DATE | Исходная дата занятия |
|
||
| `resolution` | VARCHAR(30) | Замена, перенос, аудитория, отмена или отклонение |
|
||
| `decision_status` | VARCHAR(20) | `APPLIED` либо `REJECTED` |
|
||
| `schedule_override_id` | BIGINT FK → schedule_overrides | Созданная разовая правка, если есть |
|
||
| `comment` | TEXT | Комментарий ответственного сотрудника |
|
||
| `decided_by` | BIGINT FK → users | Автор решения |
|
||
| `decided_at` | TIMESTAMPTZ | Время решения |
|
||
|
||
Уникальность `(absence_id, base_rule_slot_id, lesson_date)` не позволяет повторно обработать
|
||
одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись
|
||
сохраняется.
|
||
|
||
#### `teacher_preferences` — Пожелания преподавателей на семестр
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID пожелания |
|
||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||
| `semester_id` | BIGINT FK → semesters | Семестр действия |
|
||
| `preference_type` | VARCHAR(30) | `HARD_UNAVAILABLE`, `SOFT_PREFERRED`, `SOFT_UNWANTED`, `CONSECUTIVE` или `NO_GAPS` |
|
||
| `day_of_week` | SMALLINT NULL | День недели 1–7 для интервального пожелания |
|
||
| `time_slot_id` | BIGINT FK → time_slots NULL | Пара базовой сетки времени |
|
||
| `preference_date` | DATE NULL | Полностью недоступная дата при `HARD_UNAVAILABLE` |
|
||
| `comment` | TEXT | Комментарий преподавателя |
|
||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` |
|
||
| `requested_by` | BIGINT FK → users | Автор записи |
|
||
| `reviewed_by` | BIGINT FK → users | Автор решения |
|
||
| `review_comment` | TEXT | Комментарий согласования |
|
||
| `reviewed_at` | TIMESTAMPTZ | Время решения |
|
||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||
|
||
CHECK-ограничение разделяет три допустимые формы записи: строгая недоступность по целой
|
||
дате, строгое или мягкое пожелание по паре недели, либо пожелание компактности без даты и
|
||
пары. Частичные индексы ускоряют проверку согласованной недоступности по интервалу и дате.
|
||
Семантическая уникальность активных пожеланий проверяется сервисом, поскольку PostgreSQL
|
||
считает значения `NULL` различными.
|
||
|
||
#### `teacher_change_requests` — Заявки на изменение занятия
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID заявки |
|
||
| `teacher_id` | BIGINT FK → users | Преподаватель — автор заявки |
|
||
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия |
|
||
| `lesson_date` | DATE | Исходная дата занятия |
|
||
| `request_type` | VARCHAR(30) | `MOVE`, `CHANGE_CLASSROOM` или `CANCEL` |
|
||
| `target_lesson_date` | DATE NULL | Целевая дата переноса |
|
||
| `requested_time_slot_id` | BIGINT FK → time_slots NULL | Запрошенная пара при переносе |
|
||
| `requested_classroom_id` | BIGINT FK → classrooms NULL | Запрошенная аудитория |
|
||
| `reason` | TEXT | Обязательное обоснование длиной 1–2000 символов |
|
||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` |
|
||
| `reviewed_by` | BIGINT FK → users | Сотрудник учебного отдела |
|
||
| `review_comment` | TEXT | Комментарий решения |
|
||
| `reviewed_at` | TIMESTAMPTZ | Время решения |
|
||
| `applied_override_id` | BIGINT FK → schedule_overrides NULL | Разовая правка одобренной заявки |
|
||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||
|
||
CHECK фиксирует допустимую форму каждого типа заявки. Частичный уникальный индекс по
|
||
`(base_rule_slot_id, lesson_date)` запрещает две одновременно ожидающие заявки на одну пару.
|
||
При удалении применённого override ссылка обнуляется, но заявка и история сохраняются.
|
||
|
||
#### `teacher_change_request_history` — История решений по заявке
|
||
|
||
| Колонка | Тип | Описание |
|
||
|---------|-----|----------|
|
||
| `id` | BIGSERIAL PK | ID события |
|
||
| `request_id` | BIGINT FK → teacher_change_requests (CASCADE) | Заявка |
|
||
| `status` | VARCHAR(20) | Зафиксированный статус заявки |
|
||
| `comment` | TEXT | Обоснование или комментарий решения |
|
||
| `actor_id` | BIGINT FK → users | Автор перехода |
|
||
| `created_at` | TIMESTAMPTZ | Время события |
|
||
|
||
Записи добавляются при создании, принятии, отклонении и отзыве заявки. Индекс
|
||
`(request_id, created_at, id)` обеспечивает стабильный хронологический вывод журнала.
|
||
|
||
---
|
||
|
||
## Flyway миграции
|
||
|
||
### Правила работы
|
||
|
||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
|
||
триггеры и ограничения изменяются только в `V1__init.sql`.
|
||
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
|
||
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
|
||
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
|
||
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
|
||
tenant-БД. Сброс выполняется только по прямой команде владельца.
|
||
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
||
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
||
пустой tenant-схеме
|
||
|
||
### Текущие миграции
|
||
|
||
| Файл | Описание |
|
||
|------|----------|
|
||
| `V1__init.sql` | Полная schema-only baseline: таблицы, связи, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия, пожелания и заявки преподавателей, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии. Постоянных доменных данных нет |
|
||
| `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
|
||
|
||
### Этап разработки
|
||
|
||
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
|
||
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
|
||
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
|
||
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
|
||
|
||
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
|
||
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
|
||
production. Это сознательное ограничение текущего режима разработки; перед production-релизом
|
||
V2 необходимо сделать условной или исключить из production locations.
|
||
|
||
### Объём тестового набора V2
|
||
|
||
На чистой схеме V2 создаёт детерминированный связанный набор:
|
||
|
||
| Сущности | Количество |
|
||
|----------|-----------:|
|
||
| Кафедры / специальности / профили | 41 / 42 / 42 |
|
||
| Пользователи / преподаватели | 155 / 151 |
|
||
| Группы / подгруппы | 202 / 404 |
|
||
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
|
||
| Аудитории / связи с оборудованием | 80 / 174 |
|
||
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
|
||
| Версии / события истории | 8 / 16 |
|
||
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
|
||
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
|
||
|
||
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
|
||
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
|
||
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
|
||
|
||
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
|
||
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
|
||
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
|
||
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
|
||
исторические demo-аккаунты.
|
||
|
||
### Полный сброс БД (локально)
|
||
|
||
```bash
|
||
docker compose down -v # Удаляет volumes (данные)
|
||
docker compose up -d # Пересоздаёт БД с нуля
|
||
```
|