Files
magistr/docs/BUSINESS_LOGIC.md

40 KiB
Raw Blame History

📋 Бизнес-логика

Ролевая модель

Система поддерживает шесть ролей пользователей:

Роль 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_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 не выводится в расписании, но прошлые занятия остаются доступными для просмотра.

Диапазон расписания включает обе переданные границы и ограничен ровно 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: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 — преподаватель, аудитория или формат. Перенос даты требует явно выбранного слота эффективной сетки целевого дня. Дополнительные изменения преподавателя, аудитории и формата можно объединить с 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, поэтому два конкурентных переноса с разных исходных дат на одну целевую дату и одинаковые ресурсы проверяются последовательно.


Привязка преподаватель ↔ дисциплина

Связь Many-to-Many через таблицу teacher_subjects:

  • Указывается, какие дисциплины может вести конкретный преподаватель
  • Дополнительные поля: qualification_level, experience_years

Дополнительная связь через teacher_lesson_types:

  • Определяет, какие типы занятий (лекция, практика, лаба) может вести преподаватель по конкретной дисциплине

Бизнес-правила (планируемые)

Примечание: Следующие правила описаны в требованиях, но пока не полностью реализованы в коде.

Проверка конфликтов

  • Критический конфликт: Преподаватель не может одновременно находиться в двух разных аудиториях
  • Исключение: Преподаватель может вести несколько пар одновременно (потоковая лекция), если все группы в одной аудитории
  • Вместимость: Суммарная численность всех групп в слоте не должна превышать вместимость аудитории

Управление инцидентами

  • Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
  • Автоматическая подсветка конфликтующих пар (Red Zone)
  • Resolution Wizard: предложение замены преподавателя или переноса занятия