475 lines
55 KiB
Markdown
475 lines
55 KiB
Markdown
# 📋 Бизнес-логика
|
||
|
||
## Ролевая модель
|
||
|
||
Система поддерживает шесть ролей пользователей:
|
||
|
||
| Роль | 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 не может дописать правило после валидации черновика.
|
||
|
||
### Черновики, версии и публикация
|
||
|
||
Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии:
|
||
|
||
1. `DRAFT` создаётся пустым или как полная копия выбранной версии.
|
||
2. Конструктор добавляет, изменяет и архивирует правила только в выбранном черновике.
|
||
3. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
|
||
стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра.
|
||
4. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
|
||
переводит проверенный черновик в `PUBLISHED`.
|
||
5. Ранее опубликованная версия получает `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
|
||
нет. Для черновика доступны те же оценка, метрики и объяснимые проблемы, но рекомендации
|
||
не создаются: пользователь исправляет правила в изолированном конструкторе и повторяет
|
||
проверку до публикации. Архив анализируется только для чтения.
|
||
|
||
## Привязка преподаватель ↔ дисциплина
|
||
|
||
Связь Many-to-Many через таблицу `teacher_subjects`:
|
||
- Указывается, какие дисциплины может вести конкретный преподаватель
|
||
- Дополнительные поля: `qualification_level`, `experience_years`
|
||
|
||
Дополнительная связь через `teacher_lesson_types`:
|
||
- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине
|
||
|
||
---
|
||
|
||
## Бизнес-правила (планируемые)
|
||
|
||
> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.
|
||
|
||
### Проверка конфликтов
|
||
- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях
|
||
- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
|
||
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
|
||
|
||
### Управление инцидентами
|
||
|
||
Регистрация отсутствий и Resolution Wizard реализованы. Дальнейшее развитие этого контура:
|
||
|
||
- автоматическая рассылка уведомлений группам и преподавателям;
|
||
- пакетная обработка нескольких одновременных отсутствий;
|
||
- ранжирование вариантов по окнам и дополнительной нагрузке.
|