Files
magistr/docs/BUSINESS_LOGIC.md
2026-07-19 14:40:43 +03:00

313 lines
37 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`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
- **Завершение обучения:** если текущий курс больше `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_days` | Дневная сетка по курсу и дате |
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
года; сужение года, исключающее существующий семестр, отклоняется. Создание и изменение
семестра блокируют строку родительского года, а PostgreSQL exclusion constraints разрешают
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
Полная замена `academic_calendar_days` выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
расписания очищается только после commit.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона.
2. Вычисляет номер недели и чётность.
3. Находит назначенный группе календарный учебный график на учебный год даты.
4. Определяет код активности по курсу группы и конкретной дате.
5. Пропускает день, если код активности не разрешает обычные пары.
6. Загружает правила группы или преподавателя.
7. Для каждого типа занятия проверяет свою неделю начала в семестре.
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки
звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск
семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только
по lookup-картам. `ScheduleQueryService` передаёт все группы в один
`buildScheduleForGroups()`, поэтому число запросов не растёт как
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.
Расход часов считается в пределах одного построения расписания: при первом использовании семестра генератор одним последовательным проходом прогревает проведённые часы от начала семестра до начала запрошенного диапазона, затем ведёт локальный прогресс по правилу, типу занятия, группе и подгруппе. Обратного пересчёта прошлых дат для каждого слота нет. Singleton-кэш в сервисе не используется, поэтому данные расписания не накапливаются в heap между запросами и не устаревают после изменений правил, календаря или подгрупп.
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `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:0009:30` и `09:3011: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 не могут одновременно пройти проверку одного семестра по устаревшему снимку.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой фактический интервал или в другую аудиторию;
- `REPLACE` — заменить преподавателя, аудиторию или формат.
`CANCEL` не принимает новые ресурсы. Для `MOVE` обязателен новый временной слот или
аудитория, для `REPLACE` — преподаватель, аудитория или формат. Дополнительные изменения
можно объединять в одном payload, но основное действие должно фактически менять свою
часть пары. Формат ограничен значениями `Очно` и `Онлайн`.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
расписания на конкретную дату. Перед записью `ScheduleOverrideService`:
1. захватывает transaction advisory lock PostgreSQL для tenant-БД и даты;
2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
3. доказывает существование исходной пары с учётом семестра, календарного графика,
чётности, недели начала, лимита часов и lifecycle;
4. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
5. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы;
6. сохраняет изменение только при отсутствии конфликта.
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
для backend-pod, поэтому два конкурентных изменения одной даты проверяются последовательно.
---
## Привязка преподаватель ↔ дисциплина
Связь Many-to-Many через таблицу `teacher_subjects`:
- Указывается, какие дисциплины может вести конкретный преподаватель
- Дополнительные поля: `qualification_level`, `experience_years`
Дополнительная связь через `teacher_lesson_types`:
- Определяет, какие **типы занятий** (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине
---
## Бизнес-правила (планируемые)
> **Примечание:** Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.
### Проверка конфликтов
- **Критический конфликт:** Преподаватель не может одновременно находиться в двух разных аудиториях
- **Исключение:** Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
### Управление инцидентами
- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
- Автоматическая подсветка конфликтующих пар (Red Zone)
- Resolution Wizard: предложение замены преподавателя или переноса занятия