diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index d7f7256..51ff698 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -1,114 +1,57 @@ # Реализация динамического расписания -Этот файл фиксирует текущий результат внедрения динамической системы расписания: что уже добавлено в проект, как связаны база данных, backend, API и frontend, как работает генератор расписания и какие ограничения остаются для следующего этапа. +Файл фиксирует текущий результат внедрения динамического расписания и календарных учебных графиков. -## Статус внедрения +## Статус -Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`. +Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. По требованию текущей ветки новая отдельная Flyway-миграция не создавалась; применение рассчитано на полный сброс БД. -Старые таблицы `lessons` и `schedule_data`, старые контроллеры, DTO, модели, репозитории и админские вызовы старых API удалены. Новая модель расписания является единственным базовым способом хранения и генерации расписания. +Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы. -## Что добавлено +## База данных -### База данных - -Динамическая схема встроена в базовую Flyway-миграцию `V1__init.sql`. - -Она создаёт следующие таблицы: +`V1__init.sql` создаёт и заполняет: | Таблица | Назначение | |---------|------------| -| `time_slots` | Настраиваемая сетка пар: номер, время начала, время окончания, длительность | -| `academic_years` | Учебные годы | -| `semesters` | Семестры учебного года, от `start_date` считается первая неделя | -| `holidays` | Праздники и исключённые даты, когда занятия не проводятся | -| `academic_calendar_matrix` | Матрица учебного графика по семестру, курсу, специальности и неделе | -| `schedule_rules` | Правила проведения дисциплины с лимитом академических часов | -| `schedule_rule_groups` | Привязка правила к одной или нескольким группам | -| `schedule_rule_slots` | Конкретные шаблонные слоты правила: день, чётность, пара, преподаватель, аудитория, тип и формат | +| `specialties` | Коды и названия специальностей | +| `specialty_profiles` | Профили обучения внутри специальности | +| `student_groups` | Группы со ссылками на специальность и профиль | +| `calendar_study_forms` | Формы обучения календарного графика | +| `academic_calendar_activity_types` | Коды активностей из Excel-графика | +| `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | +| `academic_calendar_days` | Дневная сетка графика по курсу и дате | +| `student_group_calendar_assignments` | Назначения графиков группам по учебному году | +| `time_slots` | Настраиваемая сетка пар | +| `schedule_rules` | Правила проведения дисциплин | +| `schedule_rule_groups` | Группы правила | +| `schedule_rule_slots` | Шаблонные слоты правила | -Миграция также создаёт тестовые данные сразу в новой модели: +Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. -1. Создаёт базовые временные слоты. -2. Создаёт учебные годы `2024-2025` и `2025-2026`. -3. Заполняет матрицу учебного графика базовым статусом `THEORY`. -4. Создаёт тестовые `schedule_rules` для групп и дисциплин. -5. Создаёт привязки групп в `schedule_rule_groups`, включая потоковую лекцию. -6. Создаёт шаблонные занятия в `schedule_rule_slots`. +## Backend -### Backend +Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам. -Добавлены JPA-модели для новой схемы: - -- `TimeSlot` -- `AcademicYear` -- `Semester` -- `Holiday` -- `AcademicCalendarMatrix` -- `ScheduleRule` -- `ScheduleRuleSlot` -- `AcademicActivityType` -- `ScheduleParity` - -Добавлены DTO для API: - -- `TimeSlotDto` -- `AcademicYearDto` -- `SemesterDto` -- `HolidayDto` -- `AcademicCalendarMatrixDto` -- `ScheduleRuleDto` -- `ScheduleRuleSlotDto` -- `RenderedLessonDto` - -Добавлены репозитории для новых таблиц: - -- `TimeSlotRepository` -- `AcademicYearRepository` -- `SemesterRepository` -- `HolidayRepository` -- `AcademicCalendarMatrixRepository` -- `ScheduleRuleRepository` -- `ScheduleRuleSlotRepository` - -Добавлены сервисы: +Ключевые сервисы: | Сервис | Назначение | |--------|------------| -| `AcademicDateService` | Календарная математика: семестр по дате, номер недели, чётность, праздник, курс группы, тип активности | -| `ScheduleGeneratorService` | Генерация расписания группы или преподавателя на диапазон дат | +| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | +| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -Старый ручной слой занятий удалён: в backend больше нет `LessonsController`, `ScheduleDataController`, сущностей `Lesson`/`ScheduleData`, их DTO, репозиториев и валидаторов старых строк расписания. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. -### API +## API -Добавлен новый endpoint просмотра расписания: +Просмотр расписания: ```http GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 ``` -Правило запроса: нужно передать ровно один идентификатор — `groupId` или `teacherId`. - -Ответ — массив `RenderedLessonDto`, где у каждого занятия есть: - -- полная дата занятия; -- номер дня недели; -- название дня; -- номер недели семестра; -- чётность; -- временной слот; -- дисциплина; -- преподаватель; -- аудитория; -- тип и формат занятия; -- группы; -- лимит часов правила; -- уже списанные часы перед занятием; -- остаток часов после занятия. - -Добавлены административные API: +Административные API: | API | Назначение | |-----|------------| @@ -116,196 +59,48 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 | `/api/admin/calendar/years` | CRUD учебных годов | | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | | `/api/admin/calendar/semesters/{id}` | Обновление семестра | -| `/api/admin/calendar/holidays` | CRUD праздников | -| `/api/admin/calendar/matrix` | Получение и массовое сохранение матрицы учебного графика | +| `/api/admin/calendar/activity-types` | CRUD кодов активностей | +| `/api/admin/academic-calendars` | CRUD календарных учебных графиков | +| `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки | +| `/api/specialties/{id}/profiles` | CRUD профилей специальности | +| `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | | `/api/admin/schedule-rules` | CRUD правил расписания | -### Авторизация +## Frontend -Ответ `POST /api/auth/login` расширен полем `userId`. +Админский экран `schedule` содержит: -Frontend сохраняет его в `localStorage`, чтобы кабинет преподавателя мог запрашивать личное расписание через: +- конструктор правил динамического расписания; +- управление временными слотами; +- учебные годы и семестры; +- CRUD календарных графиков; +- Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. -```http -GET /api/schedule?teacherId={userId}&startDate=...&endDate=... -``` +Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год. -### Frontend +Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. -Заглушки кабинетов заменены на базовые экраны просмотра расписания. +## Проверки -Админский экран `schedule` показывает текущие `schedule_rules` и сетку `time_slots` через новые admin API. - -#### Кабинет студента - -Файл: `frontend/student/index.html`. - -Текущая модель пользователя-студента не содержит прямой связи с учебной группой, поэтому экран работает через выбор группы: - -1. Загружает список групп из `/api/groups`. -2. Сохраняет выбранную группу в `localStorage.studentGroupId`. -3. Строит недельный диапазон дат. -4. Запрашивает расписание через `GET /api/schedule?groupId=...`. -5. Показывает занятия по дням недели. - -#### Кабинет преподавателя - -Файл: `frontend/teacher/index.html`. - -Экран использует `localStorage.userId`, который появляется после нового ответа авторизации: - -1. Определяет текущую неделю. -2. Позволяет переключать неделю стрелками или через выбор даты. -3. Запрашивает расписание через `GET /api/schedule?teacherId=...`. -4. Показывает занятия преподавателя в недельной сетке. -5. В занятии отображаются дисциплина, время, тип, аудитория и группы. - -## Как работает генерация расписания - -Генерация выполняется на backend по запросу клиента. Фактические занятия не хранятся отдельными строками на каждую дату, а вычисляются из правил. - -### Расписание группы - -Алгоритм `buildScheduleForGroup(groupId, startDate, endDate)`: - -1. Проверяет корректность диапазона дат. -2. Находит группу. -3. Для каждой даты диапазона определяет семестр. -4. Вычисляет номер недели семестра. -5. Вычисляет текущий курс группы по `year_start_study`. -6. Проверяет матрицу учебного графика. -7. Пропускает дату, если это праздник или не `THEORY`. -8. Загружает правила группы для найденного семестра. -9. Для каждого правила считает, сколько академических часов уже проведено до текущей даты. -10. Если лимит `total_academic_hours` ещё не исчерпан, проецирует подходящие слоты правила на текущую дату. - -### Расписание преподавателя - -Алгоритм `buildScheduleForTeacher(teacherId, startDate, endDate)` похож, но правила ищутся не по группе, а по преподавателю в `schedule_rule_slots.teacher_id`. - -В ответе занятие дополнительно содержит все группы, привязанные к правилу. Это поддерживает потоковые лекции. - -### Чётность недели - -Чётность вычисляется от `semesters.start_date`. - -Настройка: - -```properties -app.schedule.even-week-is-upper=false -``` - -По умолчанию: - -- нечётная неделя семестра соответствует `ODD`; -- чётная неделя семестра соответствует `EVEN`. - -Если `app.schedule.even-week-is-upper=true`, соответствие инвертируется. - -### Праздники - -Праздник считается пропуском занятия: - -- занятие не отображается в расписании; -- академические часы не списываются; -- дисциплина фактически продолжается дольше, пока не будет исчерпан лимит часов. - -### Лимит часов - -Каждый `schedule_rules.total_academic_hours` задаёт общий лимит академических часов дисциплины. - -Генератор симулирует период от `active_from_date` до запрошенной даты и считает только реально проведённые слоты: - -- один слот = 2 академических часа; -- праздники не считаются; -- недели с активностью не `THEORY` не считаются; -- после достижения лимита правило перестаёт выводиться. - -## Поток данных - -Новая базовая БД сразу создаёт рабочую модель так: - -```text -V1__init.sql - -> time_slots - -> academic_years / semesters / holidays / academic_calendar_matrix - -> schedule_rules - -> schedule_rule_groups - -> schedule_rule_slots -``` - -Для просмотра: - -```text -frontend - -> GET /api/schedule - -> ScheduleController - -> ScheduleGeneratorService - -> schedule_rules + schedule_rule_slots + calendar tables - -> RenderedLessonDto[] -``` - -## Что проверено - -Проверка backend: - -```bash -docker run --rm -v /mnt/HDD/magistr/magistr/backend:/app -w /app maven:3.9-eclipse-temurin-17 mvn -q -DskipTests compile -``` - -Результат: компиляция backend прошла успешно. - -Проверка миграции: - -1. Поднят временный PostgreSQL-контейнер на образе `postgres:alpine3.23`. -2. Применён `V1__init.sql`. -3. Проверены контрольные количества строк новой модели. - -Контрольный результат: - -| Объект | Количество | -|--------|------------| -| `schedule_rules` | 4 | -| `schedule_rule_groups` | 5 | -| `schedule_rule_slots` | 4 | -| `time_slots` | 7 | -| `academic_calendar_matrix` | 132 | -| `lessons` | таблица отсутствует | -| `schedule_data` | таблица отсутствует | - -Также выполнен: +Доступные локальные проверки: ```bash +node --check frontend/admin/js/views/schedule.js +node --check frontend/admin/js/views/groups.js +node --check frontend/admin/js/views/departments-data.js git diff --check ``` -Результат: замечаний по whitespace нет. +Полная проверка требует окружения с Maven и доступом к Docker: -## Ограничения текущего среза +```bash +mvn -DskipTests compile +docker compose down -v +docker compose up -d --build +``` -Этот срез создаёт рабочее ядро динамического расписания, но ещё не закрывает весь продуктовый объём. +## Следующие этапы -Ограничения: - -- строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована; -- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`. - -## Следующий этап - -Рекомендуемый следующий этап: - -1. Реализовать backend-валидацию конфликтов: - - преподаватель не может вести разные занятия одновременно; - - аудитория не может быть занята двумя разными занятиями одновременно; - - группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам. -2. Добавить модель связи студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. -3. Добавить отдельное управление подгруппами и подключить выбор `subgroupId` в конструкторе правил. - -## Связанные документы - -- `SCHEDULE_PROPOSAL.md` — исходная концепция динамического расписания. -- `SCHEDULE_TASKS.md` — исходная декомпозиция задач. -- `docs/API.md` — описание REST API. -- `docs/DATABASE.md` — описание схемы БД. -- `docs/BUSINESS_LOGIC.md` — бизнес-правила. -- `docs/FRONTEND.md` — frontend-архитектура. +- Добавить интеграционные тесты генерации по кодам `Т`, `Э`, `К`, `*`, `=`. +- Добавить строгую backend-валидацию конфликтов преподавателей, аудиторий и групп при сохранении правил. +- Добавить связь пользователя-студента с учебной группой, чтобы кабинет студента не требовал ручного выбора группы. diff --git a/SCHEDULE_PROPOSAL.md b/SCHEDULE_PROPOSAL.md index 3b8c6e0..825b22a 100644 --- a/SCHEDULE_PROPOSAL.md +++ b/SCHEDULE_PROPOSAL.md @@ -1,178 +1,48 @@ # Концепция динамической генерации расписания -Данный документ представляет собой подробное архитектурное описание новой системы управления расписанием. Система переходит от статического хранения каждой отдельной пары к параметрическому: мы сохраняем **правила проведения** дисциплины и **календарную сетку**, а фактическое расписание на любую дату вычисляется «на лету» (генерируется). +Документ фиксирует актуальную концепцию после внедрения календарных учебных графиков. Подробная продуктовая модель описана в [`docs/ACADEMIC_CALENDAR.md`](docs/ACADEMIC_CALENDAR.md), REST API — в [`docs/API.md`](docs/API.md), схема БД — в [`docs/DATABASE.md`](docs/DATABASE.md). -> **Контекст миграции:** Новая система полностью заменяет существующие таблицы `lessons` (статическое расписание) и `schedule_data` (плановая нагрузка). Обе таблицы будут мигрированы в единую модель `schedule_rules` + `schedule_rule_slots`, которая совмещает хранение нагрузки (часы) и расписания (слоты) в одной структуре. +## Базовая идея ---- +Фактические занятия не хранятся отдельными строками на каждую дату. Система хранит: -## 1. Подробное описание компонентов системы +- календарный учебный график по специальности, профилю, форме обучения и учебному году; +- правила расписания дисциплин; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия и формат; +- назначение конкретного календарного графика учебной группе на учебный год. -Новая архитектура строится на строгом разделении данных на три логических слоя: Календарь (основа отсчета времени), Правила (шаблоны занятий) и Генератор (движок рендеринга фактического расписания). +При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. -### 1.1 Справочная база времени (Календарный учебный график) -Чтобы система понимала, *когда* можно ставить пары, а когда нет, вводится понятие календарного графика. Он состоит из трёх взаимосвязанных сущностей: +## Слои модели -* **Академические периоды (Учебные года и Семестры).** Иерархия из двух уровней: - * **Учебный год** — контейнер с названием и датами (напр. «2024/2025», `01.09.2024` — `30.06.2025`). - * **Семестр** — дочерняя сущность учебного года. Содержит дату начала, от которой отсчитывается «Неделя 1» данного семестра. Нумерация недель начинается заново для каждого семестра. Тип семестра (`autumn` / `spring`) определяет, какой набор правил активен. - - Именно от даты начала семестра отсчитывается «Неделя 1». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели. +### Календарный слой -* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок. +- `academic_years` и `semesters` задают границы учебного года и семестров. +- `specialties` и `specialty_profiles` определяют образовательную программу группы. +- `academic_calendars` хранит график профиля и формы обучения. +- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`. +- `academic_calendar_days` хранит дневную сетку по курсу и дате. +- `student_group_calendar_assignments` выбирает график для группы на учебный год. -* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность` → `Номер недели` → `Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия». +### Слой правил расписания -### 1.2 Справочник временных слотов (Time Slots) -Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки. +- `time_slots` задаёт сетку пар для тенанта. +- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов. +- `schedule_rule_groups` связывает правило с одной или несколькими группами. +- `schedule_rule_slots` хранит шаблонные занятия правила. -Слот содержит: -* `order_number` — порядковый номер пары в дне (1, 2, 3...). -* `start_time` — время начала (напр. `08:00`). -* `end_time` — время окончания (напр. `09:30`). -* `duration_minutes` — длительность пары в минутах. +## Алгоритм генерации -Это позволяет каждому университету настраивать количество и продолжительность пар без модификации кода. +1. Для даты определяется семестр. +2. Для группы находится назначенный календарный учебный график. +3. По году начала обучения вычисляется текущий курс группы. +4. По курсу и дате находится код активности. +5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. +6. Для разрешённого дня выбираются активные правила и слоты. +7. Лимит часов считается только по реально проведённым занятиям. -### 1.3 Движок правил (Schedule Rules) -Старый подход подразумевал, что каждая пара в базе (каждая клеточка) — это изолированная запись `lessons` («понедельник, 1-я пара, математика»). Новая система вводит сущность сводного **Правила Дисциплины**. Одно правило описывает расписание целого курса по конкретному предмету для одной или нескольких студенческих групп (включая потоковые лекции). +Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. -**Базовые параметры (Лимиты Правила):** -* `subject_id` — ID преподаваемой дисциплины. -* `semester_id` — ID семестра, к которому привязано правило. Одна и та же дисциплина может читаться в разных семестрах с разными параметрами. -* `startDate` — Дата или номер недели семестра, с которой предмет начинает читаться (поскольку не все предметы идут строго с 1-й недели семестра). -* `totalHours` — Полный объём выделенных **академических часов** (1 ак. час = 45 минут; одна пара = 2 ак. часа). Это важнейший **лимитатор**, который обеспечивает автоматическую остановку генерации: как только заявленные часы будут вычитаны, предмет перестает отображаться в расписании студентов на последующих неделях. +## Административный сценарий -**Связь с группами (Many-to-Many):** -Одно правило может быть связано с несколькими группами через промежуточную таблицу `schedule_rule_groups`. Это обеспечивает поддержку **потоковых лекций** — когда один преподаватель читает лекцию нескольким группам одновременно в одной аудитории. При этом правило создаётся один раз, а группы к нему привязываются списком. - -**Массив паттернов (Слоты правила):** -Само «тело» правила разбивается на подчинённые слоты. Если предмет идёт в Пн и Ср, это будет 2 слота внутри одного Правила. Слот содержит: -* `dayOfWeek`: день недели (1–7, Пн–Вс). -* `parity`: тип четности — `ENUM('BOTH', 'EVEN', 'ODD')`. `BOTH` — каждую неделю, `EVEN` — по чётным (нижним) неделям, `ODD` — по нечётным (верхним). Конкретное соответствие «чётная = верхняя или нижняя» определяется настройкой тенанта. -* `time_slot_id`: FK на таблицу `time_slots` — порядковый номер и время пары. -* `subgroup_id`: FK на подгруппу (NULL = вся группа). *Это гарантирует, что мы сможем ставить разным подгруппам пересекающиеся занятия в разных аудиториях без алгоритмических конфликтов.* -* `teacher_id`: FK на преподавателя слота. -* `classroom_id`: FK на аудиторию слота. -* `lesson_type_id`: FK на тип занятия (`Лекция`, `Практическая работа`, `Лабораторная работа`). -* `lesson_format`: формат проведения (`Очно` / `Онлайн`). - -> **Обоснование:** Хранение `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` в **слотах**, а не в главном правиле, позволяет гибко описывать ситуации вроде: лекции в понедельник читает лектор Иванов (Аудитория 100), а лабораторные в среду ведёт практик Петров (Аудитория 102В) — в рамках одного правила по предмету «Программирование», расходуя общий `totalHours`. - -### 1.4 Генератор (Рендерер) расписания -Это слой бизнес-логики (служба `ScheduleGeneratorService` в Java), который работает исключительно в оперативной памяти бэкенда и производит расчёт расписания «on-demand» (по требованию) при запросе от клиента фронтенда. - -**Пошаговый алгоритм работы генератора:** -1. Фронтенд (Интерфейс пользователя) запрашивает: *«Дай мне расписание группы ИТ-21 на конкретный период, например, с 14 октября по 20 октября»*. -2. Генератор определяет семестр по запрошенным датам и вычисляет, что 14 октября соответствует, к примеру, 7-й неделе семестра (вычисление от `startDate` семестра). -3. Он сверяется с *Матрицей учебного графика*. Для этого генератор определяет текущий курс группы по формуле `текущий_учебный_год - year_start_study + 1` и находит `specialty_id` группы. Если у данного курса/специальности сейчас стоит `VACATION` (Каникулы) или `PRACTICE` (Практика), генератор сразу возвращает пустой ответ или ответ со статусом периода. -4. Если статус недели позволяет проводить занятия (`THEORY`), генератор поднимает из Базы Данных все активные **Правила** для запрошенной группы (через таблицу `schedule_rule_groups`), привязанные к текущему семестру. -5. **Механика Лимитатора часов:** Для каждого правила алгоритм «симулирует» прогон времени с даты старта правила до текущей запрошенной недели. Он подсчитывает количество успешно проведённых ак. часов (по 2 ак. часа за каждый отработанный слот), пропуская даты, попавшие в справочник праздников, и недели с типом деятельности отличным от `THEORY`. -6. Если у правила лимит `totalHours` достиг значения `0`, программа понимает, что курс вычитан, и предмет не отображается. Если часы ещё остались, алгоритм проецирует шаблоны (слоты правила) на запрошенную текущую неделю с учётом чётности, аудиторий и подгрупп, отдавая готовый JSON-массив в браузер пользователя. - -**Генерация расписания для преподавателя:** -Аналогричный алгоритм, но поиск правил идёт не по привязке к группе, а по `teacher_id` в слотах. Генератор собирает все `schedule_rule_slots`, где `teacher_id` = ID текущего преподавателя, получает родительские правила и рендерит расписание, обогащая каждую запись списком групп из `schedule_rule_groups`. - -**Кеширование:** -Для оптимизации производительности (т.к. симуляция прогона за весь семестр для каждого запроса ресурсоёмка) предусмотрен кеш: -* Список праздников текущего учебного года кешируется при первом обращении и инвалидируется при изменении таблицы `holidays`. -* Матрица учебного графика кешируется по ключу `(course, specialty_id, semester_id)`. -* Результаты подсчёта `consumed_hours` для каждого правила могут кешироваться с инвалидацией при изменении праздников или правил. - ---- - -## 2. Архитектурные Решения - -На основе обсуждений были задокументированы следующие концептуальные решения по архитектуре: - -1. **Реакция на праздники (Продление курса):** - Алгоритм воспринимает праздник как «пропуск хода», не отнимая проведённые часы от `totalHours`. Это означает, что пара **не переносится** на другой день или время — она просто пропускается без вычета часов. Фактически предмет будет отображаться в расписании дольше (больше недель), пока `totalHours` не будет полностью исчерпан. Преподаватель честно выработает положенный объём часов за счёт увеличения количества недель преподавания. - -2. **Нормализация через связанные таблицы:** - Мы не используем сырые массивы (`INTEGER[]`) или JSONB-колонки. Реализована структура со строгой нормализацией: - * Главная таблица: `schedule_rules` (хранит лимиты и дату старта). - * Подчинённая таблица: `schedule_rule_slots` (хранит конкретный день, чётность, номер пары, преподавателя, аудиторию, тип и формат — прикреплённые к ID главного правила через Foreign Key). - * Связующая таблица: `schedule_rule_groups` (Many-to-Many между правилом и группами). - Это позволяет базе данных строить сложные выборки в стиле «Покажи загруженность кабинета №21 во вторник на второй паре по чётным неделям», исключая тяжёлый парсинг JSON. - -3. **Поддержка подгрупп внутри слотов:** - В таблицу `schedule_rule_slots` введено поле `subgroup_id` (Id подгруппы, nullable). Алгоритм генератора сможет рендерить два предмета для одной группы одновременно и без конфликтов, если они ассоциированы с разными подгруппами одной материнской группы. - -4. **Обогащённые слоты (Вариант Б):** - `teacher_id`, `classroom_id`, `lesson_type_id` и `lesson_format` хранятся в каждой строке `schedule_rule_slots`, а не в главном правиле. Это позволяет описывать лекции и практики одного предмета в рамках одного правила, расходуя общий `totalHours`. - -5. **Потоковые лекции через Many-to-Many:** - Одно правило связывается с несколькими группами через `schedule_rule_groups`. Для потоковой лекции создаётся одно правило, к которому привязываются все участвующие группы. - -6. **Настраиваемость по тенантам:** - Архитектурно все тенанты одинаковы — каждый университет получает идентичную пустую базу данных. Временные слоты (количество, длительность, время начала/окончания пар), конвенция чётности и прочие параметры не требуют специального механизма: каждый университет просто заполняет свою БД самостоятельно через панель администратора. - ---- - -## 3. Подробный План Действий по Реализации - -Интеграция новой архитектуры затронет весь стек приложения (DB → Backend → API → Frontend). Работу предлагается вести строго поэтапно: - -### Этап 1. База Данных (Flyway Миграции) - -**Схема Временных слотов:** -* `time_slots` (id, order_number, start_time TIME, end_time TIME, duration_minutes INT). - * Заполняется администратором. Нет фиксированных значений — каждый тенант настраивает свою сетку пар. - -**Схема Календарного графика:** -* `academic_years` (id, title VARCHAR, start_date DATE, end_date DATE). -* `semesters` (id, academic_year_id FK, semester_type ENUM('autumn','spring'), start_date DATE, end_date DATE). - * Именно от `semesters.start_date` отсчитывается «Неделя 1». -* `holidays` (id, date DATE, academic_year_id FK, description VARCHAR). -* `academic_calendar_matrix` (id, semester_id FK, course_number INT, specialty_id FK, week_number INT, activity_type ENUM('THEORY','EXAM','VACATION','PRACTICE')). - * Привязка к `course_number` + `specialty_id`, а НЕ к конкретной группе. - -**Схема Движка Правил:** -* `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date DATE, total_academic_hours INT). - * `total_academic_hours` — в академических часах (1 ак. час = 45 мин, одна пара = 2 ак. часа). -* `schedule_rule_groups` (schedule_rule_id FK, group_id FK) — PK составной. - * Связующая таблица для потоковых лекций. -* `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week INT CHECK(1–7), parity ENUM('BOTH','EVEN','ODD'), time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format VARCHAR). - -**Скрипт Миграции (Data ETL):** Написание SQL/Java скрипта для миграции данных из двух источников: -1. **Из `schedule_data`** → `schedule_rules` + `schedule_rule_groups`: перенос плановой нагрузки (`number_of_hours` → `total_academic_hours`, `group_id`, `subjects_id`, `teacher_id`, `lesson_type_id`, `is_division`, `semester_type`, `period`). -2. **Из `lessons`** → `schedule_rule_slots`: перенос расписания с трансформацией данных: - * `day` (строка «Понедельник»...«Суббота») → `day_of_week` (INT 1–6). - * `time` (строка «8:00 - 9:30») → `time_slot_id` (FK на `time_slots`). - * `week` (строка «Верхняя»/«Нижняя»/«Обе») → `parity` (ENUM `ODD`/`EVEN`/`BOTH`). - * Группировка записей с одинаковым `(subject_id, group_id)` в одно правило. - -После успешной миграции и верификации данных — удаление таблиц `lessons` и `schedule_data`. - -### Этап 2. Бэкенд и Вычислительное Ядро (Java + Spring Boot) -* `AcademicDateService.java` — сервис утилит для календарной математики: - * Перевод дат в номер недели семестра. - * Определение чётности недели с учётом настройки тенанта. - * Проверка попадания дня в справочник `holidays`. - * Вычисление текущего курса группы: `текущий_учебный_год - year_start_study + 1`. -* `ScheduleRuleRepository.java` — JPA репозитории для извлечения графа правил из базы данных, с оптимизацией N+1 проблемы через `JOIN FETCH` со слотами и группами. -* `ScheduleGeneratorService.java` — Сердце системы. Основные методы: - * `List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы. - * `List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate)` — расписание преподавателя (поиск по `teacher_id` в слотах, обогащение информацией о группах). - * Реализует всю бизнес-логику из пункта 1.4 (подсчёт вычитанных часов, пропуск праздников, кеширование). -* Адаптация валидаторов пересечения аудиторий: теперь валидатор должен работать не на уровне «каждой пары», а симулировать весь семестр на этапе сохранения нового Правила в панели администратора. - -### Этап 3. Обновление REST API (Контроллеры) -* **Новый эндпоинт расписания:** `GET /api/schedule` переходит на диапазонную модель. Параметры: `?groupId=123&startDate=2024-10-14&endDate=2024-10-20` или `?teacherId=456&startDate=...&endDate=...`. Ответ — массив объектов с полными датами `YYYY-MM-DD`. -* **Обратная совместимость:** Старый эндпоинт `GET /api/users/lessons` будет помечен как `@Deprecated` и продолжит работать до полной миграции фронтенда. После завершения миграции фронтенда — удаление. -* **CRUD-контроллеры для админки:** - * `/api/admin/time-slots` (настройка сетки временных слотов). - * `/api/admin/calendar/years` (учебные годы и семестры). - * `/api/admin/calendar/matrix` (настройка каникул и сессий по курсам/специальностям/неделям). - * `/api/admin/calendar/holidays` (добавление исключений). - * `/api/admin/schedule-rules` (управление жизненным циклом Правил, их слотами и привязкой к группам). - -### Этап 4. Интерфейсы Frontend (Vanilla JS + HTML) -* **Страницы просмотра (Студенты и Преподаватели):** - * Реализация переключателя календарных дат (Date Picker или кнопки-перелистывания недель). - * Логика, которая при свайпе или клике запрашивает у API конкретный диапазон дат и перерисовывает DOM-дерево. - * Для преподавателей — отображение всех групп, привязанных к каждому занятию. -* **Панель Администратора (SPA-интерфейсы):** - * **Вкладка «Временные слоты»:** Настройка сетки пар — количество, время начала/окончания, длительность. - * **Вкладка «Учебный график»:** Визуальная сетка-матрица (недели по горизонтали, Курсы/Специальности по вертикали), где админ может закрашивать пересечения разными цветами, назначая статусы (Практика, Каникулы, Теория, Экзамены). - * **Вкладка «Конструктор Правил»:** Глобально новый визуальный инструмент расписания. Админ выбирает Группы (одну или несколько для потока) и Дисциплину, задаёт `totalHours` в академических часах, а затем динамически добавляет строчки массива слотов через кнопку «Добавить занятие» со списками (Selects) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия. +Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. diff --git a/SCHEDULE_TASKS.md b/SCHEDULE_TASKS.md index 30c600d..aec40d0 100644 --- a/SCHEDULE_TASKS.md +++ b/SCHEDULE_TASKS.md @@ -1,218 +1,32 @@ -# 📋 Задачи: Динамическая генерация расписания +# Задачи: динамическое расписание и календарный учебный график -> Декомпозиция [`SCHEDULE_PROPOSAL.md`](SCHEDULE_PROPOSAL.md) на подзадачи для доски планирования. -> Категории: **Backend**, **Frontend**, **DevOps/DB** +Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год. ---- +## Выполнено -## DevOps / Database +- [x] Переписана базовая схема `V1__init.sql` под полный сброс БД. +- [x] Добавлены специальности, профили обучения и связь группы с профилем. +- [x] Добавлены формы обучения календарного графика. +- [x] Добавлен справочник кодов активностей Excel-графика. +- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. +- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. +- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. +- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. +- [x] Обновлены админские экраны специальностей, групп и расписания. +- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. -### Flyway-миграция: Временные слоты -- [ ] Создать миграцию: таблица `time_slots` (id, order_number, start_time, end_time, duration_minutes) -- [ ] Добавить CHECK-ограничения (start_time < end_time, duration_minutes > 0, order_number > 0) +## Оставшиеся задачи ---- +- [ ] Добавить интеграционные тесты генерации расписания для кодов `Т`, `Э`, `К`, `*`, `=`. +- [ ] Добавить проверку конфликтов преподавателей, аудиторий и групп при сохранении правил. +- [ ] Добавить модель связи пользователя-студента с учебной группой. +- [ ] Добавить импорт календарного графика из Excel, если он понадобится после ручного редактора. -### Flyway-миграция: Учебные годы и семестры -- [ ] Создать миграцию: таблица `academic_years` (id, title, start_date, end_date) -- [ ] Создать миграцию: таблица `semesters` (id, academic_year_id FK, semester_type ENUM, start_date, end_date) -- [ ] Добавить CHECK-ограничения и индексы +## Проверочный сценарий ---- - -### Flyway-миграция: Праздники -- [ ] Создать миграцию: таблица `holidays` (id, date, academic_year_id FK, description) -- [ ] Добавить уникальный индекс на (date, academic_year_id) - ---- - -### Flyway-миграция: Матрица учебного графика -- [ ] Создать миграцию: таблица `academic_calendar_matrix` (id, semester_id FK, course_number, specialty_id FK, week_number, activity_type ENUM) -- [ ] Добавить ENUM: `THEORY`, `EXAM`, `VACATION`, `PRACTICE` -- [ ] Добавить уникальный индекс на (semester_id, course_number, specialty_id, week_number) - ---- - -### Flyway-миграция: Правила расписания -- [ ] Создать миграцию: таблица `schedule_rules` (id, subject_id FK, semester_id FK, active_from_date, total_academic_hours) -- [ ] Создать миграцию: связующая таблица `schedule_rule_groups` (schedule_rule_id FK, group_id FK, PK составной) -- [ ] Создать миграцию: таблица `schedule_rule_slots` (id, schedule_rule_id FK, day_of_week, parity ENUM, time_slot_id FK, subgroup_id FK NULL, teacher_id FK, classroom_id FK, lesson_type_id FK, lesson_format) -- [ ] Добавить CHECK на day_of_week (1–7) -- [ ] Добавить ENUM: `BOTH`, `EVEN`, `ODD` - ---- - -### ETL-миграция данных -- [ ] Написать SQL/Java скрипт миграции `schedule_data` → `schedule_rules` + `schedule_rule_groups` - - [ ] Маппинг `number_of_hours` → `total_academic_hours` - - [ ] Маппинг привязок групп -- [ ] Написать SQL/Java скрипт миграции `lessons` → `schedule_rule_slots` - - [ ] Трансформация `day` (строка) → `day_of_week` (INT 1–6) - - [ ] Трансформация `time` (строка) → `time_slot_id` (FK) - - [ ] Трансформация `week` (строка) → `parity` (ENUM) - - [ ] Группировка записей с одинаковым (subject_id, group_id) в одно правило -- [ ] Верификация мигрированных данных (количество записей, целостность FK) -- [ ] Создать миграцию на удаление устаревших таблиц `lessons` и `schedule_data` (после верификации) - ---- - -## Backend (Java + Spring Boot) - -### JPA-сущности (Model) -- [ ] Создать Entity: `TimeSlot` -- [ ] Создать Entity: `AcademicYear` -- [ ] Создать Entity: `Semester` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `Holiday` (связь ManyToOne → AcademicYear) -- [ ] Создать Entity: `AcademicCalendarMatrix` (связи на Semester, Specialty) -- [ ] Создать Entity: `ScheduleRule` (связи на Subject, Semester) -- [ ] Создать Entity: `ScheduleRuleSlot` (связи на ScheduleRule, TimeSlot, Teacher, Classroom, LessonType) -- [ ] Настроить ManyToMany-связь ScheduleRule ↔ StudentGroup через `schedule_rule_groups` - ---- - -### DTO -- [ ] Создать DTO: `TimeSlotDto` -- [ ] Создать DTO: `AcademicYearDto`, `SemesterDto` -- [ ] Создать DTO: `HolidayDto` -- [ ] Создать DTO: `AcademicCalendarMatrixDto` -- [ ] Создать DTO: `ScheduleRuleDto`, `ScheduleRuleSlotDto` -- [ ] Создать DTO: `RenderedLessonDto` (ответ генератора расписания) - ---- - -### Repository -- [ ] Создать `TimeSlotRepository` -- [ ] Создать `AcademicYearRepository` -- [ ] Создать `SemesterRepository` (метод findByDateRange) -- [ ] Создать `HolidayRepository` (метод findByAcademicYearId) -- [ ] Создать `AcademicCalendarMatrixRepository` (метод findBySemesterAndCourseAndSpecialty) -- [ ] Создать `ScheduleRuleRepository` с JOIN FETCH (решение N+1 проблемы) - - [ ] Метод: findByGroupIdAndSemesterId (через schedule_rule_groups) - - [ ] Метод: findByTeacherIdAndSemesterId (через schedule_rule_slots.teacher_id) - ---- - -### Сервис: AcademicDateService -- [ ] Метод: перевод произвольной даты → номер недели семестра -- [ ] Метод: определение чётности недели с учётом настройки тенанта -- [ ] Метод: проверка попадания даты в справочник `holidays` -- [ ] Метод: вычисление текущего курса группы (`текущий_учебный_год - year_start_study + 1`) -- [ ] Метод: определение семестра по дате -- [ ] Написать юнит-тесты для AcademicDateService - ---- - -### Сервис: ScheduleGeneratorService -- [ ] Метод: `buildScheduleForGroup(groupId, startDate, endDate)` — расписание группы - - [ ] Определение семестра по диапазону дат - - [ ] Вычисление номера недели и курса группы - - [ ] Проверка типа деятельности через матрицу графика - - [ ] Загрузка активных правил для группы - - [ ] Симуляция прогона часов (подсчёт consumed_hours) - - [ ] Пропуск праздников при подсчёте часов - - [ ] Проекция слотов на запрошенную неделю с учётом чётности и подгрупп -- [ ] Метод: `buildScheduleForTeacher(teacherId, startDate, endDate)` — расписание преподавателя - - [ ] Поиск правил по teacher_id в слотах - - [ ] Обогащение ответа списком групп из schedule_rule_groups -- [ ] Написать юнит-тесты для ScheduleGeneratorService -- [ ] Написать интеграционные тесты (полный цикл с тестовой БД) - ---- - -### Кеширование -- [ ] Реализовать кеш списка праздников по учебному году -- [ ] Реализовать кеш матрицы учебного графика по ключу (course, specialty_id, semester_id) -- [ ] Реализовать кеш consumed_hours для каждого правила -- [ ] Реализовать инвалидацию кеша праздников при CRUD-операциях с holidays -- [ ] Реализовать инвалидацию кеша consumed_hours при изменении правил или праздников - ---- - -### Валидация -- [ ] Адаптировать валидатор пересечения аудиторий (симуляция всего семестра при сохранении правила) -- [ ] Валидация пересечения преподавателей (один преподаватель не может вести две пары одновременно) -- [ ] Валидация пересечения групп (одна группа не может быть на двух занятиях одновременно, кроме подгрупп) - ---- - -### REST API: Контроллеры -- [ ] `GET /api/schedule` — Новый эндпоинт расписания (параметры: groupId/teacherId + startDate + endDate) -- [ ] Пометить `GET /api/users/lessons` как `@Deprecated` (обратная совместимость) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/time-slots` -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/years` -- [ ] CRUD: `GET/PUT /api/admin/calendar/semesters` (вложены в years) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/calendar/holidays` -- [ ] CRUD: `GET/PUT /api/admin/calendar/matrix` (массовое сохранение матрицы) -- [ ] CRUD: `POST/GET/PUT/DELETE /api/admin/schedule-rules` - - [ ] Включая вложенные слоты и привязку групп -- [ ] Написать интеграционные тесты для API - ---- - -### Удаление устаревшего кода -- [ ] Удалить/рефакторить старый `LessonsController` (после миграции фронтенда) -- [ ] Удалить/рефакторить старый `ScheduleDataController` -- [ ] Удалить старые Entity: `Lesson`, `ScheduleData` -- [ ] Удалить старые Repository и Service для lessons/schedule_data - ---- - -## Frontend (Vanilla JS + HTML/CSS) - -### Просмотр расписания: Студенты -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?groupId=...&startDate=...&endDate=...` -- [ ] Рендеринг расписания по дням и временным слотам -- [ ] Отображение статуса периода (Каникулы / Практика / Экзамены), если неделя не учебная -- [ ] Отображение информации о подгруппах (два занятия рядом для разных подгрупп) - ---- - -### Просмотр расписания: Преподаватели -- [ ] Реализовать переключатель дат (Date Picker / кнопки-стрелки по неделям) -- [ ] Переключить API-запросы на новый `GET /api/schedule?teacherId=...&startDate=...&endDate=...` -- [ ] Отображение всех групп, привязанных к каждому занятию -- [ ] Отображение подгрупп, если преподаватель ведёт у подгруппы - ---- - -### Панель администратора: Вкладка «Временные слоты» -- [ ] Создать UI-страницу настройки временных слотов -- [ ] CRUD-интерфейс: добавление/редактирование/удаление пар -- [ ] Отображение таблицы: номер пары → время начала → время окончания → длительность -- [ ] Валидация на фронтенде (пересечение времён, корректность данных) - ---- - -### Панель администратора: Вкладка «Учебный график» -- [ ] Создать UI: выбор учебного года и семестра -- [ ] Создать UI: CRUD учебных годов и семестров -- [ ] Создать UI: CRUD праздников (список дат с описанием) -- [ ] Создать визуальную сетку-матрицу: - - [ ] Горизонтальная ось — номера недель - - [ ] Вертикальная ось — Курс + Специальность - - [ ] Цветовая кодировка ячеек: Теория/Экзамены/Каникулы/Практика - - [ ] Клик/драг для массового назначения статуса -- [ ] Сохранение матрицы через API `PUT /api/admin/calendar/matrix` - ---- - -### Панель администратора: Вкладка «Конструктор Правил» -- [ ] Создать UI: список существующих правил с фильтрацией (по группе, предмету, семестру) -- [ ] Форма создания/редактирования правила: - - [ ] Мультиселект групп (для потоковых лекций) - - [ ] Выбор дисциплины (subject) - - [ ] Выбор семестра - - [ ] Ввод totalHours (академические часы) - - [ ] Ввод даты начала (active_from_date) -- [ ] Динамический массив слотов (кнопка «Добавить занятие»): - - [ ] Select: День недели - - [ ] Select: Временной слот (из таблицы time_slots) - - [ ] Select: Чётность (Обе/Чётная/Нечётная) - - [ ] Select: Подгруппа (опционально) - - [ ] Select: Преподаватель - - [ ] Select: Аудитория - - [ ] Select: Тип занятия (Лекция/Практика/Лаба) - - [ ] Select: Формат (Очно/Онлайн) -- [ ] Визуальное предупреждение при конфликтах (аудитория/преподаватель уже заняты) -- [ ] Удаление правила с подтверждением +1. Полностью сбросить локальную БД. +2. Запустить приложение и применить `V1__init.sql`. +3. Создать профиль специальности. +4. Создать календарный график и заполнить дневную сетку. +5. Назначить график группе. +6. Проверить `GET /api/schedule` на дне теории и на днях, где пары запрещены. diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java index d3c1214..8d15db0 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -5,7 +5,6 @@ import com.magistr.app.model.*; import com.magistr.app.repository.*; import com.magistr.app.service.ScheduleGeneratorService; import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.Comparator; @@ -18,19 +17,19 @@ public class AcademicCalendarAdminController { private final AcademicYearRepository academicYearRepository; private final SemesterRepository semesterRepository; - private final HolidayRepository holidayRepository; - private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicCalendarDayRepository calendarDayRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, SemesterRepository semesterRepository, - HolidayRepository holidayRepository, - AcademicCalendarMatrixRepository calendarMatrixRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicCalendarDayRepository calendarDayRepository, ScheduleGeneratorService scheduleGeneratorService) { this.academicYearRepository = academicYearRepository; this.semesterRepository = semesterRepository; - this.holidayRepository = holidayRepository; - this.calendarMatrixRepository = calendarMatrixRepository; + this.activityTypeRepository = activityTypeRepository; + this.calendarDayRepository = calendarDayRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -128,113 +127,60 @@ public class AcademicCalendarAdminController { return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); } - @GetMapping("/holidays") - public ResponseEntity getHolidays(@RequestParam Long academicYearId) { - if (!academicYearRepository.existsById(academicYearId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); - } - return ResponseEntity.ok(holidayRepository.findByAcademicYearIdOrderByDateAsc(academicYearId).stream() - .map(this::toHolidayDto) - .toList()); + @GetMapping("/activity-types") + public List getActivityTypes() { + return activityTypeRepository.findAllByOrderByDisplayOrderAscCodeAsc().stream() + .map(this::toActivityTypeDto) + .toList(); } - @PostMapping("/holidays") - public ResponseEntity createHoliday(@RequestBody HolidayDto request) { - AcademicYear year = academicYearRepository.findById(request.academicYearId()).orElse(null); - if (year == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + @PostMapping("/activity-types") + public ResponseEntity createActivityType(@RequestBody AcademicCalendarActivityTypeDto request) { + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - if (request.date() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + if (activityTypeRepository.findByCode(request.code().trim()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); } - if (holidayRepository.existsByAcademicYearIdAndDate(year.getId(), request.date())) { - return ResponseEntity.badRequest().body(Map.of("message", "Эта дата уже добавлена в исключения")); - } - - Holiday holiday = new Holiday(); - holiday.setAcademicYear(year); - holiday.setDate(request.date()); - holiday.setDescription(request.description()); + AcademicCalendarActivityType activityType = new AcademicCalendarActivityType(); + applyActivityType(activityType, request); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); } - @PutMapping("/holidays/{id}") - public ResponseEntity updateHoliday(@PathVariable Long id, @RequestBody HolidayDto request) { - Holiday holiday = holidayRepository.findById(id).orElse(null); - if (holiday == null) { + @PutMapping("/activity-types/{id}") + public ResponseEntity updateActivityType(@PathVariable Long id, + @RequestBody AcademicCalendarActivityTypeDto request) { + AcademicCalendarActivityType activityType = activityTypeRepository.findById(id).orElse(null); + if (activityType == null) { return ResponseEntity.notFound().build(); } - if (request.date() == null) { - return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + String validationError = validateActivityType(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); } - holiday.setDate(request.date()); - holiday.setDescription(request.description()); + if (activityTypeRepository.findByCode(request.code().trim()) + .filter(existing -> !existing.getId().equals(id)) + .isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности уже существует")); + } + applyActivityType(activityType, request); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + return ResponseEntity.ok(toActivityTypeDto(activityTypeRepository.save(activityType))); } - @DeleteMapping("/holidays/{id}") - public ResponseEntity deleteHoliday(@PathVariable Long id) { - if (!holidayRepository.existsById(id)) { + @DeleteMapping("/activity-types/{id}") + public ResponseEntity deleteActivityType(@PathVariable Long id) { + if (!activityTypeRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - holidayRepository.deleteById(id); + if (calendarDayRepository.existsByActivityTypeId(id)) { + return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках")); + } + activityTypeRepository.deleteById(id); scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Праздник удалён")); - } - - @GetMapping("/matrix") - public ResponseEntity getMatrix(@RequestParam Long semesterId, - @RequestParam Integer courseNumber, - @RequestParam Long specialtyId) { - if (!semesterRepository.existsById(semesterId)) { - return ResponseEntity.badRequest().body(Map.of("message", "Семестр не найден")); - } - return ResponseEntity.ok(calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(semesterId, courseNumber, specialtyId) - .stream() - .map(this::toMatrixDto) - .toList()); - } - - @PutMapping("/matrix") - @Transactional - public ResponseEntity saveMatrix(@RequestBody List rows) { - if (rows == null || rows.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну строку матрицы")); - } - - try { - for (AcademicCalendarMatrixDto row : rows) { - Semester semester = semesterRepository.findById(row.semesterId()) - .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); - if (row.courseNumber() == null || row.courseNumber() <= 0 - || row.specialtyId() == null - || row.weekNumber() == null || row.weekNumber() <= 0) { - throw new IllegalArgumentException("Курс, специальность и номер недели обязательны"); - } - AcademicCalendarMatrix matrix = calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - row.semesterId(), - row.courseNumber(), - row.specialtyId(), - row.weekNumber() - ) - .orElseGet(AcademicCalendarMatrix::new); - matrix.setSemester(semester); - matrix.setCourseNumber(row.courseNumber()); - matrix.setSpecialtyId(row.specialtyId()); - matrix.setWeekNumber(row.weekNumber()); - matrix.setActivityType(row.activityType() == null ? AcademicActivityType.THEORY : row.activityType()); - calendarMatrixRepository.save(matrix); - } - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); - } - - scheduleGeneratorService.clearCache(); - return ResponseEntity.ok(Map.of("message", "Матрица учебного графика сохранена")); + return ResponseEntity.ok(Map.of("message", "Код активности удалён")); } private String validateYear(AcademicYearDto request) { @@ -263,6 +209,19 @@ public class AcademicCalendarAdminController { return null; } + private String validateActivityType(AcademicCalendarActivityTypeDto request) { + if (request == null) { + return "Передайте код активности"; + } + if (request.code() == null || request.code().isBlank()) { + return "Код активности обязателен"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название активности обязательно"; + } + return null; + } + private void applyYear(AcademicYear year, AcademicYearDto request) { year.setTitle(request.title().trim()); year.setStartDate(request.startDate()); @@ -275,6 +234,19 @@ public class AcademicCalendarAdminController { semester.setEndDate(request.endDate()); } + private void applyActivityType(AcademicCalendarActivityType activityType, AcademicCalendarActivityTypeDto request) { + activityType.setCode(request.code().trim()); + activityType.setName(request.name().trim()); + activityType.setAllowSchedule(Boolean.TRUE.equals(request.allowSchedule())); + activityType.setColorCode(request.colorCode() == null || request.colorCode().isBlank() + ? "#64748b" + : request.colorCode().trim()); + activityType.setDisplayOrder(request.displayOrder() == null ? 100 : request.displayOrder()); + activityType.setDescription(request.description() == null || request.description().isBlank() + ? null + : request.description().trim()); + } + private AcademicYearDto toAcademicYearDto(AcademicYear year) { List semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId()) .stream() @@ -294,24 +266,15 @@ public class AcademicCalendarAdminController { ); } - private HolidayDto toHolidayDto(Holiday holiday) { - return new HolidayDto( - holiday.getId(), - holiday.getDate(), - holiday.getAcademicYear().getId(), - holiday.getAcademicYear().getTitle(), - holiday.getDescription() - ); - } - - private AcademicCalendarMatrixDto toMatrixDto(AcademicCalendarMatrix matrix) { - return new AcademicCalendarMatrixDto( - matrix.getId(), - matrix.getSemester().getId(), - matrix.getCourseNumber(), - matrix.getSpecialtyId(), - matrix.getWeekNumber(), - matrix.getActivityType() + private AcademicCalendarActivityTypeDto toActivityTypeDto(AcademicCalendarActivityType activityType) { + return new AcademicCalendarActivityTypeDto( + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule(), + activityType.getColorCode(), + activityType.getDisplayOrder(), + activityType.getDescription() ); } } diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java new file mode 100644 index 0000000..37941e8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -0,0 +1,255 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.*; +import com.magistr.app.model.*; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/academic-calendars") +public class AcademicCalendarController { + + private final AcademicCalendarRepository calendarRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarActivityTypeRepository activityTypeRepository; + private final AcademicYearRepository academicYearRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository profileRepository; + private final CalendarStudyFormRepository studyFormRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarController(AcademicCalendarRepository calendarRepository, + AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarActivityTypeRepository activityTypeRepository, + AcademicYearRepository academicYearRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository profileRepository, + CalendarStudyFormRepository studyFormRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.calendarRepository = calendarRepository; + this.calendarDayRepository = calendarDayRepository; + this.activityTypeRepository = activityTypeRepository; + this.academicYearRepository = academicYearRepository; + this.specialtiesRepository = specialtiesRepository; + this.profileRepository = profileRepository; + this.studyFormRepository = studyFormRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getCalendars(@RequestParam(required = false) Long academicYearId, + @RequestParam(required = false) Long specialtyId, + @RequestParam(required = false) Long profileId) { + return calendarRepository.findAllWithDetails(academicYearId, specialtyId, profileId).stream() + .map(this::toCalendarDto) + .toList(); + } + + @GetMapping("/study-forms") + public List getStudyForms() { + return studyFormRepository.findAllByOrderByNameAsc().stream() + .map(form -> new CalendarStudyFormDto(form.getId(), form.getName(), form.getDescription())) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getCalendar(@PathVariable Long id) { + return calendarRepository.findByIdWithDetails(id) + .>map(calendar -> ResponseEntity.ok(toCalendarDto(calendar))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createCalendar(@RequestBody AcademicCalendarDto request) { + try { + AcademicCalendar calendar = new AcademicCalendar(); + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + public ResponseEntity updateCalendar(@PathVariable Long id, @RequestBody AcademicCalendarDto request) { + AcademicCalendar calendar = calendarRepository.findById(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + try { + applyCalendar(calendar, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCalendar(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + try { + calendarRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный график удалён")); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить график, который назначен учебным группам")); + } + } + + @GetMapping("/{id}/grid") + public ResponseEntity getGrid(@PathVariable Long id) { + if (!calendarRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream() + .map(this::toGridDayDto) + .toList()); + } + + @PutMapping("/{id}/grid") + @Transactional + public ResponseEntity saveGrid(@PathVariable Long id, @RequestBody List rows) { + AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null); + if (calendar == null) { + return ResponseEntity.notFound().build(); + } + if (rows == null || rows.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну ячейку графика")); + } + + try { + calendarDayRepository.deleteByCalendarId(id); + for (AcademicCalendarGridDayDto row : rows) { + AcademicCalendarDay day = buildGridDay(calendar, row); + calendarDayRepository.save(day); + } + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Календарный учебный график сохранён")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) { + if (request == null) { + throw new IllegalArgumentException("Передайте данные календарного графика"); + } + if (request.title() == null || request.title().isBlank()) { + throw new IllegalArgumentException("Название графика обязательно"); + } + if (request.courseCount() == null || request.courseCount() <= 0) { + throw new IllegalArgumentException("Количество курсов должно быть больше нуля"); + } + if (request.academicYearId() == null || request.specialtyId() == null + || request.specialtyProfileId() == null || request.studyFormId() == null) { + throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны"); + } + + AcademicYear year = academicYearRepository.findById(request.academicYearId()) + .orElseThrow(() -> new IllegalArgumentException("Учебный год не найден")); + Speciality speciality = specialtiesRepository.findById(request.specialtyId()) + .orElseThrow(() -> new IllegalArgumentException("Специальность не найдена")); + SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId()) + .orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден")); + if (!profile.getSpeciality().getId().equals(speciality.getId())) { + throw new IllegalArgumentException("Профиль не относится к выбранной специальности"); + } + CalendarStudyForm studyForm = studyFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения графика не найдена")); + + calendar.setTitle(request.title().trim()); + calendar.setAcademicYear(year); + calendar.setSpeciality(speciality); + calendar.setSpecialtyProfile(profile); + calendar.setStudyForm(studyForm); + calendar.setCourseCount(request.courseCount()); + } + + private AcademicCalendarDay buildGridDay(AcademicCalendar calendar, AcademicCalendarGridDayDto row) { + if (row.courseNumber() == null || row.courseNumber() <= 0 + || row.date() == null + || row.weekNumber() == null || row.weekNumber() <= 0 + || row.dayOfWeek() == null || row.dayOfWeek() < 1 || row.dayOfWeek() > 7) { + throw new IllegalArgumentException("Курс, дата, неделя и день недели обязательны"); + } + if (row.date().isBefore(calendar.getAcademicYear().getStartDate()) + || row.date().isAfter(calendar.getAcademicYear().getEndDate())) { + throw new IllegalArgumentException("Дата выходит за пределы учебного года"); + } + if (row.courseNumber() > calendar.getCourseCount()) { + throw new IllegalArgumentException("Номер курса выходит за пределы графика"); + } + + AcademicCalendarActivityType activityType = resolveActivityType(row); + AcademicCalendarDay day = new AcademicCalendarDay(); + day.setAcademicCalendar(calendar); + day.setCourseNumber(row.courseNumber()); + day.setDate(row.date()); + day.setWeekNumber(row.weekNumber()); + day.setDayOfWeek(row.dayOfWeek()); + day.setActivityType(activityType); + return day; + } + + private AcademicCalendarActivityType resolveActivityType(AcademicCalendarGridDayDto row) { + if (row.activityTypeId() != null) { + return activityTypeRepository.findById(row.activityTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + if (row.activityCode() != null && !row.activityCode().isBlank()) { + return activityTypeRepository.findByCode(row.activityCode().trim()) + .orElseThrow(() -> new IllegalArgumentException("Код активности не найден")); + } + throw new IllegalArgumentException("Код активности обязателен"); + } + + private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) { + AcademicYear year = calendar.getAcademicYear(); + Speciality speciality = calendar.getSpeciality(); + SpecialtyProfile profile = calendar.getSpecialtyProfile(); + CalendarStudyForm studyForm = calendar.getStudyForm(); + return new AcademicCalendarDto( + calendar.getId(), + calendar.getTitle(), + year.getId(), + year.getTitle(), + year.getStartDate(), + year.getEndDate(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName(), + studyForm.getId(), + studyForm.getName(), + calendar.getCourseCount() + ); + } + + private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) { + AcademicCalendarActivityType activityType = day.getActivityType(); + return new AcademicCalendarGridDayDto( + day.getId(), + day.getAcademicCalendar().getId(), + day.getCourseNumber(), + day.getDate(), + day.getWeekNumber(), + day.getDayOfWeek(), + activityType.getId(), + activityType.getCode(), + activityType.getName(), + activityType.getAllowSchedule() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 9fd2578..fdb179e 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -1,11 +1,17 @@ package com.magistr.app.controller; import com.magistr.app.dto.CreateGroupRequest; +import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupResponse; +import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; +import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; -import com.magistr.app.repository.EducationFormRepository; -import com.magistr.app.repository.GroupRepository; +import com.magistr.app.model.StudentGroupCalendarAssignment; +import com.magistr.app.repository.*; +import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.utils.CourseAndSemesterCalculator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,7 +19,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.Year; import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,11 +31,29 @@ public class GroupController { private final GroupRepository groupRepository; private final EducationFormRepository educationFormRepository; + private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; + private final AcademicYearRepository academicYearRepository; + private final AcademicCalendarRepository academicCalendarRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; + private final ScheduleGeneratorService scheduleGeneratorService; public GroupController(GroupRepository groupRepository, - EducationFormRepository educationFormRepository) { + EducationFormRepository educationFormRepository, + SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository, + AcademicYearRepository academicYearRepository, + AcademicCalendarRepository academicCalendarRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, + ScheduleGeneratorService scheduleGeneratorService) { this.groupRepository = groupRepository; this.educationFormRepository = educationFormRepository; + this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; + this.academicYearRepository = academicYearRepository; + this.academicCalendarRepository = academicCalendarRepository; + this.assignmentRepository = assignmentRepository; + this.scheduleGeneratorService = scheduleGeneratorService; } @GetMapping @@ -41,21 +64,7 @@ public class GroupController { List groups = groupRepository.findAll(); List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Получено {} групп", response.size()); return response; @@ -78,21 +87,7 @@ public class GroupController { } List response = groups.stream() - .map(g -> { - int course = CourseAndSemesterCalculator.getActualCourse(g.getYearStartStudy()); - int semester = CourseAndSemesterCalculator.getActualSemester(g.getYearStartStudy()); - return new GroupResponse( - g.getId(), - g.getName(), - g.getGroupSize(), - g.getEducationForm().getId(), - g.getEducationForm().getName(), - g.getDepartmentId(), - course, - semester, - g.getSpecialityCode() - ); - }) + .map(this::mapToResponse) .toList(); logger.info("Найдено {} групп для кафедры с ID - {}", response.size(), departmentId); @@ -110,51 +105,25 @@ public class GroupController { logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, yearStartStudy = {}", request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getYearStartStudy()); try { - if (request.getName() == null || request.getName().isBlank()) { - String errorMessage = "Название группы обязательно"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (groupRepository.findByName(request.getName().trim()).isPresent()) { - String errorMessage = "Группа с таким названием уже существует"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getGroupSize() == null) { - String errorMessage = "Численность группы обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getEducationFormId() == null) { - String errorMessage = "Форма обучения обязательна"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { - String errorMessage = "ID кафедры обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } -// if (request.getCourse() == null || request.getCourse() == 0) { -// String errorMessage = "Курс обязателен"; -// logger.error("Ошибка валидации: {}", errorMessage); -// return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); -// } - if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { - String errorMessage = "Год начала обучения обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); - } - if (request.getSpecialityCode() == null || request.getSpecialityCode() == 0) { - String errorMessage = "Код специальности обязателен"; - logger.error("Ошибка валидации: {}", errorMessage); - return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; } Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); if (efOpt.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } StudentGroup group = new StudentGroup(); group.setName(request.getName().trim()); @@ -162,20 +131,13 @@ public class GroupController { group.setEducationForm(efOpt.get()); group.setDepartmentId(request.getDepartmentId()); group.setYearStartStudy(request.getYearStartStudy()); - group.setSpecialityCode(request.getSpecialityCode()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); groupRepository.save(group); logger.info("Группа успешно создана с ID - {}", group.getId()); - return ResponseEntity.ok(new GroupResponse( - group.getId(), - group.getName(), - group.getGroupSize(), - group.getEducationForm().getId(), - group.getEducationForm().getName(), - group.getDepartmentId(), - group.getYearStartStudy(), - group.getSpecialityCode())); + return ResponseEntity.ok(mapToResponse(group)); } catch (Exception e ) { logger.error("Ошибка при создании группы: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) @@ -183,6 +145,55 @@ public class GroupController { } } + @PutMapping("/{id}") + public ResponseEntity updateGroup(@PathVariable Long id, @RequestBody CreateGroupRequest request) { + logger.info("Получен запрос на обновление группы с ID - {}", id); + try { + Optional groupOpt = groupRepository.findById(id); + if (groupOpt.isEmpty()) { + logger.info("Группа с ID - {} не найдена", id); + return ResponseEntity.notFound().build(); + } + + ResponseEntity validationError = validateGroupRequest(request); + if (validationError != null) { + return validationError; + } + + Optional efOpt = educationFormRepository.findById(request.getEducationFormId()); + if (efOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена")); + } + Speciality speciality = specialtiesRepository.findById(request.getEffectiveSpecialtyId()) + .orElse(null); + if (speciality == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Специальность не найдена")); + } + SpecialtyProfile profile = specialtyProfileRepository.findById(request.getSpecialtyProfileId()) + .orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(speciality.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "Профиль обучения не найден для выбранной специальности")); + } + + StudentGroup group = groupOpt.get(); + group.setName(request.getName().trim()); + group.setGroupSize(request.getGroupSize()); + group.setEducationForm(efOpt.get()); + group.setDepartmentId(request.getDepartmentId()); + group.setYearStartStudy(request.getYearStartStudy()); + group.setSpeciality(speciality); + group.setSpecialtyProfile(profile); + groupRepository.save(group); + + logger.info("Группа с ID - {} успешно обновлена", id); + return ResponseEntity.ok(mapToResponse(group)); + } catch (Exception e) { + logger.error("Ошибка при обновлении группы с ID - {}: {}", id, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Произошла ошибка при обновлении группы: " + e.getMessage())); + } + } + @DeleteMapping("/{id}") public ResponseEntity deleteGroup(@PathVariable Long id) { logger.info("Получен запрос на удаление группы с ID - {}", id); @@ -194,4 +205,126 @@ public class GroupController { logger.info("Группа с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Группа удалена")); } + + private ResponseEntity validateGroupRequest(CreateGroupRequest request) { + if (request.getName() == null || request.getName().isBlank()) { + return validationError("Название группы обязательно"); + } + if (request.getGroupSize() == null) { + return validationError("Численность группы обязательна"); + } + if (request.getEducationFormId() == null) { + return validationError("Форма обучения обязательна"); + } + if (request.getDepartmentId() == null || request.getDepartmentId() == 0) { + return validationError("ID кафедры обязателен"); + } + if (request.getYearStartStudy() == null || request.getYearStartStudy() == 0) { + return validationError("Год начала обучения обязателен"); + } + if (request.getEffectiveSpecialtyId() == null || request.getEffectiveSpecialtyId() == 0) { + return validationError("Код специальности обязателен"); + } + if (request.getSpecialtyProfileId() == null || request.getSpecialtyProfileId() == 0) { + return validationError("Профиль обучения обязателен"); + } + return null; + } + + private ResponseEntity validationError(String errorMessage) { + logger.error("Ошибка валидации: {}", errorMessage); + return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); + } + + private GroupResponse mapToResponse(StudentGroup group) { + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy()); + int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); + Speciality speciality = group.getSpeciality(); + SpecialtyProfile profile = group.getSpecialtyProfile(); + return new GroupResponse( + group.getId(), + group.getName(), + group.getGroupSize(), + group.getEducationForm().getId(), + group.getEducationForm().getName(), + group.getDepartmentId(), + group.getYearStartStudy(), + course, + semester, + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getId(), + profile.getName() + ); + } + + @GetMapping("/{id}/calendar-assignments") + public ResponseEntity getCalendarAssignments(@PathVariable Long id) { + if (!groupRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream() + .map(this::toAssignmentDto) + .toList()); + } + + @PutMapping("/{id}/calendar-assignments") + public ResponseEntity saveCalendarAssignment(@PathVariable Long id, + @RequestBody GroupCalendarAssignmentDto request) { + if (request == null || request.academicYearId() == null || request.calendarId() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год и календарный график обязательны")); + } + StudentGroup group = groupRepository.findById(id).orElse(null); + if (group == null) { + return ResponseEntity.notFound().build(); + } + AcademicYear academicYear = academicYearRepository.findById(request.academicYearId()).orElse(null); + if (academicYear == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + AcademicCalendar calendar = academicCalendarRepository.findByIdWithDetails(request.calendarId()).orElse(null); + if (calendar == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Календарный график не найден")); + } + if (!calendar.getAcademicYear().getId().equals(academicYear.getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График относится к другому учебному году")); + } + if (!calendar.getSpeciality().getId().equals(group.getSpeciality().getId()) + || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); + } + + StudentGroupCalendarAssignment assignment = assignmentRepository + .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) + .orElseGet(StudentGroupCalendarAssignment::new); + assignment.setStudentGroup(group); + assignment.setAcademicYear(academicYear); + assignment.setAcademicCalendar(calendar); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment))); + } + + @DeleteMapping("/{id}/calendar-assignments/{assignmentId}") + public ResponseEntity deleteCalendarAssignment(@PathVariable Long id, @PathVariable Long assignmentId) { + StudentGroupCalendarAssignment assignment = assignmentRepository.findById(assignmentId).orElse(null); + if (assignment == null || !assignment.getStudentGroup().getId().equals(id)) { + return ResponseEntity.notFound().build(); + } + assignmentRepository.delete(assignment); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено")); + } + + private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) { + return new GroupCalendarAssignmentDto( + assignment.getId(), + assignment.getStudentGroup().getId(), + assignment.getStudentGroup().getName(), + assignment.getAcademicYear().getId(), + assignment.getAcademicYear().getTitle(), + assignment.getAcademicCalendar().getId(), + assignment.getAcademicCalendar().getTitle() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java index ab05f3e..a601627 100644 --- a/backend/src/main/java/com/magistr/app/controller/SpecialityController.java +++ b/backend/src/main/java/com/magistr/app/controller/SpecialityController.java @@ -2,8 +2,11 @@ package com.magistr.app.controller; import com.magistr.app.dto.CreateSpecialityRequest; import com.magistr.app.dto.SpecialityResponse; +import com.magistr.app.dto.SpecialtyProfileDto; import com.magistr.app.model.Speciality; +import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.repository.SpecialtiesRepository; +import com.magistr.app.repository.SpecialtyProfileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -20,9 +23,12 @@ public class SpecialityController { private static final Logger logger = LoggerFactory.getLogger(SpecialityController.class); private final SpecialtiesRepository specialtiesRepository; + private final SpecialtyProfileRepository specialtyProfileRepository; - public SpecialityController(SpecialtiesRepository specialtiesRepository) { + public SpecialityController(SpecialtiesRepository specialtiesRepository, + SpecialtyProfileRepository specialtyProfileRepository) { this.specialtiesRepository = specialtiesRepository; + this.specialtyProfileRepository = specialtyProfileRepository; } @GetMapping @@ -75,6 +81,10 @@ public class SpecialityController { speciality.setSpecialityName(request.getSpecialityName()); speciality.setSpecialityCode(request.getSpecialityCode()); specialtiesRepository.save(speciality); + SpecialtyProfile defaultProfile = new SpecialtyProfile(); + defaultProfile.setSpeciality(speciality); + defaultProfile.setName("Без профиля"); + specialtyProfileRepository.save(defaultProfile); logger.info("Специальность успешно создана с ID: {}", speciality.getId()); @@ -159,4 +169,98 @@ public class SpecialityController { logger.info("Специальность с ID - {} успешно удалена", id); return ResponseEntity.ok(Map.of("message", "Специальность удалена")); } + + @GetMapping("/{specialtyId}/profiles") + public ResponseEntity getProfiles(@PathVariable Long specialtyId) { + if (!specialtiesRepository.existsById(specialtyId)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(specialtyProfileRepository.findBySpecialityIdOrderByNameAsc(specialtyId).stream() + .map(this::toProfileDto) + .toList()); + } + + @PostMapping("/{specialtyId}/profiles") + public ResponseEntity createProfile(@PathVariable Long specialtyId, @RequestBody SpecialtyProfileDto request) { + Speciality speciality = specialtiesRepository.findById(specialtyId).orElse(null); + if (speciality == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, null, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + SpecialtyProfile profile = new SpecialtyProfile(); + profile.setSpeciality(speciality); + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @PutMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity updateProfile(@PathVariable Long specialtyId, + @PathVariable Long profileId, + @RequestBody SpecialtyProfileDto request) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + String validationError = validateProfile(specialtyId, profileId, request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + profile.setName(request.name().trim()); + profile.setDescription(trimToNull(request.description())); + return ResponseEntity.ok(toProfileDto(specialtyProfileRepository.save(profile))); + } + + @DeleteMapping("/{specialtyId}/profiles/{profileId}") + public ResponseEntity deleteProfile(@PathVariable Long specialtyId, @PathVariable Long profileId) { + SpecialtyProfile profile = specialtyProfileRepository.findById(profileId).orElse(null); + if (profile == null || !profile.getSpeciality().getId().equals(specialtyId)) { + return ResponseEntity.notFound().build(); + } + try { + specialtyProfileRepository.delete(profile); + return ResponseEntity.ok(Map.of("message", "Профиль обучения удалён")); + } catch (Exception e) { + logger.error("Ошибка при удалении профиля обучения с ID - {}: {}", profileId, e.getMessage(), e); + return ResponseEntity.badRequest() + .body(Map.of("message", "Нельзя удалить профиль, который используется группами или календарными графиками")); + } + } + + private String validateProfile(Long specialtyId, Long profileId, SpecialtyProfileDto request) { + if (request == null) { + return "Передайте данные профиля"; + } + if (request.name() == null || request.name().isBlank()) { + return "Название профиля обязательно"; + } + return specialtyProfileRepository.findBySpecialityIdAndName(specialtyId, request.name().trim()) + .filter(existing -> !existing.getId().equals(profileId)) + .map(existing -> "Профиль с таким названием уже существует") + .orElse(null); + } + + private SpecialtyProfileDto toProfileDto(SpecialtyProfile profile) { + Speciality speciality = profile.getSpeciality(); + return new SpecialtyProfileDto( + profile.getId(), + speciality.getId(), + speciality.getSpecialityCode(), + speciality.getSpecialityName(), + profile.getName(), + profile.getDescription() + ); + } + + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java new file mode 100644 index 0000000..d5a1fc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarActivityTypeDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record AcademicCalendarActivityTypeDto( + Long id, + String code, + String name, + Boolean allowSchedule, + String colorCode, + Integer displayOrder, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java new file mode 100644 index 0000000..2f0dd96 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarDto.java @@ -0,0 +1,21 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarDto( + Long id, + String title, + Long academicYearId, + String academicYearTitle, + LocalDate academicYearStartDate, + LocalDate academicYearEndDate, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName, + Long studyFormId, + String studyFormName, + Integer courseCount +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java new file mode 100644 index 0000000..29e4509 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarGridDayDto.java @@ -0,0 +1,17 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record AcademicCalendarGridDayDto( + Long id, + Long calendarId, + Integer courseNumber, + LocalDate date, + Integer weekNumber, + Integer dayOfWeek, + Long activityTypeId, + String activityCode, + String activityName, + Boolean allowSchedule +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java deleted file mode 100644 index 6fad2dd..0000000 --- a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.magistr.app.dto; - -import com.magistr.app.model.AcademicActivityType; - -public record AcademicCalendarMatrixDto( - Long id, - Long semesterId, - Integer courseNumber, - Long specialtyId, - Integer weekNumber, - AcademicActivityType activityType -) { -} diff --git a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java new file mode 100644 index 0000000..7880b76 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java @@ -0,0 +1,8 @@ +package com.magistr.app.dto; + +public record CalendarStudyFormDto( + Long id, + String name, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index 107482f..8f058d3 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -7,6 +7,8 @@ public class CreateGroupRequest { private Long educationFormId; private Long departmentId; private Integer yearStartStudy; + private Long specialtyId; + private Long specialtyProfileId; private Long specialityCode; public String getName() { @@ -56,4 +58,24 @@ public class CreateGroupRequest { public void setSpecialityCode(Long specialityCode) { this.specialityCode = specialityCode; } + + public Long getSpecialtyId() { + return specialtyId; + } + + public void setSpecialtyId(Long specialtyId) { + this.specialtyId = specialtyId; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public void setSpecialtyProfileId(Long specialtyProfileId) { + this.specialtyProfileId = specialtyProfileId; + } + + public Long getEffectiveSpecialtyId() { + return specialtyId != null ? specialtyId : specialityCode; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java new file mode 100644 index 0000000..a949840 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/GroupCalendarAssignmentDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +public record GroupCalendarAssignmentDto( + Long id, + Long groupId, + String groupName, + Long academicYearId, + String academicYearTitle, + Long calendarId, + String calendarTitle +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 8e6f174..4807f35 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -14,21 +14,26 @@ public class GroupResponse { private Integer yearStartStudy; private Integer course; private Integer semester; - private Long specialityCode; + private Long specialtyId; + private String specialtyCode; + private String specialtyName; + private Long specialtyProfileId; + private String specialtyProfileName; - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Integer semester, Long specialityCode) { - this.id = id; - this.name = name; - this.groupSize = groupSize; - this.educationFormId = educationFormId; - this.educationFormName = educationFormName; - this.departmentId = departmentId; - this.course = course; - this.semester = semester; - this.specialityCode = specialityCode; - } - - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer yearStartStudy, Long specialityCode) { + public GroupResponse(Long id, + String name, + Long groupSize, + Long educationFormId, + String educationFormName, + Long departmentId, + Integer yearStartStudy, + Integer course, + Integer semester, + Long specialtyId, + String specialtyCode, + String specialtyName, + Long specialtyProfileId, + String specialtyProfileName) { this.id = id; this.name = name; this.groupSize = groupSize; @@ -36,7 +41,13 @@ public class GroupResponse { this.educationFormName = educationFormName; this.departmentId = departmentId; this.yearStartStudy = yearStartStudy; - this.specialityCode = specialityCode; + this.course = course; + this.semester = semester; + this.specialtyId = specialtyId; + this.specialtyCode = specialtyCode; + this.specialtyName = specialtyName; + this.specialtyProfileId = specialtyProfileId; + this.specialtyProfileName = specialtyProfileName; } public Long getId() { @@ -76,6 +87,26 @@ public class GroupResponse { } public Long getSpecialityCode() { - return specialityCode; + return specialtyId; + } + + public Long getSpecialtyId() { + return specialtyId; + } + + public String getSpecialtyCode() { + return specialtyCode; + } + + public String getSpecialtyName() { + return specialtyName; + } + + public Long getSpecialtyProfileId() { + return specialtyProfileId; + } + + public String getSpecialtyProfileName() { + return specialtyProfileName; } } diff --git a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java deleted file mode 100644 index 8c0bc19..0000000 --- a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.magistr.app.dto; - -import java.time.LocalDate; - -public record HolidayDto( - Long id, - LocalDate date, - Long academicYearId, - String academicYearTitle, - String description -) { -} diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 59a5f25..04db846 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -1,6 +1,5 @@ package com.magistr.app.dto; -import com.magistr.app.model.AcademicActivityType; import com.magistr.app.model.ScheduleParity; import java.time.LocalDate; @@ -31,7 +30,7 @@ public record RenderedLessonDto( Long subgroupId, List groupIds, List groupNames, - AcademicActivityType activityType, + String activityType, Integer totalAcademicHours, Integer consumedAcademicHoursBeforeLesson, Integer remainingAcademicHoursAfterLesson diff --git a/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java new file mode 100644 index 0000000..2452621 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SpecialtyProfileDto.java @@ -0,0 +1,11 @@ +package com.magistr.app.dto; + +public record SpecialtyProfileDto( + Long id, + Long specialtyId, + String specialtyCode, + String specialtyName, + String name, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java deleted file mode 100644 index 2b4cfb2..0000000 --- a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.magistr.app.model; - -public enum AcademicActivityType { - THEORY, - EXAM, - VACATION, - PRACTICE -} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java new file mode 100644 index 0000000..9d5894d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendars") +public class AcademicCalendar { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; + + @ManyToOne(optional = false) + @JoinColumn(name = "study_form_id", nullable = false) + private CalendarStudyForm studyForm; + + @Column(name = "course_count", nullable = false) + private Integer courseCount; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; + } + + public CalendarStudyForm getStudyForm() { + return studyForm; + } + + public void setStudyForm(CalendarStudyForm studyForm) { + this.studyForm = studyForm; + } + + public Integer getCourseCount() { + return courseCount; + } + + public void setCourseCount(Integer courseCount) { + this.courseCount = courseCount; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java new file mode 100644 index 0000000..826eccd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarActivityType.java @@ -0,0 +1,86 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendar_activity_types") +public class AcademicCalendarActivityType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 10) + private String code; + + @Column(nullable = false) + private String name; + + @Column(name = "allow_schedule", nullable = false) + private Boolean allowSchedule; + + @Column(name = "color_code", nullable = false, length = 7) + private String colorCode; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Boolean getAllowSchedule() { + return allowSchedule; + } + + public void setAllowSchedule(Boolean allowSchedule) { + this.allowSchedule = allowSchedule; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public Integer getDisplayOrder() { + return displayOrder; + } + + public void setDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java new file mode 100644 index 0000000..b256ccb --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java @@ -0,0 +1,90 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_calendar_days") +public class AcademicCalendarDay { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + @Column(name = "course_number", nullable = false) + private Integer courseNumber; + + @Column(nullable = false) + private LocalDate date; + + @Column(name = "week_number", nullable = false) + private Integer weekNumber; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @ManyToOne(optional = false) + @JoinColumn(name = "activity_type_id", nullable = false) + private AcademicCalendarActivityType activityType; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } + + public Integer getCourseNumber() { + return courseNumber; + } + + public void setCourseNumber(Integer courseNumber) { + this.courseNumber = courseNumber; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public AcademicCalendarActivityType getActivityType() { + return activityType; + } + + public void setActivityType(AcademicCalendarActivityType activityType) { + this.activityType = activityType; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java deleted file mode 100644 index 1d92917..0000000 --- a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "academic_calendar_matrix") -public class AcademicCalendarMatrix { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(optional = false) - @JoinColumn(name = "semester_id", nullable = false) - private Semester semester; - - @Column(name = "course_number", nullable = false) - private Integer courseNumber; - - @Column(name = "specialty_id", nullable = false) - private Long specialtyId; - - @Column(name = "week_number", nullable = false) - private Integer weekNumber; - - @Enumerated(EnumType.STRING) - @Column(name = "activity_type", nullable = false, length = 20) - private AcademicActivityType activityType; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Semester getSemester() { - return semester; - } - - public void setSemester(Semester semester) { - this.semester = semester; - } - - public Integer getCourseNumber() { - return courseNumber; - } - - public void setCourseNumber(Integer courseNumber) { - this.courseNumber = courseNumber; - } - - public Long getSpecialtyId() { - return specialtyId; - } - - public void setSpecialtyId(Long specialtyId) { - this.specialtyId = specialtyId; - } - - public Integer getWeekNumber() { - return weekNumber; - } - - public void setWeekNumber(Integer weekNumber) { - this.weekNumber = weekNumber; - } - - public AcademicActivityType getActivityType() { - return activityType; - } - - public void setActivityType(AcademicActivityType activityType) { - this.activityType = activityType; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java new file mode 100644 index 0000000..49ea74f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java @@ -0,0 +1,42 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "calendar_study_forms") +public class CalendarStudyForm { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Holiday.java b/backend/src/main/java/com/magistr/app/model/Holiday.java deleted file mode 100644 index c01ca07..0000000 --- a/backend/src/main/java/com/magistr/app/model/Holiday.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -import java.time.LocalDate; - -@Entity -@Table(name = "holidays") -public class Holiday { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private LocalDate date; - - @ManyToOne(optional = false) - @JoinColumn(name = "academic_year_id", nullable = false) - private AcademicYear academicYear; - - @Column(length = 255) - private String description; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public LocalDate getDate() { - return date; - } - - public void setDate(LocalDate date) { - this.date = date; - } - - public AcademicYear getAcademicYear() { - return academicYear; - } - - public void setAcademicYear(AcademicYear academicYear) { - this.academicYear = academicYear; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } -} diff --git a/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java new file mode 100644 index 0000000..4c6ae37 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/SpecialtyProfile.java @@ -0,0 +1,54 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "specialty_profiles") +public class SpecialtyProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @Column(nullable = false, length = 500) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Speciality getSpeciality() { + return speciality; + } + + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index c0f78fd..fa42974 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -10,7 +10,7 @@ public class StudentGroup { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(unique = true, nullable = false, length = 100) + @Column(nullable = false, length = 100) private String name; @Column(name = "group_size", nullable = false) @@ -23,8 +23,13 @@ public class StudentGroup { @Column(name = "department_id", nullable = false) private Long departmentId; - @Column(name="specialty_code", nullable = false) - private Long specialityCode; + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_id", nullable = false) + private Speciality speciality; + + @ManyToOne(optional = false) + @JoinColumn(name = "specialty_profile_id", nullable = false) + private SpecialtyProfile specialtyProfile; @Column(name="year_start_study", nullable = false) private Integer yearStartStudy; @@ -72,12 +77,20 @@ public class StudentGroup { this.departmentId = departmentId; } - public Long getSpecialityCode() { - return specialityCode; + public Speciality getSpeciality() { + return speciality; } - public void setSpecialityCode(Long specialityCode) { - this.specialityCode = specialityCode; + public void setSpeciality(Speciality speciality) { + this.speciality = speciality; + } + + public SpecialtyProfile getSpecialtyProfile() { + return specialtyProfile; + } + + public void setSpecialtyProfile(SpecialtyProfile specialtyProfile) { + this.specialtyProfile = specialtyProfile; } public Integer getYearStartStudy() { diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java new file mode 100644 index 0000000..8076ce1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/StudentGroupCalendarAssignment.java @@ -0,0 +1,56 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "student_group_calendar_assignments") +public class StudentGroupCalendarAssignment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "group_id", nullable = false) + private StudentGroup studentGroup; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @ManyToOne(optional = false) + @JoinColumn(name = "calendar_id", nullable = false) + private AcademicCalendar academicCalendar; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public StudentGroup getStudentGroup() { + return studentGroup; + } + + public void setStudentGroup(StudentGroup studentGroup) { + this.studentGroup = studentGroup; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public AcademicCalendar getAcademicCalendar() { + return academicCalendar; + } + + public void setAcademicCalendar(AcademicCalendar academicCalendar) { + this.academicCalendar = academicCalendar; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java new file mode 100644 index 0000000..f4a2d8c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarActivityTypeRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarActivityType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarActivityTypeRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAscCodeAsc(); + + Optional findByCode(String code); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java new file mode 100644 index 0000000..761e1be --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java @@ -0,0 +1,43 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarDay; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarDayRepository extends JpaRepository { + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + order by day.courseNumber asc, day.date asc + """) + List findByCalendarIdWithActivity(@Param("calendarId") Long calendarId); + + @Query(""" + select day + from AcademicCalendarDay day + join fetch day.activityType + where day.academicCalendar.id = :calendarId + and day.courseNumber = :courseNumber + and day.date = :date + """) + Optional findByCalendarIdAndCourseNumberAndDate( + @Param("calendarId") Long calendarId, + @Param("courseNumber") Integer courseNumber, + @Param("date") LocalDate date + ); + + boolean existsByActivityTypeId(Long activityTypeId); + + @Modifying + @Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId") + void deleteByCalendarId(@Param("calendarId") Long calendarId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java deleted file mode 100644 index 70964d6..0000000 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.AcademicCalendarMatrix; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface AcademicCalendarMatrixRepository extends JpaRepository { - - Optional findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - Long semesterId, - Integer courseNumber, - Long specialtyId, - Integer weekNumber - ); - - List findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc( - Long semesterId, - Integer courseNumber, - Long specialtyId - ); -} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java new file mode 100644 index 0000000..5d2257e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -0,0 +1,41 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarRepository extends JpaRepository { + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where (:academicYearId is null or calendar.academicYear.id = :academicYearId) + and (:specialtyId is null or calendar.speciality.id = :specialtyId) + and (:profileId is null or calendar.specialtyProfile.id = :profileId) + order by calendar.academicYear.startDate desc, calendar.title asc + """) + List findAllWithDetails( + @Param("academicYearId") Long academicYearId, + @Param("specialtyId") Long specialtyId, + @Param("profileId") Long profileId + ); + + @Query(""" + select calendar + from AcademicCalendar calendar + join fetch calendar.academicYear + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where calendar.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java new file mode 100644 index 0000000..7c7eb25 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.CalendarStudyForm; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CalendarStudyFormRepository extends JpaRepository { + + List findAllByOrderByNameAsc(); +} diff --git a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java index 7ab7312..520f8e7 100755 --- a/backend/src/main/java/com/magistr/app/repository/GroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/GroupRepository.java @@ -4,12 +4,9 @@ import com.magistr.app.model.StudentGroup; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; public interface GroupRepository extends JpaRepository { - Optional findByName(String name); - List findByEducationFormId(Long educationFormId); List findByDepartmentId(Long departmentId); diff --git a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java deleted file mode 100644 index 43ec732..0000000 --- a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.Holiday; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDate; -import java.util.List; - -public interface HolidayRepository extends JpaRepository { - - List findByAcademicYearIdOrderByDateAsc(Long academicYearId); - - List findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate); - - boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date); -} diff --git a/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java new file mode 100644 index 0000000..931adc3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SpecialtyProfileRepository.java @@ -0,0 +1,14 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.SpecialtyProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SpecialtyProfileRepository extends JpaRepository { + + List findBySpecialityIdOrderByNameAsc(Long specialityId); + + Optional findBySpecialityIdAndName(Long specialityId, String name); +} diff --git a/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java new file mode 100644 index 0000000..446c99d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/StudentGroupCalendarAssignmentRepository.java @@ -0,0 +1,54 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.StudentGroupCalendarAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface StudentGroupCalendarAssignmentRepository extends JpaRepository { + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.studentGroup + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + order by assignment.academicYear.startDate desc + """) + List findByGroupIdWithDetails(@Param("groupId") Long groupId); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicYear + join fetch assignment.academicCalendar calendar + join fetch calendar.speciality + join fetch calendar.specialtyProfile + join fetch calendar.studyForm + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findByGroupIdAndAcademicYearIdWithDetails( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); + + @Query(""" + select assignment + from StudentGroupCalendarAssignment assignment + join fetch assignment.academicCalendar + where assignment.studentGroup.id = :groupId + and assignment.academicYear.id = :academicYearId + """) + Optional findForSchedule( + @Param("groupId") Long groupId, + @Param("academicYearId") Long academicYearId + ); +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java index 0a2248d..8180415 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -1,9 +1,9 @@ package com.magistr.app.service; import com.magistr.app.model.*; -import com.magistr.app.repository.AcademicCalendarMatrixRepository; -import com.magistr.app.repository.HolidayRepository; +import com.magistr.app.repository.AcademicCalendarDayRepository; import com.magistr.app.repository.SemesterRepository; +import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -15,17 +15,17 @@ import java.util.Optional; public class AcademicDateService { private final SemesterRepository semesterRepository; - private final HolidayRepository holidayRepository; - private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final AcademicCalendarDayRepository calendarDayRepository; + private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final boolean evenWeekIsUpper; public AcademicDateService(SemesterRepository semesterRepository, - HolidayRepository holidayRepository, - AcademicCalendarMatrixRepository calendarMatrixRepository, + AcademicCalendarDayRepository calendarDayRepository, + StudentGroupCalendarAssignmentRepository assignmentRepository, @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { this.semesterRepository = semesterRepository; - this.holidayRepository = holidayRepository; - this.calendarMatrixRepository = calendarMatrixRepository; + this.calendarDayRepository = calendarDayRepository; + this.assignmentRepository = assignmentRepository; this.evenWeekIsUpper = evenWeekIsUpper; } @@ -50,38 +50,40 @@ public class AcademicDateService { return evenWeek ? ScheduleParity.EVEN : ScheduleParity.ODD; } - public boolean isHoliday(Semester semester, LocalDate date) { - if (semester == null || semester.getAcademicYear() == null) { - return false; - } - return holidayRepository.existsByAcademicYearIdAndDate(semester.getAcademicYear().getId(), date); - } - public int getCourseNumber(StudentGroup group, LocalDate date) { int academicYearStart = date.getMonthValue() >= 9 ? date.getYear() : date.getYear() - 1; return academicYearStart - group.getYearStartStudy() + 1; } - public AcademicActivityType getActivityType(StudentGroup group, Semester semester, LocalDate date) { - int weekNumber = getWeekNumber(semester, date); - int courseNumber = getCourseNumber(group, date); - if (weekNumber < 1 || courseNumber < 1) { - return AcademicActivityType.VACATION; + public Optional getActivityType(StudentGroup group, Semester semester, LocalDate date) { + if (group == null || semester == null || semester.getAcademicYear() == null || date == null) { + return Optional.empty(); } - return calendarMatrixRepository - .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( - semester.getId(), + int courseNumber = getCourseNumber(group, date); + if (courseNumber < 1) { + return Optional.empty(); + } + + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()) + .flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate( + assignment.getAcademicCalendar().getId(), courseNumber, - group.getSpecialityCode(), - weekNumber - ) - .map(AcademicCalendarMatrix::getActivityType) - .orElse(AcademicActivityType.THEORY); + date + )) + .map(AcademicCalendarDay::getActivityType); } public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { - return getActivityType(group, semester, date) == AcademicActivityType.THEORY - && !isHoliday(semester, date); + return getActivityType(group, semester, date) + .map(AcademicCalendarActivityType::getAllowSchedule) + .orElse(false); + } + + public boolean hasCalendarAssignment(StudentGroup group, Semester semester) { + if (group == null || semester == null || semester.getAcademicYear() == null) { + return false; + } + return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()).isPresent(); } } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index a7554cc..4574678 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -5,6 +5,8 @@ import com.magistr.app.model.*; import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.ScheduleRuleRepository; import com.magistr.app.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.time.DayOfWeek; @@ -17,6 +19,7 @@ import java.util.stream.Collectors; @Service public class ScheduleGeneratorService { + private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class); private static final int ACADEMIC_HOURS_PER_SLOT = 2; private static final long MAX_RANGE_DAYS = 120; @@ -43,6 +46,7 @@ public class ScheduleGeneratorService { Map> rulesBySemester = new HashMap<>(); List result = new ArrayList<>(); + Set warnedAcademicYears = new HashSet<>(); for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { Optional semesterOpt = academicDateService.findSemester(date); @@ -51,6 +55,11 @@ public class ScheduleGeneratorService { } Semester semester = semesterOpt.get(); + if (!academicDateService.hasCalendarAssignment(group, semester) + && warnedAcademicYears.add(semester.getAcademicYear().getId())) { + logger.info("Для группы '{}' не назначен календарный учебный график на учебный год {}", + group.getName(), semester.getAcademicYear().getTitle()); + } if (!academicDateService.isTheoryDay(group, semester, date)) { continue; } @@ -148,10 +157,6 @@ public class ScheduleGeneratorService { } Semester semester = semesterOpt.get(); - if (academicDateService.isHoliday(semester, date)) { - return; - } - List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); if (eligibleGroups.isEmpty()) { return; @@ -216,9 +221,6 @@ public class ScheduleGeneratorService { if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { continue; } - if (academicDateService.isHoliday(semester, date)) { - continue; - } if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { continue; } @@ -274,7 +276,7 @@ public class ScheduleGeneratorService { slot.getSubgroupId(), sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), - AcademicActivityType.THEORY, + "Т", rule.getTotalAcademicHours(), consumedBeforeLesson, remainingAfterLesson diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index de034a4..4c08017 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -20,13 +20,28 @@ INSERT INTO departments (name, code) VALUES CREATE TABLE IF NOT EXISTS specialties ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, - specialty_code VARCHAR(255) NOT NULL + specialty_code VARCHAR(255) UNIQUE NOT NULL ); INSERT INTO specialties (name, specialty_code) VALUES ('Информационная безопасность', '10.03.01'), ('Информатика и вычислительная техника', '09.03.01'), - ('Программная инженерия', '09.03.04'); + ('Программная инженерия', '09.03.04') +ON CONFLICT (specialty_code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS specialty_profiles ( + id BIGSERIAL PRIMARY KEY, + specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, + name VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) +); + +INSERT INTO specialty_profiles (specialty_id, name) +SELECT id, 'Без профиля' +FROM specialties +ON CONFLICT (specialty_id, name) DO NOTHING; -- ========================================== -- Пользователи и роли @@ -70,20 +85,28 @@ ON CONFLICT (name) DO NOTHING; -- ========================================== CREATE TABLE IF NOT EXISTS student_groups ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, group_size BIGINT NOT NULL, education_form_id BIGINT NOT NULL REFERENCES education_forms(id), department_id BIGINT NOT NULL REFERENCES departments(id), - specialty_code INT NOT NULL REFERENCES specialties(id), + specialty_id BIGINT NOT NULL REFERENCES specialties(id), + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), year_start_study BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Тестовая базовая группа для работы -INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_code, year_start_study) -VALUES ('ИВТ-21-1', 25, 1, 1, 2, 2025), - ('ИБ-41м', 15, 2, 1, 1, 2024) -ON CONFLICT (name) DO NOTHING; +INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study) +SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study +FROM (VALUES + ('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT), + ('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT) +) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study) +JOIN education_forms ef ON ef.name = data.education_form_name +JOIN departments dept ON dept.name = data.department_name +JOIN specialties specialty ON specialty.specialty_code = data.specialty_code +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name); -- ========================================== -- Подгруппы (например: "ИВТ-21-1 Подгруппа 1") @@ -272,45 +295,131 @@ ON CONFLICT (academic_year_id, semester_type) DO NOTHING; CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); -CREATE TABLE IF NOT EXISTS holidays ( +CREATE TABLE IF NOT EXISTS calendar_study_forms ( id BIGSERIAL PRIMARY KEY, - date DATE NOT NULL, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO calendar_study_forms (name, description) VALUES + ('очная', 'Очная форма обучения') +ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(10) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + allow_schedule BOOLEAN NOT NULL DEFAULT FALSE, + color_code VARCHAR(7) NOT NULL DEFAULT '#64748b', + display_order INT NOT NULL DEFAULT 100, + description TEXT +); + +INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES + ('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'), + ('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'), + ('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'), + ('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'), + ('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'), + ('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'), + ('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'), + ('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'), + ('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'), + ('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'), + ('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'), + ('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'), + ('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания') +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendars ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, - description VARCHAR(255), - CONSTRAINT uq_holidays_year_date UNIQUE (academic_year_id, date) -); - -CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date); - -CREATE TABLE IF NOT EXISTS academic_calendar_matrix ( - id BIGSERIAL PRIMARY KEY, - semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, - course_number INT NOT NULL, specialty_id BIGINT NOT NULL REFERENCES specialties(id), - week_number INT NOT NULL, - activity_type VARCHAR(20) NOT NULL, - CONSTRAINT chk_calendar_course_positive CHECK (course_number > 0), - CONSTRAINT chk_calendar_week_positive CHECK (week_number > 0), - CONSTRAINT chk_calendar_activity_type CHECK (activity_type IN ('THEORY', 'EXAM', 'VACATION', 'PRACTICE')), - CONSTRAINT uq_calendar_matrix UNIQUE (semester_id, course_number, specialty_id, week_number) + specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), + study_form_id BIGINT NOT NULL REFERENCES calendar_study_forms(id), + course_count INT NOT NULL DEFAULT 4, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0), + CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title) ); -CREATE INDEX IF NOT EXISTS idx_calendar_lookup - ON academic_calendar_matrix(semester_id, course_number, specialty_id, week_number); +CREATE INDEX IF NOT EXISTS idx_academic_calendars_context + ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id); -INSERT INTO academic_calendar_matrix (semester_id, course_number, specialty_id, week_number, activity_type) +INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count) SELECT - s.id, - SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 AS course_number, - sg.specialty_code AS specialty_id, - weeks.week_number, - 'THEORY' -FROM semesters s -JOIN academic_years ay ON ay.id = s.academic_year_id -CROSS JOIN student_groups sg -CROSS JOIN GENERATE_SERIES(1, 22) AS weeks(week_number) -WHERE SPLIT_PART(ay.title, '-', 1)::INT - sg.year_start_study + 1 > 0 -ON CONFLICT (semester_id, course_number, specialty_id, week_number) DO NOTHING; + specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title, + ay.id, + specialty.id, + profile.id, + study_form.id, + 4 +FROM academic_years ay +CROSS JOIN calendar_study_forms study_form +JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01') +JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' +WHERE ay.title = '2025-2026' +ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_calendar_days ( + id BIGSERIAL PRIMARY KEY, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + course_number INT NOT NULL, + date DATE NOT NULL, + week_number INT NOT NULL, + day_of_week INT NOT NULL, + activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id), + CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0), + CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0), + CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7), + CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup + ON academic_calendar_days(calendar_id, course_number, date); + +INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id) +SELECT + calendar.id, + course.course_number, + days.date::DATE, + ((days.date::DATE - ay.start_date) / 7) + 1, + EXTRACT(ISODOW FROM days.date::DATE)::INT, + activity.id +FROM academic_calendars calendar +JOIN academic_years ay ON ay.id = calendar.academic_year_id +JOIN academic_calendar_activity_types activity ON activity.code = 'Т' +CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number) +CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) +ON CONFLICT (calendar_id, course_number, date) DO NOTHING; + +CREATE TABLE IF NOT EXISTS student_group_calendar_assignments ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_group_calendar_year UNIQUE (group_id, academic_year_id) +); + +CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup + ON student_group_calendar_assignments(group_id, academic_year_id); + +INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id) +SELECT + sg.id, + ay.id, + calendar.id +FROM student_groups sg +JOIN academic_years ay ON ay.title = '2025-2026' +JOIN academic_calendars calendar + ON calendar.academic_year_id = ay.id + AND calendar.specialty_profile_id = sg.specialty_profile_id +WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м') +ON CONFLICT (group_id, academic_year_id) DO NOTHING; CREATE TABLE IF NOT EXISTS schedule_rules ( id BIGSERIAL PRIMARY KEY, @@ -445,17 +554,26 @@ CREATE TRIGGER update_users_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_academic_calendars_updated_at + BEFORE UPDATE ON academic_calendars + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- ========================================== -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; +COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности'; COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; -COMMENT ON TABLE holidays IS 'Праздники и неучебные даты'; -COMMENT ON TABLE academic_calendar_matrix IS 'Матрица учебного графика по курсу, специальности и неделе'; +COMMENT ON TABLE calendar_study_forms IS 'Формы обучения календарного учебного графика'; +COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; +COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; +COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; +COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; @@ -489,7 +607,8 @@ COMMENT ON COLUMN student_groups.name IS 'Название группы'; COMMENT ON COLUMN student_groups.group_size IS 'Количество студентов'; COMMENT ON COLUMN student_groups.education_form_id IS 'ID формы обучения, к которой относится группа'; COMMENT ON COLUMN student_groups.department_id IS 'ID кафедры'; -COMMENT ON COLUMN student_groups.specialty_code IS 'ID специальности'; +COMMENT ON COLUMN student_groups.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN student_groups.specialty_profile_id IS 'ID профиля обучения'; COMMENT ON COLUMN student_groups.year_start_study IS 'Год начала обучения'; COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; @@ -526,7 +645,7 @@ COMMENT ON COLUMN classrooms.created_at IS 'Дата и время создан COMMENT ON COLUMN classroom_equipments.classroom_id IS 'ID аудитории'; COMMENT ON COLUMN classroom_equipments.equipment_id IS 'ID оборудования'; -COMMENT ON COLUMN classroom_equipments.quantity IS 'Дата и время создания'; -- Так было в V2 +COMMENT ON COLUMN classroom_equipments.quantity IS 'Количество единиц оборудования'; COMMENT ON COLUMN classroom_equipments.notes IS 'Примечания к записи'; COMMENT ON COLUMN teacher_subjects.user_id IS 'ID преподавателя'; @@ -542,6 +661,37 @@ COMMENT ON COLUMN specialties.id IS 'ID специальности'; COMMENT ON COLUMN specialties.name IS 'Название специальности'; COMMENT ON COLUMN specialties.specialty_code IS 'Код специальности'; +COMMENT ON COLUMN specialty_profiles.id IS 'ID профиля обучения'; +COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения'; +COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения'; + +COMMENT ON COLUMN calendar_study_forms.id IS 'ID формы обучения графика'; +COMMENT ON COLUMN calendar_study_forms.name IS 'Название формы обучения графика'; +COMMENT ON COLUMN calendar_study_forms.description IS 'Описание формы обучения графика'; + +COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика'; +COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности'; +COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания'; + +COMMENT ON COLUMN academic_calendars.title IS 'Название календарного учебного графика'; +COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности'; +COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения'; +COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения графика'; +COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; + +COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; +COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса'; +COMMENT ON COLUMN academic_calendar_days.date IS 'Дата'; +COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году'; +COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; +COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; + +COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы'; +COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года'; +COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика'; + COMMENT ON COLUMN teacher_lesson_types.user_id IS 'ID преподавателя'; COMMENT ON COLUMN teacher_lesson_types.subject_id IS 'ID предмета'; COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md new file mode 100644 index 0000000..e8f86fa --- /dev/null +++ b/docs/ACADEMIC_CALENDAR.md @@ -0,0 +1,76 @@ +# Календарный учебный график + +## Назначение + +Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения. + +График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели. + +## Связи данных + +Основная цепочка выглядит так: + +```text +специальность -> профиль обучения -> календарный учебный график -> назначение группе +``` + +- `specialties` хранит код и название специальности, например `09.03.04`. +- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. +- `academic_calendars` хранит график профиля, формы обучения и учебного года. +- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. +- `student_groups` связана со специальностью и профилем обучения. +- `student_group_calendar_assignments` назначает группе конкретный график на учебный год. + +Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. + +## Коды активностей + +| Код | Значение | Обычные пары | +|-----|----------|--------------| +| `Т` | Теоретическое обучение | Да | +| `Э` | Экзаменационная сессия | Нет | +| `К` | Каникулы | Нет | +| `У` | Учебная практика | Нет | +| `П` | Производственная практика | Нет | +| `Пд` | Преддипломная практика | Нет | +| `Н` | Научно-исследовательская работа | Нет | +| `Г` | Государственная итоговая аттестация | Нет | +| `Д` | Подготовка выпускной квалификационной работы | Нет | +| `ПА` | Промежуточная аттестация | Нет | +| `С` | Самостоятельная работа или специальный период | Нет | +| `*` | Праздничный или нерабочий день | Нет | +| `=` | День вне учебного года или пустая ячейка графика | Нет | + +Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`. В seed-данных `true` установлен только для `Т`. + +## Правило генерации расписания + +`ScheduleGeneratorService` при запросе `GET /api/schedule` проверяет календарный график до вывода занятия: + +1. Определяется учебный год и семестр даты. +2. Для группы находится назначение графика на этот учебный год. +3. По году начала обучения группы вычисляется текущий курс. +4. В `academic_calendar_days` ищется ячейка для курса и даты. +5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`. +6. Если код не разрешает пары, день пропускается и академические часы не списываются. + +Если график группе не назначен, расписание группы возвращается пустым списком без ошибки. Это сделано намеренно: отсутствие календаря означает, что нет разрешённых дат для генерации. + +## Работа администратора + +1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. +2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. +3. В разделе расписания администратор создаёт учебный год и семестры. +4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения и количество курсов. +5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. +6. В разделе групп администратор назначает календарный график группе на нужный учебный год. + +После сохранения графика или назначения группы кэш динамического расписания сбрасывается. Следующий запрос `GET /api/schedule` сразу использует новые правила календаря. + +## Особенности модели + +- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. +- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. +- График привязан к профилю обучения, а не только к коду специальности. +- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. +- Excel-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт. diff --git a/docs/API.md b/docs/API.md index a7f633b..cfad4e2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -116,7 +116,7 @@ | `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | | `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | -Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. Если у группы нет назначения календарного графика на учебный год даты, расписание для неё возвращается пустым списком. **Пример:** ```http @@ -150,7 +150,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "subgroupId": null, "groupIds": [1], "groupNames": ["ИВТ-21-1"], - "activityType": "THEORY", + "activityType": "Т", "totalAcademicHours": 72, "consumedAcademicHoursBeforeLesson": 24, "remainingAcademicHoursAfterLesson": 46 @@ -179,7 +179,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` -### Календарный график администратора +### Учебные годы, семестры и коды календарного графика | Метод | URL | Назначение | |-------|-----|------------| @@ -190,12 +190,59 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | `GET` | `/api/admin/calendar/years/{academicYearId}/semesters` | Семестры учебного года | | `POST` | `/api/admin/calendar/years/{academicYearId}/semesters` | Создать семестр | | `PUT` | `/api/admin/calendar/semesters/{id}` | Обновить семестр | -| `GET` | `/api/admin/calendar/holidays?academicYearId=1` | Праздники учебного года | -| `POST` | `/api/admin/calendar/holidays` | Создать праздник | -| `PUT` | `/api/admin/calendar/holidays/{id}` | Обновить праздник | -| `DELETE` | `/api/admin/calendar/holidays/{id}` | Удалить праздник | -| `GET` | `/api/admin/calendar/matrix?semesterId=1&courseNumber=1&specialtyId=2` | Матрица учебного графика | -| `PUT` | `/api/admin/calendar/matrix` | Массовое сохранение матрицы | +| `GET` | `/api/admin/calendar/activity-types` | Справочник кодов активностей графика | +| `POST` | `/api/admin/calendar/activity-types` | Создать код активности | +| `PUT` | `/api/admin/calendar/activity-types/{id}` | Обновить код активности | +| `DELETE` | `/api/admin/calendar/activity-types/{id}` | Удалить код активности | + +**Тело создания/обновления кода активности:** +```json +{ + "code": "Т", + "name": "Теоретическое обучение", + "allowSchedule": true, + "colorCode": "#22c55e", + "displayOrder": 10, + "description": "Обычные пары разрешены" +} +``` + +### Календарные учебные графики + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | +| `GET` | `/api/admin/academic-calendars/study-forms` | Формы обучения графика | +| `GET` | `/api/admin/academic-calendars/{id}` | Один график | +| `POST` | `/api/admin/academic-calendars` | Создать график | +| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | +| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | +| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | +| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | + +**Тело создания/обновления графика:** +```json +{ + "title": "09.03.04 очная форма 2025-2026", + "academicYearId": 1, + "specialtyId": 2, + "specialtyProfileId": 3, + "studyFormId": 1, + "courseCount": 4 +} +``` + +**Ячейка сетки графика:** +```json +{ + "calendarId": 1, + "courseNumber": 1, + "date": "2025-09-01", + "weekNumber": 1, + "dayOfWeek": 1, + "activityTypeId": 1 +} +``` ### `POST /api/admin/schedule-rules` @@ -274,6 +321,10 @@ CRUD доступен по: | `POST` | `/api/specialties` | Создать специальность | | `PUT` | `/api/specialties/{id}` | Обновить специальность | | `DELETE` | `/api/specialties/{id}` | Удалить специальность | +| `GET` | `/api/specialties/{id}/profiles` | Профили выбранной специальности | +| `POST` | `/api/specialties/{id}/profiles` | Создать профиль | +| `PUT` | `/api/specialties/{id}/profiles/{profileId}` | Обновить профиль | +| `DELETE` | `/api/specialties/{id}/profiles/{profileId}` | Удалить профиль | **Тело создания/обновления:** ```json @@ -283,6 +334,16 @@ CRUD доступен по: } ``` +При создании специальности автоматически создаётся профиль `Без профиля`. + +**Тело создания/обновления профиля:** +```json +{ + "name": "Безопасность автоматизированных систем", + "description": "Необязательное описание" +} +``` + --- ## Группы @@ -301,7 +362,14 @@ CRUD доступен по: "educationFormId": 1, "educationFormName": "Бакалавриат", "departmentId": 1, + "yearStartStudy": 2023, "course": 3, + "semester": 6, + "specialtyId": 1, + "specialtyCode": "09.03.04", + "specialtyName": "Программная инженерия", + "specialtyProfileId": 2, + "specialtyProfileName": "Без профиля", "specialityCode": 1 } ] @@ -322,16 +390,53 @@ CRUD доступен по: "educationFormId": 1, "departmentId": 1, "yearStartStudy": 2026, - "specialityCode": 1 + "specialtyId": 1, + "specialtyProfileId": 2 } ``` -`specialityCode` исторически содержит ID записи из `/api/specialties`; текущий курс вычисляется из `yearStartStudy`. +`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`. + +Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`. + +### `PUT /api/groups/{id}` + +Редактирование группы. + +```json +{ + "name": "ИВТ-11", + "groupSize": 24, + "educationFormId": 1, + "departmentId": 1, + "yearStartStudy": 2026, + "specialtyId": 1, + "specialtyProfileId": 2 +} +``` ### `DELETE /api/groups/{id}` Удаление группы. +### Календарные графики группы + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/groups/{id}/calendar-assignments` | Список назначений графиков группе | +| `PUT` | `/api/groups/{id}/calendar-assignments` | Создать или заменить назначение на учебный год | +| `DELETE` | `/api/groups/{id}/calendar-assignments/{assignmentId}` | Удалить назначение | + +**Тело назначения графика:** +```json +{ + "academicYearId": 1, + "calendarId": 5 +} +``` + +Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа. + --- ## Аудитории diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index da1e6ea..637d3b5 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -28,9 +28,10 @@ ### Специальности (Specialties) -Учебные направления с кодом по ФГОС. +Учебные направления с кодом по ФГОС. У одной специальности может быть несколько профилей обучения, например базовый профиль и профиль конкретной образовательной программы. - Примеры: «Информационная безопасность» (10.03.01), «Программная инженерия» (09.03.04) +- При создании специальности автоматически создаётся профиль `Без профиля` ### Формы обучения (Education Forms) @@ -41,9 +42,10 @@ ### Учебные группы (Student Groups) -- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения +- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) +- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график ### Аудитории (Classrooms) @@ -74,8 +76,11 @@ | Компонент | Назначение | |-----------|------------| | `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` | -| `holidays` | Даты, когда занятия не проводятся и часы не списываются | -| `academic_calendar_matrix` | Тип недели для курса и специальности: теория, сессия, каникулы, практика | +| `specialty_profiles` | Профили обучения внутри специальности | +| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | +| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `academic_calendar_days` | Дневная сетка по курсу и дате | +| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимит часов дисциплины в семестре | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | @@ -84,12 +89,14 @@ Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. 2. Вычисляет номер недели и чётность. -3. Проверяет праздники и матрицу учебного графика. -4. Загружает правила группы или преподавателя. -5. Симулирует уже проведённые занятия от `active_from_date`. -6. Останавливает вывод правила, когда достигнут `total_academic_hours`. +3. Находит назначенный группе календарный учебный график на учебный год даты. +4. Определяет код активности по курсу группы и конкретной дате. +5. Пропускает день, если код активности не разрешает обычные пары. +6. Загружает правила группы или преподавателя. +7. Симулирует уже проведённые занятия от `active_from_date`. +8. Останавливает вывод правила, когда достигнут `total_academic_hours`. -Праздник считается пропуском: занятие не переносится и не списывает академические часы. +Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. ### Временны́е слоты diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5dd0cd8..c7ab763 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -23,7 +23,14 @@ erDiagram specialties { BIGSERIAL id PK VARCHAR name - VARCHAR specialty_code + VARCHAR specialty_code UK + } + + specialty_profiles { + BIGSERIAL id PK + BIGINT specialty_id FK + VARCHAR name + TEXT description } users { @@ -47,11 +54,12 @@ erDiagram student_groups { BIGSERIAL id PK - VARCHAR name UK + VARCHAR name BIGINT group_size BIGINT education_form_id FK BIGINT department_id FK - BIGINT specialty_code FK + BIGINT specialty_id FK + BIGINT specialty_profile_id FK BIGINT year_start_study TIMESTAMP created_at } @@ -140,20 +148,48 @@ erDiagram DATE end_date } - holidays { + calendar_study_forms { BIGSERIAL id PK - DATE date - BIGINT academic_year_id FK - VARCHAR description + VARCHAR name UK + TEXT description } - academic_calendar_matrix { + academic_calendar_activity_types { BIGSERIAL id PK - BIGINT semester_id FK - INT course_number + VARCHAR code UK + VARCHAR name + BOOLEAN allow_schedule + VARCHAR color_code + INT display_order + } + + academic_calendars { + BIGSERIAL id PK + VARCHAR title + BIGINT academic_year_id FK BIGINT specialty_id FK + BIGINT specialty_profile_id FK + BIGINT study_form_id FK + INT course_count + TIMESTAMP created_at + TIMESTAMP updated_at + } + + academic_calendar_days { + BIGSERIAL id PK + BIGINT calendar_id FK + INT course_number + DATE date INT week_number - VARCHAR activity_type + INT day_of_week + BIGINT activity_type_id FK + } + + student_group_calendar_assignments { + BIGSERIAL id PK + BIGINT group_id FK + BIGINT academic_year_id FK + BIGINT calendar_id FK } schedule_rules { @@ -186,9 +222,12 @@ erDiagram departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" education_forms ||--o{ student_groups : "education_form_id" - specialties ||--o{ student_groups : "specialty_code" + specialties ||--o{ specialty_profiles : "specialty_id" + specialties ||--o{ student_groups : "specialty_id" + specialty_profiles ||--o{ student_groups : "specialty_profile_id" student_groups ||--o{ subgroups : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id" + student_groups ||--o{ student_group_calendar_assignments : "group_id" users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_lesson_types : "user_id" subjects ||--o{ teacher_subjects : "subject_id" @@ -200,10 +239,15 @@ erDiagram classrooms ||--o{ classroom_equipments : "classroom_id" equipments ||--o{ classroom_equipments : "equipment_id" academic_years ||--o{ semesters : "academic_year_id" - academic_years ||--o{ holidays : "academic_year_id" - semesters ||--o{ academic_calendar_matrix : "semester_id" + academic_years ||--o{ academic_calendars : "academic_year_id" + academic_years ||--o{ student_group_calendar_assignments : "academic_year_id" semesters ||--o{ schedule_rules : "semester_id" - specialties ||--o{ academic_calendar_matrix : "specialty_id" + specialties ||--o{ academic_calendars : "specialty_id" + specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" + calendar_study_forms ||--o{ academic_calendars : "study_form_id" + academic_calendars ||--o{ academic_calendar_days : "calendar_id" + academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" + academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id" @@ -229,7 +273,15 @@ erDiagram |---------|-----|----------| | `id` | BIGSERIAL PK | ID специальности | | `name` | VARCHAR(255) | Название специальности | -| `specialty_code` | VARCHAR(255) | Код ФГОС (напр. `10.03.01`) | +| `specialty_code` | VARCHAR(255) UNIQUE | Код ФГОС (напр. `10.03.01`) | + +#### `specialty_profiles` — Профили обучения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID профиля | +| `specialty_id` | BIGINT FK → specialties | Специальность, внутри которой существует профиль | +| `name` | VARCHAR(255) | Название профиля, уникально внутри специальности | +| `description` | TEXT | Описание профиля | ### Пользователи @@ -261,11 +313,12 @@ erDiagram | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(100) UNIQUE | Название группы (напр. `ИВТ-21-1`) | +| `name` | VARCHAR(100) | Название группы (напр. `ИВТ-21-1`), не уникальное | | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `specialty_code` | BIGINT FK → specialties | Специальность | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | | `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | #### `subgroups` — Подгруппы @@ -367,23 +420,55 @@ erDiagram | `start_date` | DATE | Дата начала, от неё считается неделя 1 | | `end_date` | DATE | Дата окончания | -#### `holidays` — Праздники и исключения +#### `calendar_study_forms` — Формы обучения календарного графика | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `date` | DATE | Неучебная дата | -| `academic_year_id` | BIGINT FK → academic_years | Учебный год | -| `description` | VARCHAR(255) | Описание | +| `name` | VARCHAR(100) UNIQUE | Форма обучения графика, seed: `очная` | +| `description` | TEXT | Описание | -#### `academic_calendar_matrix` — Матрица учебного графика +#### `academic_calendar_activity_types` — Коды активностей графика | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | -| `semester_id` | BIGINT FK → semesters | Семестр | -| `course_number` | INT | Курс | +| `code` | VARCHAR(10) UNIQUE | Код из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | +| `name` | VARCHAR(100) | Расшифровка кода | +| `allow_schedule` | BOOLEAN | Разрешает генерацию обычных пар | +| `color_code` | VARCHAR(7) | HEX-цвет для редактора | +| `display_order` | INT | Порядок вывода в UI | +| `description` | TEXT | Дополнительное описание | + +#### `academic_calendars` — Календарные учебные графики +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(255) | Название графика | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | | `specialty_id` | BIGINT FK → specialties | Специальность | -| `week_number` | INT | Номер недели семестра | -| `activity_type` | VARCHAR(20) | `THEORY`, `EXAM`, `VACATION`, `PRACTICE` | +| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | +| `study_form_id` | BIGINT FK → calendar_study_forms | Форма обучения графика | +| `course_count` | INT CHECK(> 0) | Количество курсов в сетке | +| `created_at` | TIMESTAMP | Дата создания | +| `updated_at` | TIMESTAMP | Дата обновления | + +#### `academic_calendar_days` — Дневная сетка календарного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | +| `course_number` | INT | Номер курса | +| `date` | DATE | Дата учебного года | +| `week_number` | INT | Номер недели от начала учебного года | +| `day_of_week` | INT CHECK(1–7) | День недели ISO | +| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | + +#### `student_group_calendar_assignments` — Назначения графиков группам +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `group_id` | BIGINT FK → student_groups (CASCADE) | Учебная группа | +| `academic_year_id` | BIGINT FK → academic_years (CASCADE) | Учебный год | +| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Назначенный график | #### `schedule_rules` — Правила расписания | Колонка | Тип | Описание | @@ -426,11 +511,13 @@ erDiagram 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline +> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. + ### Текущие миграции | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index c71916b..6261df7 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -103,15 +103,17 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры, праздники и матрица графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/lesson-types` | +| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры и Excel-подобный редактор календарного учебного графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/lesson-types` | +| `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | ### Особенности админских вкладок -- Вкладка `groups` загружает кафедры и специальности из справочников и при создании группы отправляет `yearStartStudy`; поле `specialityCode` содержит ID выбранной специальности. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. -- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. +- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. +- Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. В этом же модуле администратор создаёт календарные графики, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}`, `PUT/DELETE /api/specialties/{id}` и CRUD профилей `/api/specialties/{id}/profiles`. ### Страница настроек (`/admin/settings/`) diff --git a/docs/README.md b/docs/README.md index 3775cc5..875f486 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ ## Обзор -**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент) и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. +**Magistr** — веб-приложение для управления расписанием занятий университета. Система поддерживает мультитенантную архитектуру (каждый университет = отдельная база данных), ролевую модель доступа (Администратор, Преподаватель, Студент), календарные учебные графики и полное управление аудиторным фондом, группами, дисциплинами и преподавательским составом. - **Продакшн:** [https://magistr.zuev.company](https://magistr.zuev.company) - **Локальная разработка:** [http://localhost:80](http://localhost:80) @@ -106,6 +106,7 @@ magistr/ |----------|-----------| | [Архитектура](ARCHITECTURE.md) | Общая архитектура, мультитенантность, взаимодействие компонентов | | [Бизнес-логика](BUSINESS_LOGIC.md) | Ролевая модель, правила расписания, управление ресурсами | +| [Календарный учебный график](ACADEMIC_CALENDAR.md) | Модель графиков по специальностям, профилям и группам | | [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции | | [REST API](API.md) | Все эндпоинты с примерами запросов и ответов | | [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг | diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 8ea95ae..ad76478 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -1,4 +1,4 @@ -/* ===== Auditorium Workload Specific Styles ===== */ +/* ===== Стили загруженности аудиторий ===== */ .workload-grid-container { width: 100%; @@ -37,7 +37,8 @@ box-shadow: 0 1px 0 var(--bg-card-border); } -.workload-table .time-cell { +.workload-table .time-cell, +.workload-table .axis-cell { background: var(--bg-input); color: var(--text-secondary); font-weight: 500; @@ -62,7 +63,7 @@ box-shadow: 1px 1px 0 var(--bg-card-border); } -/* Diagonal line using SVG or linear-gradient */ +/* Диагональная линия через linear-gradient */ .workload-table .top-left-cell::before { content: ''; position: absolute; @@ -126,7 +127,7 @@ } .lesson-group { - font-weight: 700; /* Bolder specific for groups request mockup */ + font-weight: 700; /* Акцент на названии группы */ color: var(--text-primary); margin-bottom: 0.1rem; font-size: 0.75rem; @@ -137,7 +138,7 @@ font-size: 0.75rem; } -/* Custom scrollbar adjustments for grid container */ +/* Настройка scrollbar для контейнера сетки */ .workload-grid-container::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index c0cb7b0..1cb7e41 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -162,28 +162,96 @@ margin-top: 1rem; } -.matrix-grid { +.calendar-fill-panel { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(4, minmax(150px, 1fr)) auto; gap: 0.75rem; - margin-top: 1rem; -} - -.matrix-week { - display: grid; - gap: 0.45rem; + align-items: end; + margin: 1rem 0; padding: 0.75rem; background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); } -.matrix-week > span { +.academic-calendar-grid { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.calendar-course-block { + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); + overflow: hidden; +} + +.calendar-course-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-course-header h3 { + font-size: 1rem; +} + +.calendar-course-header span, +.calendar-week-label { + color: var(--text-secondary); + font-size: 0.78rem; +} + +.calendar-days-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 0.5rem; + padding: 0.75rem; +} + +.calendar-day { + display: grid; + gap: 0.4rem; + min-height: 92px; + padding: 0.55rem; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); +} + +.calendar-day-meta { + display: flex; + justify-content: space-between; + gap: 0.35rem; color: var(--text-secondary); font-size: 0.78rem; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; +} + +.calendar-day-activity { + min-width: 0; +} + +.calendar-totals { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.calendar-total-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--chip-color) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--chip-color) 52%, transparent); + color: var(--text-primary); + font-size: 0.8rem; + font-weight: 600; } .muted { @@ -194,12 +262,20 @@ .schedule-slot-row { grid-template-columns: repeat(2, minmax(180px, 1fr)); } + + .calendar-fill-panel { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } } @media (max-width: 640px) { .schedule-slot-row { grid-template-columns: 1fr; } + + .calendar-fill-panel { + grid-template-columns: 1fr; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/js/views/auditorium-workload.js b/frontend/admin/js/views/auditorium-workload.js index bd3dc44..a608e24 100644 --- a/frontend/admin/js/views/auditorium-workload.js +++ b/frontend/admin/js/views/auditorium-workload.js @@ -1,7 +1,7 @@ import { initMultiSelect } from '../utils.js'; export function initAuditoriumWorkload() { - // Initialize date input with current date + // Дата по умолчанию — текущий день. const dateInput = document.getElementById('workload-date'); if (dateInput) { const today = new Date(); @@ -11,20 +11,20 @@ export function initAuditoriumWorkload() { dateInput.value = `${yyyy}-${mm}-${dd}`; } - // Initialize Multi-Selects + // Инициализация мультиселектов. initMultiSelect('building-box', 'building-menu', 'building-text', 'building-checkboxes'); initMultiSelect('capacity-box', 'capacity-menu', 'capacity-text', 'capacity-checkboxes'); initMultiSelect('equipment-box', 'equipment-menu', 'equipment-text', 'equipment-checkboxes'); - // Populate Filters with Mock/Initial Data + // Заполнение фильтров начальными данными. populateFilters(); - // Render Mock Data for the Grid based on the UI requested layout + // Отрисовка mock-данных в нужной ориентации сетки. renderMockGrid(); } function populateFilters() { - // Buildings + // Корпуса. const buildingsContainer = document.getElementById('building-checkboxes'); const buildings = [ { id: 1, name: "Корпус 1 (Главный)" }, @@ -39,7 +39,7 @@ function populateFilters() { `).join(''); - // Capacities + // Вместимость. const capacityContainer = document.getElementById('capacity-checkboxes'); const capacities = [ { id: 'small', name: "До 30 мест" }, @@ -55,7 +55,7 @@ function populateFilters() { `).join(''); - // Equipment + // Оборудование. const equipmentContainer = document.getElementById('equipment-checkboxes'); const equipmentList = [ { id: 1, name: "Проектор" }, @@ -73,7 +73,7 @@ function populateFilters() { } function renderMockGrid() { - // In future this will be loaded from API + // В будущем данные будут загружаться из API. const timeslots = [ "8:00-9:30", "9:40-11:10", @@ -89,7 +89,7 @@ function renderMockGrid() { "201", "202", "204", "205", "206", "207", "208" ]; - // Mock schedule data mapped by room and time + // Mock-расписание по аудитории и времени. // Ключ: "roomId_timeSlotId", значение: объект занятия const mockSchedule = { "201_8:00-9:30": { subject: "Физика", group: "ИБ-41м", teacher: "Атлетов А.Р." }, @@ -105,37 +105,43 @@ function renderMockGrid() { "205_9:40-11:10": { subject: "Организация аудита ИБ", group: "ИБ-41м", teacher: "Таныгин М.О." }, }; - // Render Headers + // В столбцах отображаются временные слоты, в строках — аудитории. const headerRow = document.getElementById('workload-header-row'); - // Start after the first fixed cell (which is already in HTML) + headerRow.innerHTML = ` + + Время + Аудитория + + `; - auditoriums.forEach(room => { + timeslots.forEach(time => { const th = document.createElement('th'); - th.textContent = room; + th.textContent = time; headerRow.appendChild(th); }); - // Render Body Rows + // Строки таблицы. const tbody = document.getElementById('workload-tbody'); + tbody.innerHTML = ''; - timeslots.forEach((time) => { + auditoriums.forEach((room) => { const tr = document.createElement('tr'); - // Add Time Cell - const tdTime = document.createElement('td'); - tdTime.className = 'time-cell'; - tdTime.textContent = time; - tr.appendChild(tdTime); + // Ячейка аудитории. + const tdRoom = document.createElement('td'); + tdRoom.className = 'axis-cell'; + tdRoom.textContent = room; + tr.appendChild(tdRoom); - // Add Room Cells for this Time - auditoriums.forEach(room => { + // Ячейки временных слотов для аудитории. + timeslots.forEach(time => { const td = document.createElement('td'); const scheduleKey = `${room}_${time}`; const lesson = mockSchedule[scheduleKey]; if (lesson) { - // Render lesson card + // Карточка занятия. td.innerHTML = `
${lesson.subject}
diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 2e2e64f..45eca7f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,15 +7,22 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); + const createProfileForm = document.getElementById('create-profile-form'); + const profileSpecialtySelect = document.getElementById('profile-specialty'); + const profilesTbody = document.getElementById('profiles-tbody'); const editDeptForm = document.getElementById('edit-department-form'); const editSpecForm = document.getElementById('edit-specialty-form'); + const editProfileForm = document.getElementById('edit-profile-form'); const editDeptModal = document.getElementById('modal-edit-department'); const editSpecModal = document.getElementById('modal-edit-specialty'); + const editProfileModal = document.getElementById('modal-edit-profile'); const editDeptClose = document.getElementById('modal-edit-department-close'); const editSpecClose = document.getElementById('modal-edit-specialty-close'); + const editProfileClose = document.getElementById('modal-edit-profile-close'); let departments = []; let specialties = []; + let profiles = []; async function loadData() { // Load Departments @@ -30,8 +37,11 @@ export async function initDepartmentsData() { try { specialties = await api.get('/api/specialties'); renderSpecialties(); + renderProfileSpecialtySelect(); + await loadProfiles(); } catch (e) { specTbody.innerHTML = '-'; + profilesTbody.innerHTML = '-'; } } @@ -71,6 +81,52 @@ export async function initDepartmentsData() { `).join(''); } + function renderProfileSpecialtySelect() { + const current = profileSpecialtySelect.value; + profileSpecialtySelect.innerHTML = '' + + specialties.map(s => ``).join(''); + if (current && specialties.some(s => String(s.id) === String(current))) { + profileSpecialtySelect.value = current; + } else if (specialties.length) { + profileSpecialtySelect.value = String(specialties[0].id); + } + syncSelect(profileSpecialtySelect); + } + + async function loadProfiles() { + const specialtyId = profileSpecialtySelect.value; + if (!specialtyId) { + profiles = []; + profilesTbody.innerHTML = 'Выберите специальность'; + return; + } + profilesTbody.innerHTML = 'Загрузка...'; + try { + profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); + renderProfiles(); + } catch (error) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderProfiles() { + if (!profiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + profilesTbody.innerHTML = profiles.map(profile => ` + + ${profile.id} + ${escapeHtml(profile.name)} + ${escapeHtml(profile.description || '-')} + + + + + + `).join(''); + } + createDeptForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-dept-alert'); @@ -141,6 +197,59 @@ export async function initDepartmentsData() { } }); + profileSpecialtySelect.addEventListener('change', loadProfiles); + + createProfileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('create-profile-alert'); + const specialtyId = profileSpecialtySelect.value; + const name = document.getElementById('profile-name').value.trim(); + const description = document.getElementById('profile-description').value.trim(); + + if (!specialtyId || !name) { + showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); + return; + } + + try { + await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); + showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); + createProfileForm.reset(); + profileSpecialtySelect.value = specialtyId; + syncSelect(profileSpecialtySelect); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.btn-edit-profile'); + const deleteBtn = e.target.closest('.btn-delete-profile'); + + if (editBtn) { + const profile = profiles.find(item => item.id == editBtn.dataset.id); + if (!profile) return; + document.getElementById('edit-profile-id').value = profile.id; + document.getElementById('edit-profile-name').value = profile.name || ''; + document.getElementById('edit-profile-description').value = profile.description || ''; + hideAlert('edit-profile-alert'); + editProfileModal.classList.add('open'); + return; + } + + if (deleteBtn) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteBtn.dataset.id}`); + showAlert('create-profile-alert', 'Профиль удалён', 'success'); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + specTbody.addEventListener('click', async (e) => { const editBtn = e.target.closest('.btn-edit-specialty'); const deleteBtn = e.target.closest('.btn-delete-specialty'); @@ -213,14 +322,45 @@ export async function initDepartmentsData() { } }); + editProfileForm.addEventListener('submit', async (e) => { + e.preventDefault(); + hideAlert('edit-profile-alert'); + const specialtyId = profileSpecialtySelect.value; + const id = document.getElementById('edit-profile-id').value; + const name = document.getElementById('edit-profile-name').value.trim(); + const description = document.getElementById('edit-profile-description').value.trim(); + + if (!specialtyId || !id || !name) { + showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); + return; + } + + try { + await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); + editProfileModal.classList.remove('open'); + showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); + await loadProfiles(); + } catch (error) { + showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); + } + }); + editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); + editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); editDeptModal.addEventListener('click', (e) => { if (e.target === editDeptModal) editDeptModal.classList.remove('open'); }); editSpecModal.addEventListener('click', (e) => { if (e.target === editSpecModal) editSpecModal.classList.remove('open'); }); + editProfileModal.addEventListener('click', (e) => { + if (e.target === editProfileModal) editProfileModal.classList.remove('open'); + }); await loadData(); + + function syncSelect(select) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js old mode 100755 new mode 100644 index a8a65d8..d06cf62 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -8,105 +8,408 @@ export async function initGroups() { const newGroupEfSelect = document.getElementById('new-group-ef'); const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); + const newGroupProfileSelect = document.getElementById('new-group-profile'); const filterEfSelect = document.getElementById('filter-ef'); + const modalEditGroup = document.getElementById('modal-edit-group'); + const modalEditGroupClose = document.getElementById('modal-edit-group-close'); + const editGroupForm = document.getElementById('edit-group-form'); + const editGroupEfSelect = document.getElementById('edit-group-ef'); + const editGroupDepartmentSelect = document.getElementById('edit-group-department'); + const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code'); + const editGroupProfileSelect = document.getElementById('edit-group-profile'); + const calendarForm = document.getElementById('group-calendar-form'); + const calendarGroupSelect = document.getElementById('calendar-group'); + const calendarYearSelect = document.getElementById('calendar-year'); + const calendarSelect = document.getElementById('calendar-id'); + const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); let allGroups = []; let educationForms = []; let departments = []; let specialties = []; + let profilesBySpecialty = new Map(); + let academicYears = []; + let calendars = []; + let currentAssignments = []; + + bindEvents(); + await loadInitialData(); + + function bindEvents() { + filterEfSelect.addEventListener('change', applyGroupFilter); + newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); + editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); + calendarGroupSelect.addEventListener('change', () => { + populateCalendarSelect(); + loadAssignments(); + }); + calendarYearSelect.addEventListener('change', populateCalendarSelect); + createGroupForm.addEventListener('submit', createGroup); + editGroupForm.addEventListener('submit', updateGroup); + calendarForm.addEventListener('submit', saveAssignment); + groupsTbody.addEventListener('click', handleGroupTableClick); + calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); + modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); + modalEditGroup.addEventListener('click', (event) => { + if (event.target === modalEditGroup) { + modalEditGroup.classList.remove('open'); + } + }); + } async function loadInitialData() { try { - [educationForms, departments, specialties] = await Promise.all([ + [educationForms, departments, specialties, academicYears, calendars] = await Promise.all([ fetchEducationForms(), api.get('/api/departments'), - api.get('/api/specialties') + api.get('/api/specialties'), + api.get('/api/admin/calendar/years'), + api.get('/api/admin/academic-calendars') ]); + await loadProfiles(); populateEfSelects(educationForms); - populateDepartmentSelect(departments); - populateSpecialitySelect(specialties); + populateDepartmentSelects(departments); + populateSpecialitySelects(specialties); + populateYearSelect(); + populateCalendarSelect(); await loadGroups(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки данных'; + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + async function loadGroups() { try { allGroups = await api.get('/api/groups'); applyGroupFilter(); - } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки'; + populateGroupSelect(); + await loadAssignments(); + } catch (error) { + groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function applyGroupFilter() { const filterId = filterEfSelect.value; const filtered = filterId - ? allGroups.filter(g => g.educationFormId == filterId) + ? allGroups.filter(group => group.educationFormId == filterId) : allGroups; renderGroups(filtered); } - filterEfSelect.addEventListener('change', applyGroupFilter); + function renderGroups(groups) { + if (!groups || !groups.length) { + groupsTbody.innerHTML = 'Нет групп'; + return; + } + groupsTbody.innerHTML = groups.map(group => ` + + ${group.id} + ${escapeHtml(group.name)} + ${escapeHtml(String(group.groupSize))} + ${escapeHtml(group.educationFormName)} + ${escapeHtml(departmentLabel(group.departmentId))} + ${group.course || '-'} + ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} + ${escapeHtml(group.specialtyProfileName || '-')} + + + + + + `).join(''); + } function populateEfSelects(forms) { - // Group creation select - const currentVal = newGroupEfSelect.value; - newGroupEfSelect.innerHTML = forms.map(ef => - `` - ).join(''); - if (currentVal && forms.find(f => f.id == currentVal)) { - newGroupEfSelect.value = currentVal; - } - syncSelects(newGroupEfSelect); - - // Filter select + populateEfSelect(newGroupEfSelect, forms); + populateEfSelect(editGroupEfSelect, forms); const currentFilter = filterEfSelect.value; filterEfSelect.innerHTML = '' + - forms.map(ef => - `` - ).join(''); + forms.map(form => ``).join(''); if (currentFilter) filterEfSelect.value = currentFilter; syncSelects(filterEfSelect); } - function populateDepartmentSelect(items) { - const currentVal = newGroupDepartmentSelect.value; - if (!items || !items.length) { - newGroupDepartmentSelect.innerHTML = ''; - return; + function populateEfSelect(select, forms) { + const currentVal = select.value; + select.innerHTML = forms.map(form => ``).join(''); + if (currentVal && forms.find(form => form.id == currentVal)) { + select.value = currentVal; } + syncSelects(select); + } - newGroupDepartmentSelect.innerHTML = '' + + function populateDepartmentSelects(items) { + populateDepartmentSelect(newGroupDepartmentSelect, items); + populateDepartmentSelect(editGroupDepartmentSelect, items); + } + + function populateDepartmentSelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + items.map(department => { const name = department.departmentName || department.name || 'Без названия'; const code = department.departmentCode || department.code || department.id; return ``; }).join(''); if (currentVal && items.find(department => department.id == currentVal)) { - newGroupDepartmentSelect.value = currentVal; + select.value = currentVal; } - syncSelects(newGroupDepartmentSelect); + syncSelects(select); } - function populateSpecialitySelect(items) { - const currentVal = newGroupSpecialitySelect.value; - if (!items || !items.length) { - newGroupSpecialitySelect.innerHTML = ''; + function populateSpecialitySelects(items) { + populateSpecialitySelect(newGroupSpecialitySelect, items); + populateSpecialitySelect(editGroupSpecialitySelect, items); + populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value); + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value); + } + + function populateSpecialitySelect(select, items) { + const currentVal = select.value; + select.innerHTML = '' + + items.map(speciality => ``).join(''); + if (currentVal && items.find(speciality => speciality.id == currentVal)) { + select.value = currentVal; + } + syncSelects(select); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateYearSelect() { + calendarYearSelect.innerHTML = '' + + academicYears.map(year => ``).join(''); + if (!calendarYearSelect.value && academicYears.length) { + calendarYearSelect.value = String(academicYears[0].id); + } + syncSelects(calendarYearSelect); + } + + function populateGroupSelect() { + const current = calendarGroupSelect.value; + calendarGroupSelect.innerHTML = '' + + allGroups.map(group => ``).join(''); + if (current && allGroups.some(group => String(group.id) === String(current))) { + calendarGroupSelect.value = current; + } else if (allGroups.length) { + calendarGroupSelect.value = String(allGroups[0].id); + } + syncSelects(calendarGroupSelect); + } + + function populateCalendarSelect() { + const group = selectedCalendarGroup(); + const yearId = calendarYearSelect.value; + const matching = group && yearId + ? calendars.filter(calendar => + String(calendar.academicYearId) === String(yearId) + && String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode) + && String(calendar.specialtyProfileId) === String(group.specialtyProfileId) + ) + : []; + calendarSelect.innerHTML = matching.length + ? '' + matching.map(calendar => + `` + ).join('') + : ''; + syncSelects(calendarSelect); + } + + async function createGroup(event) { + event.preventDefault(); + hideAlert('create-group-alert'); + const payload = readGroupPayload('new'); + if (!payload) return; + + try { + const data = await api.post('/api/groups', payload); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success'); + createGroupForm.reset(); + syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); + await loadGroups(); + } catch (error) { + showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); + } + } + + async function updateGroup(event) { + event.preventDefault(); + hideAlert('edit-group-alert'); + const id = document.getElementById('edit-group-id').value; + const payload = readGroupPayload('edit'); + if (!payload) return; + + try { + const data = await api.put('/api/groups/' + id, payload); + modalEditGroup.classList.remove('open'); + showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); + await loadGroups(); + } catch (error) { + showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); + } + } + + function readGroupPayload(prefix) { + const isEdit = prefix === 'edit'; + const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert'; + const name = document.getElementById(`${prefix}-group-name`).value.trim(); + const groupSize = document.getElementById(`${prefix}-group-size`).value; + const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value; + const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value; + const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value; + const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value; + const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value; + + if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; } + if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; } + if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; } + if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; } + if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; } + if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; } + if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; } + + return { + name, + groupSize: Number(groupSize), + educationFormId: Number(educationFormId), + departmentId: Number(departmentId), + yearStartStudy: Number(yearStartStudy), + specialtyId: Number(specialtyId), + specialtyProfileId: Number(specialtyProfileId) + }; + } + + async function handleGroupTableClick(event) { + const btnDelete = event.target.closest('.btn-delete'); + const btnEdit = event.target.closest('.btn-edit-group'); + + if (btnDelete) { + if (!confirm('Удалить группу?')) return; + try { + await api.delete('/api/groups/' + btnDelete.dataset.id); + await loadGroups(); + } catch (error) { + alert(error.message || 'Ошибка удаления'); + } + } + + if (btnEdit) { + openEditGroupModal(btnEdit.dataset.id); + } + } + + function openEditGroupModal(id) { + const group = allGroups.find(item => item.id == id); + if (!group) { + alert('Группа не найдена'); return; } - newGroupSpecialitySelect.innerHTML = '' + - items.map(speciality => { - const name = speciality.specialityName || speciality.name || 'Без названия'; - const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; - return ``; - }).join(''); - if (currentVal && items.find(speciality => speciality.id == currentVal)) { - newGroupSpecialitySelect.value = currentVal; + document.getElementById('edit-group-id').value = group.id; + document.getElementById('edit-group-name').value = group.name || ''; + document.getElementById('edit-group-size').value = group.groupSize || ''; + editGroupEfSelect.value = group.educationFormId || ''; + editGroupDepartmentSelect.value = group.departmentId || ''; + document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || ''; + editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || ''; + populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId); + syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect); + hideAlert('edit-group-alert'); + modalEditGroup.classList.add('open'); + } + + async function saveAssignment(event) { + event.preventDefault(); + hideAlert('group-calendar-alert'); + const groupId = calendarGroupSelect.value; + const academicYearId = calendarYearSelect.value; + const calendarId = calendarSelect.value; + + if (!groupId || !academicYearId || !calendarId) { + showAlert('group-calendar-alert', 'Выберите группу, учебный год и график', 'error'); + return; } - syncSelects(newGroupSpecialitySelect); + + try { + await api.put(`/api/groups/${groupId}/calendar-assignments`, { + academicYearId: Number(academicYearId), + calendarId: Number(calendarId) + }); + showAlert('group-calendar-alert', 'Календарный график назначен', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка назначения графика', 'error'); + } + } + + async function loadAssignments() { + const groupId = calendarGroupSelect.value; + if (!groupId) { + currentAssignments = []; + calendarAssignmentsTbody.innerHTML = 'Выберите группу'; + return; + } + + try { + currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`); + renderAssignments(); + } catch (error) { + calendarAssignmentsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderAssignments() { + if (!currentAssignments.length) { + calendarAssignmentsTbody.innerHTML = 'Графики не назначены'; + return; + } + calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => ` + + ${escapeHtml(assignment.academicYearTitle)} + ${escapeHtml(assignment.calendarTitle)} + + + + + `).join(''); + } + + async function handleAssignmentTableClick(event) { + const deleteButton = event.target.closest('.btn-delete-assignment'); + if (!deleteButton) return; + if (!confirm('Удалить назначение графика?')) return; + + try { + await api.delete(`/api/groups/${calendarGroupSelect.value}/calendar-assignments/${deleteButton.dataset.id}`); + showAlert('group-calendar-alert', 'Назначение удалено', 'success'); + await loadAssignments(); + } catch (error) { + showAlert('group-calendar-alert', error.message || 'Ошибка удаления назначения', 'error'); + } + } + + function selectedCalendarGroup() { + return allGroups.find(group => String(group.id) === String(calendarGroupSelect.value)); } function departmentLabel(departmentId) { @@ -123,73 +426,6 @@ export async function initGroups() { return name ? `${code} — ${name}` : code; } - function renderGroups(groups) { - if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; - return; - } - groupsTbody.innerHTML = groups.map(g => ` - - ${g.id} - ${escapeHtml(g.name)} - ${escapeHtml(g.groupSize)} - ${escapeHtml(g.educationFormName)} - ${escapeHtml(departmentLabel(g.departmentId))} - ${g.course || '-'} - ${escapeHtml(specialityLabel(g.specialityCode))} - - `).join(''); - } - - createGroupForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('create-group-alert'); - const name = document.getElementById('new-group-name').value.trim(); - const groupSize = document.getElementById('new-group-size').value; - const educationFormId = newGroupEfSelect.value; - const departmentId = newGroupDepartmentSelect.value; - const yearStartStudy = document.getElementById('new-group-yearStartStudy').value; - const specialityCode = newGroupSpecialitySelect.value; - - if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; } - if (!groupSize) { showAlert('create-group-alert', 'Введите размер группы', 'error'); return; } - if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; } - if (!departmentId) { showAlert('create-group-alert', 'Выберите кафедру', 'error'); return; } - if (!yearStartStudy) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; } - if (!specialityCode) { showAlert('create-group-alert', 'Выберите специальность', 'error'); return; } - - try { - const data = await api.post('/api/groups', { - name, - groupSize: Number(groupSize), - educationFormId: Number(educationFormId), - departmentId: Number(departmentId), - yearStartStudy: Number(yearStartStudy), - specialityCode: Number(specialityCode) - }); - showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); - createGroupForm.reset(); - syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect); - loadGroups(); - } catch (e) { - showAlert('create-group-alert', e.message || 'Ошибка создания', 'error'); - } - }); - - groupsTbody.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-delete'); - if (!btn) return; - if (!confirm('Удалить группу?')) return; - try { - await api.delete('/api/groups/' + btn.dataset.id); - loadGroups(); - } catch (e) { - alert(e.message || 'Ошибка удаления'); - } - }); - - await loadInitialData(); - function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 3c867ab..ee38e92 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -22,13 +22,6 @@ const SEMESTER_LABELS = { spring: 'весенний' }; -const ACTIVITY_LABELS = { - THEORY: 'Теория', - EXAM: 'Экзамены', - VACATION: 'Каникулы', - PRACTICE: 'Практика' -}; - export async function initSchedule() { const ruleForm = document.getElementById('schedule-rule-form'); const ruleFormTitle = document.getElementById('schedule-rule-form-title'); @@ -74,20 +67,29 @@ export async function initSchedule() { const semesterEndInput = document.getElementById('semester-end'); const semesterResetButton = document.getElementById('semester-reset'); - const holidayForm = document.getElementById('holiday-form'); - const holidayIdInput = document.getElementById('holiday-id'); - const holidayYearSelect = document.getElementById('holiday-year'); - const holidayDateInput = document.getElementById('holiday-date'); - const holidayDescriptionInput = document.getElementById('holiday-description'); - const holidayResetButton = document.getElementById('holiday-reset'); - const holidaysTbody = document.getElementById('holidays-tbody'); + const calendarForm = document.getElementById('academic-calendar-form'); + const calendarFormTitle = document.getElementById('academic-calendar-form-title'); + const calendarIdInput = document.getElementById('academic-calendar-id'); + const calendarTitleInput = document.getElementById('academic-calendar-title'); + const calendarYearSelect = document.getElementById('academic-calendar-year'); + const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); + const calendarProfileSelect = document.getElementById('academic-calendar-profile'); + const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); + const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); + const calendarResetButton = document.getElementById('academic-calendar-reset'); + const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); + const calendarsTbody = document.getElementById('academic-calendars-tbody'); - const matrixSemesterSelect = document.getElementById('matrix-semester'); - const matrixCourseInput = document.getElementById('matrix-course'); - const matrixSpecialtySelect = document.getElementById('matrix-specialty'); - const matrixLoadButton = document.getElementById('matrix-load'); - const matrixSaveButton = document.getElementById('matrix-save'); - const matrixGrid = document.getElementById('matrix-grid'); + const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); + const editorLoadButton = document.getElementById('calendar-editor-load'); + const editorSaveButton = document.getElementById('calendar-editor-save'); + const fillCourseSelect = document.getElementById('calendar-fill-course'); + const fillStartInput = document.getElementById('calendar-fill-start'); + const fillEndInput = document.getElementById('calendar-fill-end'); + const fillActivitySelect = document.getElementById('calendar-fill-activity'); + const fillApplyButton = document.getElementById('calendar-fill-apply'); + const calendarGrid = document.getElementById('calendar-grid'); + const calendarTotals = document.getElementById('calendar-totals'); let rules = []; let subjects = []; @@ -99,15 +101,19 @@ export async function initSchedule() { let academicYears = []; let semesters = []; let specialties = []; - let holidays = []; + let profilesBySpecialty = new Map(); + let studyForms = []; + let activityTypes = []; + let calendars = []; + let currentGridRows = []; bindEvents(); try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]); renderRuleGroups([]); renderRuleSlots([{}]); - await Promise.all([loadRules(), loadHolidays()]); + await Promise.all([loadRules(), loadCalendars()]); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } @@ -116,15 +122,17 @@ export async function initSchedule() { refreshRulesButton?.addEventListener('click', loadRules); refreshSlotsButton?.addEventListener('click', loadTimeSlots); academicYearsRefreshButton?.addEventListener('click', loadYears); + calendarsRefreshButton?.addEventListener('click', loadCalendars); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm); semesterResetButton?.addEventListener('click', resetSemesterForm); - holidayResetButton?.addEventListener('click', resetHolidayForm); - holidayYearSelect?.addEventListener('change', loadHolidays); - matrixLoadButton?.addEventListener('click', loadMatrix); - matrixSaveButton?.addEventListener('click', saveMatrix); + calendarResetButton?.addEventListener('click', resetCalendarForm); + calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); + editorLoadButton?.addEventListener('click', loadCalendarGrid); + editorSaveButton?.addEventListener('click', saveCalendarGrid); + fillApplyButton?.addEventListener('click', applyCalendarFill); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); @@ -134,16 +142,22 @@ export async function initSchedule() { renderRuleSlots(slots.length ? slots : [{}]); }); + calendarGrid.addEventListener('change', (event) => { + if (event.target.matches('.calendar-day-activity')) { + renderCalendarTotals(); + } + }); + ruleForm.addEventListener('submit', saveRule); timeSlotForm.addEventListener('submit', saveTimeSlot); academicYearForm.addEventListener('submit', saveAcademicYear); semesterForm.addEventListener('submit', saveSemester); - holidayForm.addEventListener('submit', saveHoliday); + calendarForm.addEventListener('submit', saveCalendar); rulesTbody.addEventListener('click', handleRuleTableClick); timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); - holidaysTbody.addEventListener('click', handleHolidayTableClick); + calendarsTbody.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { @@ -155,11 +169,19 @@ export async function initSchedule() { api.get('/api/lesson-types'), api.get('/api/specialties') ]); - + await loadProfiles(); populateSubjectSelects(); populateSpecialtySelects(); } + async function loadProfiles() { + const pairs = await Promise.all(specialties.map(async (speciality) => { + const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`); + return [String(speciality.id), profiles]; + })); + profilesBySpecialty = new Map(pairs); + } + async function loadRules() { rulesTbody.innerHTML = 'Загрузка...'; try { @@ -171,11 +193,10 @@ export async function initSchedule() { } function renderRules() { - if (!rules || !rules.length) { + if (!rules.length) { rulesTbody.innerHTML = 'Правила расписания не созданы'; return; } - rulesTbody.innerHTML = rules.map(rule => ` ${rule.id} @@ -195,7 +216,6 @@ export async function initSchedule() { function renderRuleSlotsSummary(slots) { if (!slots || !slots.length) return 'Нет слотов'; - return slots.map(slot => { const parity = PARITY_LABELS[slot.parity] || slot.parity || '-'; const parts = [ @@ -237,7 +257,6 @@ export async function initSchedule() { async function saveRule(event) { event.preventDefault(); hideAlert('schedule-rule-alert'); - try { const payload = buildRulePayload(); const id = ruleIdInput.value; @@ -256,26 +275,12 @@ export async function initSchedule() { const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) .map(input => Number(input.value)); const slots = readRuleSlots(true); - - if (!ruleSubjectSelect.value) { - throw new Error('Выберите дисциплину'); - } - if (!ruleSemesterSelect.value) { - throw new Error('Выберите семестр'); - } - if (!ruleActiveFromInput.value) { - throw new Error('Укажите дату начала правила'); - } - if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) { - throw new Error('Укажите количество академических часов'); - } - if (!groupIds.length) { - throw new Error('Выберите хотя бы одну группу'); - } - if (!slots.length) { - throw new Error('Добавьте хотя бы один слот занятия'); - } - + if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); + if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); + if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила'); + if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов'); + if (!groupIds.length) throw new Error('Выберите хотя бы одну группу'); + if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия'); return { id: ruleIdInput.value ? Number(ruleIdInput.value) : null, subjectId: Number(ruleSubjectSelect.value), @@ -316,7 +321,6 @@ export async function initSchedule() { ruleGroupsContainer.innerHTML = '
Нет групп
'; return; } - const selected = new Set((selectedIds || []).map(String)); ruleGroupsContainer.innerHTML = groups.map(group => `
- +
- + - +
- Аудитория - Время + Время + Аудитория
diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index dd31814..897ee96 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -40,6 +40,47 @@ +
+

Профили обучения

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + + +
IDПрофильОписаниеДействия
Выберите специальность
+
+
+

Создание специальности

@@ -124,3 +165,25 @@
+ + diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index c05b8af..cdba50c 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -1,4 +1,4 @@ - +

Новая группа

@@ -33,6 +33,12 @@
+
+ + +
@@ -60,14 +66,114 @@ Кафедра Курс Специальность + Профиль Действия - Загрузка... + Загрузка... + +
+

Назначение календарного графика

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + +
Учебный годГрафикДействия
Выберите группу
+
+
+ + + diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index b5faf14..af7209e 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -213,41 +213,72 @@
-

Праздники и исключения

-
- +
+

Календарный учебный график

+ +
+ +
- - +
+
+ +
- - + +
- - + +
- - +
+ + +
+
+ + +
+
- +
-
+
+ +
+
+

Календарные графики

+ +
+
- - + + + + + + - + - +
ДатаОписаниеНазваниеУчебный годСпециальностьПрофильФормаКурсов Действия
Выберите учебный годЗагрузка...
@@ -255,27 +286,41 @@
-

Матрица учебного графика

+

Редактор годового графика

- -
-
- - -
-
- - -
- - + +
- -
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+