# 📋 Бизнес-логика ## Ролевая модель Система поддерживает шесть ролей пользователей: | Роль | 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: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 не могут одновременно пройти проверку одного семестра по устаревшему снимку. ### Точечные изменения расписания Учебный отдел может создать изменение конкретной пары: - `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: предложение замены преподавателя или переноса занятия