Files
magistr/docs/BUSINESS_LOGIC.md
Zuev 855d191517 .
2026-08-14 22:54:16 +03:00

482 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📋 Бизнес-логика
## Ролевая модель
Система поддерживает шесть ролей пользователей:
| Роль | Enum | Возможности |
|------|------|------------|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, учебные периоды и календарные графики, временные слоты, формы обучения, аудитории и загруженность; read-only справочники специальностей и профилей. |
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, заявки на создание преподавателей, комментарии, расписание и нагрузка кафедры. |
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в режиме одной активной совмещённой таблицы чётной/нечётной недели. |
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
После авторизации пользователь перенаправляется на свой интерфейс:
- `ADMIN` → `/admin/`
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
- `DEPARTMENT` → `/admin/#department-workspace`
- `SCHEDULE_VIEWER` → `/admin/#schedule-view`
- `TEACHER` → `/teacher/`
- `STUDENT` → `/student/`
Bearer-токен проверяется на backend. Frontend-скрытие пунктов меню является только удобством, а не источником прав.
Для роли `DEPARTMENT` backend дополнительно ограничивает изменения рамками кафедры текущего пользователя:
- загрузка и комментарии дисциплин идут через `/api/department/*`;
- общий `/api/subjects` доступен кафедре только на чтение;
- привязки `/api/teacher-subjects` разрешены только если дисциплина принадлежит кафедре текущего пользователя, а у преподавателя на текущую дату действует основная или дополнительная связь с ней;
- добавление существующего преподавателя через `/api/department/teachers/{teacherId}/assignments` создаёт связь только со своей кафедрой;
- заявки `/api/department/teacher-requests` создаются и просматриваются кафедрой только в рамках своей кафедры, а создание пользователя выполняет администратор после проверки.
- все `/api/workload/*`, включая свободные аудитории, принудительно используют кафедру из `AuthContext`; запрос с чужим `departmentId` отклоняется с `403`.
Глобальный workload и произвольный фильтр кафедры разрешены ролям `ADMIN`,
`EDUCATION_OFFICE` и `SCHEDULE_VIEWER`.
Ролевая матрица учебного отдела согласована с видимыми экранами: календарный график может
читать специальности и профили, а раздел настроек форм обучения выполняет GET/POST/DELETE.
Изменение самих специальностей и профилей остаётся административной операцией.
---
## Управление ресурсами
### Кафедры (Departments)
Организационные единицы университета. К кафедре привязываются пользователи, группы и дисциплины.
- Имеют уникальный числовой `code`
- Предзаполнены: «Кафедра ИБ», «Кафедра ВТ», «Кафедра КТ»
### Специальности (Specialties)
Учебные направления с кодом по ФГОС. У одной специальности может быть несколько профилей обучения, например базовый профиль и профиль конкретной образовательной программы.
- Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04)
- При создании специальности автоматически создаётся профиль `Без профиля`
### Формы обучения (Education Forms)
Уровни/формы обучения для привязки к группам и календарным учебным графикам.
- Предзаполнены: Бакалавриат, Магистратура, Специалитет
- Нельзя удалить форму обучения, если к ней привязаны группы или календарные графики
### Учебные группы (Student Groups)
- **Поля:** Название, положительная численность, форма обучения, кафедра, специальность, профиль обучения, положительный год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Численность каждой подгруппы обязательна и положительна, а сумма численностей активных подгрупп не может превышать численность группы. Численность группы нельзя уменьшить ниже этой суммы. Изменения группы и подгрупп сериализуются блокировкой родительской группы и дополнительно защищены триггерами V1, поэтому параллельные запросы не нарушают инвариант. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
- **Целостность назначения:** год, специальность, профиль и форма графика должны совпадать с группой, а вычисленный курс должен входить в `1..course_count`. После назначения несовместимое изменение группы или графика отклоняется целиком с `409 Conflict`; назначение не удаляется и не становится устаревшим.
- **Размерность графика:** уменьшение `course_count` запрещено, пока существуют строки сетки старших курсов или дисциплины семестров выше `course_count * 2`. Даты сетки всегда находятся внутри учебного года графика.
### Аудитории (Classrooms)
- **Поля:** Название (уникальное), вместимость (> 0), корпус, этаж, доступность
- **Оборудование:** К каждой аудитории привязывается список оборудования (Many-to-Many) с указанием количества
- **Статус:** Флаг `is_available` для блокирования назначения пар
- **Жизненный цикл:** `status=ARCHIVED` означает вывод из эксплуатации. Такая аудитория отображается в прошлом расписании, но запрещена для новых назначений.
### Оборудование (Equipments)
Каталог оборудования для привязки к аудиториям.
- Предзаполнены: Проектор, ПК, Лаборатория, Интерактивная доска, Документ-камера, Аудиосистема
- Уникальность по названию
### Дисциплины (Subjects)
- **Поля:** Название (глобально уникальное без учёта регистра), код, кафедра, описание
- Привязка преподавателей через `teacher_subjects` (Many-to-Many)
- Кафедра может добавлять комментарии к дисциплине и загружать список дисциплин через кабинет кафедры
- Повторный импорт собственной дисциплины обновляет ту же запись и восстанавливает её из архива
- Совпадение названия с дисциплиной другой кафедры даёт `409 Conflict` и никогда не меняет владельца
### Жизненный цикл справочников
Справочники, которые участвуют в расписании и отчётах, не удаляются физически. Для них используется архивирование:
- `ACTIVE` — запись доступна для выбора;
- `ARCHIVED` — запись остаётся в истории, но не используется в будущих назначениях.
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
### Кафедральные связи преподавателей
Актуальные и исторические связи преподавателя с кафедрами определяются только по
`teacher_department_assignments`. Поле `users.department_id` сохраняется как legacy-зеркало
основной кафедры на текущую дату и не используется для бизнес-решений или исторических
отчётов. Преподаватель может быть связан с несколькими кафедрами: одна связь остаётся
основной, дополнительные связи создаются как неосновные.
Правила:
- периоды двух основных кафедр одного преподавателя не могут пересекаться;
- преподаватель может иметь несколько открытых неосновных кафедр;
- одна открытая пара `teacher_id` + `department_id` запрещает дубли одной и той же связи;
- при переводе старая запись закрывается днём перед `valid_from`, новая начинается с `valid_from`;
- будущий перевод не меняет текущую принадлежность и legacy-зеркало до даты вступления в силу;
- кафедра может добавить существующего активного преподавателя только на свою кафедру, без смены его основной кафедры;
- кафедра может отправить заявку на создание нового преподавателя, но заявка не хранит пароль;
- администратор при одобрении заявки может скорректировать кафедру, логин, ФИО и должность, задаёт пароль и создаёт пользователя с ролью `TEACHER`;
- исторические списки включают архивного преподавателя, если он и назначение действовали на целевую дату;
- нагрузка определяется для каждой даты занятия отдельно и разделяется между кафедрами при переводе внутри отчётного периода.
---
## Логика расписания
### Динамическая модель расписания
Основная модель расписания строится из правил, а не из отдельных статических пар.
| Компонент | Назначение |
|-----------|------------|
| `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` |
| `specialty_profiles` | Профили обучения внутри специальности |
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `academic_calendar_periods` | Непересекающиеся непрерывные периоды активности по курсу |
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Название календарного учебного графика не является отдельным пользовательским атрибутом.
При создании и изменении backend всегда формирует его из кода специальности, названия
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
года; сужение года, исключающее существующий семестр, отклоняется. Создание и изменение
семестра блокируют строку родительского года, а PostgreSQL exclusion constraints разрешают
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
Полная замена дневной сетки выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет весь дневной payload, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Затем соседние даты одного курса с одинаковой активностью
объединяются в один включительный период `academic_calendar_periods`. Только после этого
прежние периоды удаляются и новый набор записывается одной транзакцией. Ошибка любой строки
сохраняет прежнюю сетку целиком, а кэш расписания очищается только после commit. База
дополнительно запрещает пересекающиеся периоды одного курса и графика через exclusion
constraint. При чтении API разворачивает периоды обратно в дневные ячейки. Недели дневной сетки выровнены по ISO-неделе
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
к неделе 2.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона.
2. Вычисляет номер недели и чётность.
3. Находит назначенный группе календарный учебный график на учебный год даты.
4. Определяет код активности по курсу группы и конкретной дате.
5. Пропускает день, если код активности не разрешает обычные пары.
6. Загружает правила группы или преподавателя.
7. Для каждого типа занятия проверяет свою неделю начала в семестре.
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
календарей всех выбранных групп, пересекающиеся периоды активности, правила и эффективные
сетки звонков загружаются batch-запросами. Периоды группируются по календарю и курсу, а
поиск периода для даты выполняется бинарным поиском. Поиск семестра и подстановка времени
внутри циклов выполняются только по снимку запроса. `ScheduleQueryService` передаёт все группы в один
`buildScheduleForGroups()`, поэтому число запросов не растёт как
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Диапазон расписания включает обе переданные границы и ограничен ровно 120 календарными
датами. Query- и generator-слои используют одинаковый расчёт `end - start + 1`: 120 дат
разрешены, 121 дата отклоняется до генерации.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
базового расписания преподавателя. Для overrides с совпадающим `newTeacher` сервис один раз
на каждую релевантную дату достраивает базовый день, выбирает только целевые слоты, применяет
единый снимок изменений и затем фильтрует итогового преподавателя. При отсутствии таких
замен полный список групп не загружается.
Отчёты workload и свободные аудитории не являются интерактивным поиском и используют
отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без
лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию
и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита
широкого `/api/schedule/search` остаётся прежней.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
### Временные слоты
Сетки времени хранятся в `time_slot_scopes`, сами пары — в `time_slots`. Базовая сетка (`DEFAULT`) применяется по умолчанию, субботняя (`WEEKDAY`, `day_of_week = 6`) применяется автоматически по субботам, а пользовательские сетки (`MANUAL`) применяются только через `time_slot_date_assignments` на конкретные даты. Ручное назначение выполняется из ячейки редактора календарного графика, потому что оно относится к конкретной учебной дате.
Создание и изменение слота выполняет транзакционный `TimeSlotService`. Время начала должно
быть раньше окончания, продолжительность вычисляется только backend, а внутри одной сетки
запрещены одинаковые номера пар и пересекающиеся полуоткрытые интервалы. Поэтому соседние
интервалы, например `08:00–09:30` и `09:30–11:00`, допустимы. Операции блокируют строки
затронутых сеток в стабильном порядке; exclusion constraint PostgreSQL дополнительно
защищает инвариант между экземплярами backend и при прямых конкурентных записях.
Правило расписания выбирает базовую пару по номеру. При генерации `ScheduleGeneratorService` сначала проверяет ручное назначение даты, затем автоматическую субботнюю сетку, затем базовую сетку. Если в выбранной сетке нет пары с нужным номером, используется базовый слот. Ручная сетка меняет только время занятий и не включает пары в дни, где календарный учебный график запрещает обычное расписание.
Правила расписания могут ссылаться только на слоты сетки `DEFAULT`. Используемый базовый
слот нельзя перенести в `WEEKDAY` или `MANUAL`, а сетку с используемыми слотами нельзя
сделать небазовой. Эти условия проверяются сервисом и транзакционными триггерами БД.
При миграции одинаковые стартовые слоты создаются для базовой и субботней сетки:
| № | Время |
|---|-------|
| 1 | 08:00 – 09:30 |
| 2 | 09:40 – 11:10 |
| 3 | 11:40 – 13:10 |
| 4 | 13:20 – 14:50 |
| 5 | 15:00 – 16:30 |
| 6 | 16:50 – 18:20 |
| 7 | 18:30 – 20:00 |
### Валидация правил расписания
- **Правило:** обязательны дисциплина, семестр, положительные недели начала и хотя бы одна группа; каждый лимит часов неотрицателен и чётен, а сумма лимитов положительна. Ноль разрешён для неиспользуемого типа занятия.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД; преподавателем может быть только активный пользователь с ролью `TEACHER`.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и принимает только `Очно` или `Онлайн`.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила той же версии. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание блокирует строку семестра и выбранную версию, а update — правило, версию и старый/новый семестры в стабильном порядке. Публикация блокирует версию до завершения полной проверки, поэтому другой backend-pod не может дописать правило после валидации черновика.
- **Связанные слоты:** update сохраняет ID переданных существующих слотов, чтобы не разрывать точечные изменения и операционный аудит. Удаление слота отклоняется, если на него ссылаются override, заявка преподавателя или решение по отсутствию.
### Черновики, версии и публикация
Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии:
1. При создании семестра автоматически появляется пустая версия 1 `PUBLISHED` — основное расписание.
2. Конструктор по умолчанию открывает опубликованное расписание: изменения его правил сразу
видны конечным пользователям. Пользователь может переключиться на существующий `DRAFT`.
3. `DRAFT` создаётся пустым через API или прямо в конструкторе как полная копия выбранной
опубликованной либо черновой версии; после создания он сразу становится текущим.
4. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра.
5. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
переводит проверенный черновик в `PUBLISHED`.
6. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же
атомарной операцией с обязательной причиной.
На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на
семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с
редактированием расписания или конкурентной публикацией. Каждое создание, архивирование,
публикация и восстановление записывается в неизменяемый журнал с автором, временем и
причиной.
Обычная генерация для студентов, преподавателей и кабинетов просмотра всегда выбирает
только опубликованные правила. Точечные изменения привязаны к слотам конкретной версии:
после новой публикации overrides прежней версии сохраняются как аудит, но не влияют на
актуальное расписание и не показываются в его операционном реестре.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другую учебную дату того же семестра или другой фактический интервал;
- `REPLACE` — без изменения даты и времени заменить преподавателя, аудиторию или формат.
`CANCEL` не принимает целевую дату и новые ресурсы. Для `MOVE` обязателен новый временной
слот, для `REPLACE` — преподаватель, аудитория или формат. Перенос даты требует явно
выбранного слота эффективной сетки целевого дня. Дополнительные изменения преподавателя,
аудитории и формата можно объединить с `MOVE`, но основное действие должно фактически
менять свою часть пары. Формат ограничен значениями `Очно` и `Онлайн`; дисциплина и тип
занятия не изменяются.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
расписания. Исходная пара всегда идентифицируется как `baseRuleSlotId + lessonDate`, а
`targetLessonDate` хранит только новую дату единственного занятия. Перед записью
`ScheduleOverrideService`:
1. захватывает transaction advisory lock PostgreSQL для исходных и целевых дат в стабильном порядке;
2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
3. доказывает существование исходной пары с учётом семестра, календарного графика,
чётности, недели начала, лимита часов и lifecycle;
4. для новой даты проверяет границы того же семестра, активность правила и дисциплины,
учебный день всех затронутых групп и эффективную сетку времени;
5. проверяет lifecycle итоговых преподавателя и аудитории на целевую дату;
6. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
7. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы целевого дня;
8. сохраняет изменение только при отсутствии конфликта.
В поиске `MOVE` удаляет исходное вхождение и добавляет занятие в целевой день, включая
случай, когда в запрос попала только целевая дата. День недели, номер недели и чётность
вычисляются заново по семестру, а расход академических часов остаётся у исходной пары.
`CANCEL` скрывает занятие. Удаление override возвращает текущий вариант, который снова
формируется базовым правилом.
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
дату и одинаковые ресурсы проверяются последовательно.
### Отсутствия преподавателей и мастер замены
Отсутствие хранится отдельно от базовых правил расписания и проходит жизненный цикл
`PENDING → APPROVED → RESOLVED`; отклонённые и отменённые записи получают соответственно
`REJECTED` и `CANCELLED`. Преподаватель создаёт заявку только для себя со статусом
`PENDING`. Администратор, учебный отдел и кафедра могут регистрировать согласованное
отсутствие сразу. Кафедра видит и подтверждает только преподавателей, назначенных ей на
выбранный период через `teacher_department_assignments`.
Для согласованного отсутствия `ScheduleQueryService` строит фактические занятия
преподавателя в пределах периода. Мастер предлагает не более пяти проверенных вариантов
каждого типа:
- преподавателей со связью по дисциплине и допустимому типу занятия;
- ближайшие учебные даты вне периода отсутствия и эффективные временные слоты;
- активные свободные аудитории;
- отмену занятия или явное отклонение предложений.
Если у связи преподавателя с дисциплиной нет настроенных строк `teacher_lesson_types`, она
считается разрешающей все типы; при наличии настроек требуется точное совпадение типа.
Кандидаты-замены исключаются, если архивированы, заняты или сами отсутствуют в дату пары.
Каждый вариант до показа проходит read-only проверку `ScheduleOverrideService`, а выбранный
пакет повторно валидируется и сохраняется в одной транзакции. Поэтому время, аудитория,
группы и подгруппы проверяются тем же механизмом, что и ручные точечные изменения.
Применённые действия создают обычные `schedule_overrides`; базовое правило семестра не
изменяется. Таблица `teacher_absence_decisions` фиксирует как применённые, так и отклонённые
решения. Незаполненные строки мастер не меняет, и к ним можно вернуться позже.
---
### Пожелания преподавателей на семестр
Преподаватель формирует набор пожеланий отдельно для каждого семестра. Интервальные записи
привязаны к дню недели и паре базовой сетки времени, полная строгая недоступность — к
конкретной дате семестра. Поддерживаются:
- `HARD_UNAVAILABLE` — строго запрещённый интервал либо полностью недоступная дата;
- `SOFT_PREFERRED` и `SOFT_UNWANTED` — предпочтительный и нежелательный интервалы;
- `CONSECUTIVE` и `NO_GAPS` — пожелания к компактности расписания.
Запись преподавателя сначала имеет статус `PENDING`. Кафедра может рассматривать только
пожелания преподавателей, относившихся к ней в период семестра; учебный отдел и
администратор работают со всеми записями. Ответственный сотрудник также может сразу создать
согласованную запись. Отклонение требует комментария, а преподаватель может отозвать только
собственную ожидающую запись.
Только согласованные строгие ограничения влияют на валидацию. `ScheduleRuleService`
проверяет каждую активную неделю нового или изменённого правила, а `ScheduleOverrideService`
— фактическую дату результата разовой правки. Поэтому строгая недоступность одинаково
учитывается конструктором правил, мастером замены и заявками преподавателей. Мягкие
пожелания и компактность подсвечиваются в конструкторе, но не меняют опубликованное
расписание автоматически.
### Заявки преподавателей на изменение занятия
Заявку можно создать только по собственному фактическому занятию, для которого ещё нет
разовой правки. Поддерживаются перенос даты/времени (`MOVE`), смена аудитории
(`CHANGE_CLASSROOM`) и отмена (`CANCEL`). Одновременно по одной паре допускается только одна
заявка `PENDING`.
До отправки интерфейс получает список ближайших учебных дат, временных слотов и аудиторий.
Каждый вариант проходит `ScheduleOverrideService`: проверяются принадлежность семестру,
календарный график групп, эффективная сетка времени, жизненный цикл ресурсов, пересечения
преподавателя, аудитории, групп и подгрупп, подтверждённые отсутствия и строгая
недоступность преподавателя. При создании заявки проверка выполняется повторно.
Кафедра видит заявки своих преподавателей, но применять изменение вправе только
`ADMIN` или `EDUCATION_OFFICE`. При одобрении в одной транзакции повторно проверяется и
создаётся обычный `schedule_override`, его ID сохраняется в заявке, а в неизменяемую
историю добавляется статус `APPROVED`. Отклонение требует комментария; преподаватель может
отозвать только собственную ожидающую заявку. История содержит автора, время, статус и
комментарий каждого перехода.
### Анализ качества и локальная оптимизация расписания
Анализатор качества работает без собственной таблицы и не меняет правила расписания.
Пользователь выбирает конкретную версию семестра. Для `PUBLISHED` он строит фактические
занятия через `ScheduleQueryService`, поэтому в расчёт входят переносы, замены и отмены из
`schedule_overrides`. `DRAFT` и `ARCHIVED` генерируются напрямую по собственным правилам,
без точечных изменений текущей публикации. Семестр загружается частями не более 120 дней,
но оценка рассчитывается единообразно по всему периоду.
Итоговая оценка от 0 до 100 формируется из объяснимых штрафов:
- окна групп и преподавателей между занятиями одного дня;
- пятая и последующие пары группы или преподавателя за день;
- нехватка мест и заметно избыточная вместимость аудитории;
- занятие в подтверждённый строго недоступный или нежелательный интервал;
- занятие вне предпочтительного интервала преподавателя на выбранный день недели;
- нарушение пожеланий `NO_GAPS` и `CONSECUTIVE`.
Неравномерность дневной нагрузки выводится отдельной диагностической метрикой в процентах
и не добавляет скрытого штрафа к итоговой оценке.
Внутренне штраф рассчитывается для каждого фактического занятия, поэтому повторяющиеся
пары одного правила увеличивают его вклад в оценку. Перед выдачей нарушения объединяются
по правилу: одна карточка содержит суммарный штраф, число затронутых занятий, все типы и
краткие причины. Это сохраняет воспроизводимость оценки, но направляет пользователя к
изменению базового правила вместо ручного исправления каждой пары.
Для проблемы опубликованной версии помощник перебирает другие слоты эффективной сетки того же дня и
активные аудитории достаточной вместимости. Каждый вариант повторно проходит
`ScheduleOverrideService`, затем анализатор моделирует его влияние на общую оценку и
показывает улучшения и компромиссы. Занятия с ручным override считаются закреплёнными и не
получают рекомендаций. Применение возможно только после подтверждения пользователя через
обычный механизм `schedule_overrides`; автоматической публикации и полного solver в MVP
нет. Интерфейс анализа для опубликованной версии и черновика открывает выбранное правило
в конструкторе соответствующей версии; черновик остаётся изолированным до публикации.
Архив анализируется только для чтения. API локальных рекомендаций сохраняется для
совместимости и точечных сценариев, но реестр качества работает на уровне правил.
## Привязка преподаватель ↔ дисциплина
Связь Many-to-Many через таблицу `teacher_subjects`:
- Указывается, какие дисциплины может вести конкретный преподаватель
- Дополнительные поля: `qualification_level`, `experience_years`
Дополнительная связь через `teacher_lesson_types`:
- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине
---
## Бизнес-правила (планируемые)
> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.
### Проверка конфликтов
- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях
- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
### Управление инцидентами
Регистрация отсутствий и Resolution Wizard реализованы. Дальнейшее развитие этого контура:
- автоматическая рассылка уведомлений группам и преподавателям;
- пакетная обработка нескольких одновременных отсутствий;
- ранжирование вариантов по окнам и дополнительной нагрузке.