# 🗄 База данных

## Общая информация

- **СУБД:** 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
        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
    }

    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(1–8) | Количество курсов в сетке |
| `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 | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
| `day_of_week` | INT CHECK(1–7) | День недели 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(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.

---

## 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-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__align_academic_calendar_weeks_to_monday.sql` | Перенумерация сохранённых дней календарного графика по периодам `понедельник–воскресенье`, чтобы неполная первая неделя не заполнялась датами следующей недели |

### Этап разработки

По прямому решению владельца проекта прежние разработческие миграции V2–V7 были объединены
в baseline `V1`. Новая миграция `V2__align_academic_calendar_weeks_to_monday.sql` создана
после фиксации baseline и накатывается поверх существующих tenant-БД без изменения
контрольной суммы V1.

### Полный сброс БД (локально)

```bash
docker compose down -v    # Удаляет volumes (данные)
docker compose up -d      # Пересоздаёт БД с нуля
```
