создал систему календарного учебного графика

This commit is contained in:
Zuev
2026-04-30 23:40:49 +03:00
parent 96e9d8155f
commit 89c822a073
56 changed files with 3356 additions and 1644 deletions

View File

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

View File

@@ -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». Конвенция чётности (верхняя = чётная или нечётная) **настраивается на уровне тенанта**, так как у разных университетов разные традиции. Это избавляет систему от уязвимостей, связанных с плавающими днями начала учёбы, високосными годами и смещениями дней недели.
- `academic_years` и `semesters` задают границы учебного года и семестров.
- `specialties` и `specialty_profiles` определяют образовательную программу группы.
- `academic_calendars` хранит график профиля и формы обучения.
- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`.
- `academic_calendar_days` хранит дневную сетку по курсу и дате.
- `student_group_calendar_assignments` выбирает график для группы на учебный год.
* **Справочник исключений (Праздники и Выходные).** В этой таблице хранятся конкретные даты `YYYY-MM-DD`, когда университет юридически или физически закрыт (например, государственные праздники). Если по правилу пара должна быть в этот день, алгоритм будет знать, что его нужно пропустить без штрафов и ошибок.
### Слой правил расписания
* **Матрица учебного графика.** Это цифровая копия эксель-таблицы (`Курс + Специальность``Номер недели``Тип деятельности`). Привязка идёт к `course_number` + `specialty_id`, а **не** к конкретной группе, так как учебный график одинаков для всех групп одного курса одной специальности. Номер текущего курса группы вычисляется из поля `year_start_study` модели `StudentGroup` относительно текущей даты по формуле: `course = текущий_учебный_год - year_start_study + 1`. Типы деятельности включают `THEORY` (Теория, пары идут в штатном режиме), `EXAM` (Э — экзаменационная сессия), `VACATION` (К — каникулы), `PRACTICE` (У, П — практика). Если, например, у 3-го курса на 18-й неделе стоит статус `EXAM`, алгоритм даже не будет пытаться генерировать для них теоретические лекции, а отобразит блок «Экзаменационная сессия».
- `time_slots` задаёт сетку пар для тенанта.
- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов.
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
- `schedule_rule_slots` хранит шаблонные занятия правила.
### 1.2 Справочник временных слотов (Time Slots)
Вместо хардкода фиксированных 7 пар, система хранит временные слоты в отдельной **настраиваемой таблице**. Каждый тенант (университет) может иметь собственное количество пар, их длительность и временные рамки.
## Алгоритм генерации
Слот содержит:
* `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`: день недели (17, Пн–Вс).
* `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(17), 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 16).
* `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<RenderedLesson> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate)` — расписание группы.
* `List<RenderedLesson> 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) для Дня Недели, Временного слота, Подгруппы, Чётности, Преподавателя, Аудитории и Типа занятия.
Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу.

View File

@@ -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 (17)
- [ ] Добавить 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 16)
- [ ] Трансформация `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` на дне теории и на днях, где пары запрещены.

View File

@@ -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<AcademicCalendarActivityTypeDto> 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<AcademicCalendarMatrixDto> 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<SemesterDto> 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()
);
}
}

View File

@@ -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<AcademicCalendarDto> 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<CalendarStudyFormDto> 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)
.<ResponseEntity<?>>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<AcademicCalendarGridDayDto> 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()
);
}
}

View File

@@ -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<StudentGroup> groups = groupRepository.findAll();
List<GroupResponse> 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<GroupResponse> 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<EducationForm> 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<StudentGroup> 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<EducationForm> 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()
);
}
}

View File

@@ -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();
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.dto;
public record CalendarStudyFormDto(
Long id,
String name,
String description
) {
}

View File

@@ -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;
}
}

View File

@@ -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
) {
}

View File

@@ -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;
}
}

View File

@@ -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
) {
}

View File

@@ -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<Long> groupIds,
List<String> groupNames,
AcademicActivityType activityType,
String activityType,
Integer totalAcademicHours,
Integer consumedAcademicHoursBeforeLesson,
Integer remainingAcademicHoursAfterLesson

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
public record SpecialtyProfileDto(
Long id,
Long specialtyId,
String specialtyCode,
String specialtyName,
String name,
String description
) {
}

View File

@@ -1,8 +0,0 @@
package com.magistr.app.model;
public enum AcademicActivityType {
THEORY,
EXAM,
VACATION,
PRACTICE
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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() {

View File

@@ -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;
}
}

View File

@@ -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<AcademicCalendarActivityType, Long> {
List<AcademicCalendarActivityType> findAllByOrderByDisplayOrderAscCodeAsc();
Optional<AcademicCalendarActivityType> findByCode(String code);
}

View File

@@ -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<AcademicCalendarDay, Long> {
@Query("""
select day
from AcademicCalendarDay day
join fetch day.activityType
where day.academicCalendar.id = :calendarId
order by day.courseNumber asc, day.date asc
""")
List<AcademicCalendarDay> 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<AcademicCalendarDay> 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);
}

View File

@@ -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<AcademicCalendarMatrix, Long> {
Optional<AcademicCalendarMatrix> findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber(
Long semesterId,
Integer courseNumber,
Long specialtyId,
Integer weekNumber
);
List<AcademicCalendarMatrix> findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(
Long semesterId,
Integer courseNumber,
Long specialtyId
);
}

View File

@@ -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<AcademicCalendar, Long> {
@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<AcademicCalendar> 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<AcademicCalendar> findByIdWithDetails(@Param("id") Long id);
}

View File

@@ -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<CalendarStudyForm, Long> {
List<CalendarStudyForm> findAllByOrderByNameAsc();
}

View File

@@ -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<StudentGroup, Long> {
Optional<StudentGroup> findByName(String name);
List<StudentGroup> findByEducationFormId(Long educationFormId);
List<StudentGroup> findByDepartmentId(Long departmentId);

View File

@@ -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<Holiday, Long> {
List<Holiday> findByAcademicYearIdOrderByDateAsc(Long academicYearId);
List<Holiday> findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate);
boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date);
}

View File

@@ -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<SpecialtyProfile, Long> {
List<SpecialtyProfile> findBySpecialityIdOrderByNameAsc(Long specialityId);
Optional<SpecialtyProfile> findBySpecialityIdAndName(Long specialityId, String name);
}

View File

@@ -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<StudentGroupCalendarAssignment, Long> {
@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<StudentGroupCalendarAssignment> 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<StudentGroupCalendarAssignment> 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<StudentGroupCalendarAssignment> findForSchedule(
@Param("groupId") Long groupId,
@Param("academicYearId") Long academicYearId
);
}

View File

@@ -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<AcademicCalendarActivityType> 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();
}
}

View File

@@ -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<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
Set<Long> warnedAcademicYears = new HashSet<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> 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<StudentGroup> 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

View File

@@ -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 типа занятия';

76
docs/ACADEMIC_CALENDAR.md Normal file
View File

@@ -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-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт.

View File

@@ -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
}
```
Назначаемый график должен относиться к тому же учебному году, специальности и профилю, что и группа.
---
## Аудитории

View File

@@ -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` возвращает пустой список для этой группы без ошибки.
### Временны́е слоты

View File

@@ -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(17) | День недели 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` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов

View File

@@ -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/`)

View File

@@ -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, мониторинг |

View File

@@ -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;

View File

@@ -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 */

View File

@@ -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() {
</label>
`).join('');
// Capacities
// Вместимость.
const capacityContainer = document.getElementById('capacity-checkboxes');
const capacities = [
{ id: 'small', name: "До 30 мест" },
@@ -55,7 +55,7 @@ function populateFilters() {
</label>
`).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 = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
</th>
`;
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 = `
<div class="lesson-card">
<div class="lesson-subject">${lesson.subject}</div>

View File

@@ -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 = '<tr><td colspan="4" class="loading-row">-</td></tr>';
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
}
@@ -71,6 +81,52 @@ export async function initDepartmentsData() {
`).join('');
}
function renderProfileSpecialtySelect() {
const current = profileSpecialtySelect.value;
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}${escapeHtml(s.specialityName || s.name)}</option>`).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 = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
return;
}
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
renderProfiles();
} catch (error) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderProfiles() {
if (!profiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = profiles.map(profile => `
<tr>
<td>${profile.id}</td>
<td>${escapeHtml(profile.name)}</td>
<td>${escapeHtml(profile.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
</td>
</tr>
`).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 }));
}
}

460
frontend/admin/js/views/groups.js Executable file → Normal file
View File

@@ -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 = '<tr><td colspan="8" class="loading-row">Ошибка загрузки данных</td></tr>';
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
}
}
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 = '<tr><td colspan="8" class="loading-row">Ошибка загрузки</td></tr>';
populateGroupSelect();
await loadAssignments();
} catch (error) {
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
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 = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(group => `
<tr>
<td>${group.id}</td>
<td>${escapeHtml(group.name)}</td>
<td>${escapeHtml(String(group.groupSize))}</td>
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
<td>${group.course || '-'}</td>
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td style="text-align: right;">
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
</td>
</tr>
`).join('');
}
function populateEfSelects(forms) {
// Group creation select
const currentVal = newGroupEfSelect.value;
newGroupEfSelect.innerHTML = forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).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 = '<option value="">Все формы</option>' +
forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
syncSelects(filterEfSelect);
}
function populateDepartmentSelect(items) {
const currentVal = newGroupDepartmentSelect.value;
if (!items || !items.length) {
newGroupDepartmentSelect.innerHTML = '<option value="">Нет кафедр</option>';
return;
function populateEfSelect(select, forms) {
const currentVal = select.value;
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
if (currentVal && forms.find(form => form.id == currentVal)) {
select.value = currentVal;
}
syncSelects(select);
}
newGroupDepartmentSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
function populateDepartmentSelects(items) {
populateDepartmentSelect(newGroupDepartmentSelect, items);
populateDepartmentSelect(editGroupDepartmentSelect, items);
}
function populateDepartmentSelect(select, items) {
const currentVal = select.value;
select.innerHTML = '<option value="">Выберите кафедру</option>' +
items.map(department => {
const name = department.departmentName || department.name || 'Без названия';
const code = department.departmentCode || department.code || department.id;
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
}).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 = '<option value="">Нет специальностей</option>';
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 = '<option value="">Выберите специальность</option>' +
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).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
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateYearSelect() {
calendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (!calendarYearSelect.value && academicYears.length) {
calendarYearSelect.value = String(academicYears[0].id);
}
syncSelects(calendarYearSelect);
}
function populateGroupSelect() {
const current = calendarGroupSelect.value;
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).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
? '<option value="">Выберите график</option>' + matching.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`
).join('')
: '<option value="">Нет подходящих графиков</option>';
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 = '<option value="">Выберите специальность</option>' +
items.map(speciality => {
const name = speciality.specialityName || speciality.name || 'Без названия';
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
return `<option value="${speciality.id}">${escapeHtml(code)}${escapeHtml(name)}</option>`;
}).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 = '<tr><td colspan="3" class="loading-row">Выберите группу</td></tr>';
return;
}
try {
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
renderAssignments();
} catch (error) {
calendarAssignmentsTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAssignments() {
if (!currentAssignments.length) {
calendarAssignmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Графики не назначены</td></tr>';
return;
}
calendarAssignmentsTbody.innerHTML = currentAssignments.map(assignment => `
<tr>
<td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td>
<td>
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td>
</tr>
`).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 = '<tr><td colspan="8" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
<tr>
<td>${g.id}</td>
<td>${escapeHtml(g.name)}</td>
<td>${escapeHtml(g.groupSize)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td>${escapeHtml(departmentLabel(g.departmentId))}</td>
<td>${g.course || '-'}</td>
<td>${escapeHtml(specialityLabel(g.specialityCode))}</td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).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 }));

View File

@@ -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 = '<tr><td colspan="8" class="loading-row">Загрузка...</td></tr>';
try {
@@ -171,11 +193,10 @@ export async function initSchedule() {
}
function renderRules() {
if (!rules || !rules.length) {
if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Правила расписания не созданы</td></tr>';
return;
}
rulesTbody.innerHTML = rules.map(rule => `
<tr>
<td>${rule.id}</td>
@@ -195,7 +216,6 @@ export async function initSchedule() {
function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
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 = '<div class="loading-row">Нет групп</div>';
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupsContainer.innerHTML = groups.map(group => `
<label class="checkbox-item">
@@ -330,38 +334,13 @@ export async function initSchedule() {
function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
<div class="schedule-slot-row" data-index="${index}">
<div class="form-group">
<label>День</label>
<select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select>
</div>
<div class="form-group">
<label>Чётность</label>
<select class="slot-parity" required>
${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}
</select>
</div>
<div class="form-group">
<label>Пара</label>
<select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select>
</div>
<div class="form-group">
<label>Преподаватель</label>
<select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select>
</div>
<div class="form-group">
<label>Аудитория</label>
<select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select>
</div>
<div class="form-group">
<label>Тип</label>
<select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select>
</div>
<div class="form-group">
<label>Формат</label>
<select class="slot-format" required>
${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}
</select>
</div>
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
@@ -381,7 +360,6 @@ export async function initSchedule() {
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
lessonFormat: row.querySelector('.slot-format').value
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1}`);
@@ -401,11 +379,10 @@ export async function initSchedule() {
}
function renderTimeSlots() {
if (!timeSlots || !timeSlots.length) {
if (!timeSlots.length) {
timeSlotsTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Сетка пар не настроена</td></tr>';
return;
}
timeSlotsTbody.innerHTML = timeSlots.map(slot => `
<tr>
<td>${slot.orderNumber}</td>
@@ -423,7 +400,6 @@ export async function initSchedule() {
async function saveTimeSlot(event) {
event.preventDefault();
hideAlert('time-slot-alert');
const payload = {
id: timeSlotIdInput.value ? Number(timeSlotIdInput.value) : null,
orderNumber: Number(timeSlotOrderInput.value),
@@ -431,10 +407,9 @@ export async function initSchedule() {
endTime: timeSlotEndInput.value,
durationMinutes: timeSlotDurationInput.value ? Number(timeSlotDurationInput.value) : null
};
try {
if (!payload.orderNumber || !payload.startTime || !payload.endTime) {
throw new Error('Заполните номер пары, начало и окончание');
throw new Error('Заполните номер пары и время');
}
const id = timeSlotIdInput.value;
await (id
@@ -443,7 +418,6 @@ export async function initSchedule() {
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetTimeSlotForm();
await loadTimeSlots();
renderRuleSlots(readRuleSlots(false));
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
@@ -452,7 +426,6 @@ export async function initSchedule() {
async function handleTimeSlotTableClick(event) {
const editButton = event.target.closest('.btn-edit-time-slot');
const deleteButton = event.target.closest('.btn-delete-time-slot');
if (editButton) {
const slot = timeSlots.find(item => item.id == editButton.dataset.id);
if (!slot) return;
@@ -466,7 +439,6 @@ export async function initSchedule() {
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить временной слот?')) return;
try {
@@ -500,15 +472,14 @@ export async function initSchedule() {
}
function renderAcademicYears() {
if (!academicYears || !academicYears.length) {
if (!academicYears.length) {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
return;
}
academicYearsTbody.innerHTML = academicYears.map(year => `
<tr>
<td>${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} ${escapeHtml(year.endDate)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
<td>${renderSemesterList(year.semesters || [])}</td>
<td>
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
@@ -531,17 +502,13 @@ export async function initSchedule() {
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try {
if (!payload.title || !payload.startDate || !payload.endDate) {
throw new Error('Заполните название и даты учебного года');
}
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
@@ -549,6 +516,7 @@ export async function initSchedule() {
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
}
@@ -558,7 +526,6 @@ export async function initSchedule() {
const editYearButton = event.target.closest('.btn-edit-year');
const deleteYearButton = event.target.closest('.btn-delete-year');
const editSemesterButton = event.target.closest('.btn-edit-semester');
if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return;
@@ -571,7 +538,6 @@ export async function initSchedule() {
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
@@ -586,13 +552,13 @@ export async function initSchedule() {
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами?')) return;
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error');
}
@@ -609,18 +575,14 @@ export async function initSchedule() {
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try {
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) throw new Error('Заполните учебный год, тип и даты семестра');
const id = semesterIdInput.value;
await (id
? api.put('/api/admin/calendar/semesters/' + id, payload)
@@ -641,174 +603,268 @@ export async function initSchedule() {
hideAlert('semester-alert');
}
async function saveHoliday(event) {
event.preventDefault();
hideAlert('holiday-alert');
async function loadStudyForms() {
studyForms = await api.get('/api/admin/academic-calendars/study-forms');
populateStudyFormSelect();
}
const payload = {
id: holidayIdInput.value ? Number(holidayIdInput.value) : null,
academicYearId: Number(holidayYearSelect.value),
date: holidayDateInput.value,
description: holidayDescriptionInput.value.trim()
};
async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects();
}
async function loadCalendars() {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try {
if (!payload.academicYearId || !payload.date) {
throw new Error('Выберите учебный год и дату');
}
const id = holidayIdInput.value;
await (id
? api.put('/api/admin/calendar/holidays/' + id, payload)
: api.post('/api/admin/calendar/holidays', payload));
showAlert('holiday-alert', 'Дата сохранена', 'success');
resetHolidayForm(false);
await loadHolidays();
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка сохранения даты', 'error');
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
async function loadHolidays() {
const yearId = holidayYearSelect.value;
if (!yearId) {
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Выберите учебный год</td></tr>';
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
return;
}
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка...</td></tr>';
try {
holidays = await api.get('/api/admin/calendar/holidays?academicYearId=' + encodeURIComponent(yearId));
renderHolidays();
} catch (error) {
holidaysTbody.innerHTML = `<tr><td colspan="3" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderHolidays() {
if (!holidays || !holidays.length) {
holidaysTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Исключения не добавлены</td></tr>';
return;
}
holidaysTbody.innerHTML = holidays.map(holiday => `
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(holiday.date)}</td>
<td>${escapeHtml(holiday.description || '-')}</td>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-holiday" data-id="${holiday.id}">Изменить</button>
<button class="btn-delete btn-delete-holiday" data-id="${holiday.id}">Удалить</button>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleHolidayTableClick(event) {
const editButton = event.target.closest('.btn-edit-holiday');
const deleteButton = event.target.closest('.btn-delete-holiday');
async function saveCalendar(event) {
event.preventDefault();
hideAlert('academic-calendar-alert');
const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
studyFormId: Number(calendarStudyFormSelect.value),
courseCount: Number(calendarCourseCountInput.value)
};
try {
if (!payload.title || !payload.academicYearId || !payload.specialtyId
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
throw new Error('Заполните все поля календарного графика');
}
const id = calendarIdInput.value;
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
resetCalendarForm();
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error');
}
}
async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid');
const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) {
const holiday = holidays.find(item => item.id == editButton.dataset.id);
if (!holiday) return;
holidayIdInput.value = holiday.id;
holidayYearSelect.value = holiday.academicYearId || holidayYearSelect.value;
holidayDateInput.value = holiday.date || '';
holidayDescriptionInput.value = holiday.description || '';
syncSelects(holidayYearSelect);
hideAlert('holiday-alert');
holidayForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
calendarStudyFormSelect.value = calendar.studyFormId || '';
calendarCourseCountInput.value = calendar.courseCount || 4;
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (gridButton) {
editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить дату-исключение?')) return;
if (!confirm('Удалить календарный график?')) return;
try {
await api.delete('/api/admin/calendar/holidays/' + deleteButton.dataset.id);
showAlert('holiday-alert', 'Дата удалена', 'success');
await loadHolidays();
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars();
} catch (error) {
showAlert('holiday-alert', error.message || 'Ошибка удаления даты', 'error');
showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error');
}
}
}
function resetHolidayForm(clearYear = true) {
const yearId = holidayYearSelect.value;
holidayForm.reset();
if (!clearYear) holidayYearSelect.value = yearId;
holidayIdInput.value = '';
syncSelects(holidayYearSelect);
hideAlert('holiday-alert');
function resetCalendarForm() {
calendarForm.reset();
calendarFormTitle.textContent = 'Календарный учебный график';
calendarIdInput.value = '';
calendarCourseCountInput.value = 4;
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
}
async function loadMatrix() {
hideAlert('matrix-alert');
const semesterId = matrixSemesterSelect.value;
const courseNumber = Number(matrixCourseInput.value);
const specialtyId = matrixSpecialtySelect.value;
if (!semesterId || !courseNumber || !specialtyId) {
showAlert('matrix-alert', 'Выберите семестр, курс и специальность', 'error');
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
try {
const params = new URLSearchParams({ semesterId, courseNumber: String(courseNumber), specialtyId });
const rows = await api.get('/api/admin/calendar/matrix?' + params.toString());
renderMatrix(rows && rows.length ? rows : buildDefaultMatrixRows(semesterId, courseNumber, specialtyId));
showAlert('matrix-alert', rows && rows.length ? 'Матрица загружена' : 'Матрица не найдена, подготовлена новая сетка', 'success');
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
currentGridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(currentGridRows);
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) {
showAlert('matrix-alert', error.message || 'Ошибка загрузки матрицы', 'error');
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
}
}
async function saveMatrix() {
hideAlert('matrix-alert');
const rows = Array.from(matrixGrid.querySelectorAll('.matrix-week')).map(item => ({
id: item.dataset.id ? Number(item.dataset.id) : null,
semesterId: Number(item.dataset.semesterId),
courseNumber: Number(item.dataset.courseNumber),
specialtyId: Number(item.dataset.specialtyId),
weekNumber: Number(item.dataset.weekNumber),
activityType: item.querySelector('select').value
async function saveCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id,
courseNumber: Number(day.dataset.course),
date: day.dataset.date,
weekNumber: Number(day.dataset.week),
dayOfWeek: Number(day.dataset.day),
activityTypeId: Number(day.querySelector('.calendar-day-activity').value)
}));
if (!rows.length) {
showAlert('matrix-alert', 'Сначала загрузите матрицу', 'error');
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
return;
}
try {
await api.put('/api/admin/calendar/matrix', rows);
showAlert('matrix-alert', 'Матрица учебного графика сохранена', 'success');
await loadMatrix();
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid();
} catch (error) {
showAlert('matrix-alert', error.message || 'Ошибка сохранения матрицы', 'error');
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
}
}
function renderMatrix(rows) {
matrixGrid.innerHTML = rows.map(row => `
<div class="matrix-week"
data-id="${row.id || ''}"
data-semester-id="${row.semesterId}"
data-course-number="${row.courseNumber}"
data-specialty-id="${row.specialtyId}"
data-week-number="${row.weekNumber}">
<span>${row.weekNumber}</span>
<select>${options(activityOptions(), row.activityType || 'THEORY')}</select>
</div>
`).join('');
function applyCalendarFill() {
hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value;
const start = fillStartInput.value;
const end = fillEndInput.value;
if (!activityId || !start || !end) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return;
}
if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
const course = fillCourseSelect.value;
let changed = 0;
calendarGrid.querySelectorAll('.calendar-day').forEach(day => {
const sameCourse = !course || day.dataset.course === course;
const inRange = day.dataset.date >= start && day.dataset.date <= end;
if (sameCourse && inRange) {
day.querySelector('.calendar-day-activity').value = activityId;
changed += 1;
}
});
renderCalendarTotals();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
}
function buildDefaultMatrixRows(semesterId, courseNumber, specialtyId) {
return Array.from({ length: 22 }, (_, index) => ({
id: null,
semesterId: Number(semesterId),
courseNumber,
specialtyId: Number(specialtyId),
weekNumber: index + 1,
activityType: 'THEORY'
}));
function renderCalendarGrid(rows) {
const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<section class="calendar-course-block">
<div class="calendar-course-header">
<h3>${course} курс</h3>
<span>${courseRows.length} дней</span>
</div>
<div class="calendar-days-grid">
${courseRows.map(renderCalendarDay).join('')}
</div>
</section>
`).join('');
populateFillCourseSelect();
renderCalendarTotals();
}
function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || '';
return `
<div class="calendar-day"
data-course="${row.courseNumber}"
data-date="${row.date}"
data-week="${row.weekNumber}"
data-day="${row.dayOfWeek}">
<div class="calendar-day-meta">
<span>${formatShortDate(row.date)}</span>
<span>${dayName}</span>
</div>
<select class="calendar-day-activity">${options(activityTypes.map(toActivityOption), selected)}</select>
<span class="calendar-week-label">${row.weekNumber} нед.</span>
</div>
`;
}
function buildDefaultGrid(calendar) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: Math.floor(index / 7) + 1,
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(),
activityCode: 'Т'
});
});
}
return rows;
}
function renderCalendarTotals() {
const counts = new Map();
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value);
const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1);
});
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
const activity = activityTypes.find(item => item.code === code);
return `<span class="calendar-total-chip" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}</span>`;
}).join('');
}
function populateSubjectSelects() {
@@ -820,26 +876,66 @@ export async function initSchedule() {
const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
holidayYearSelect.innerHTML = yearOptions;
if (!holidayYearSelect.value && academicYears.length) {
holidayYearSelect.value = String(academicYears[0].id);
}
syncSelects(semesterYearSelect, holidayYearSelect);
calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect);
}
function populateSemesterSelects() {
const semesterOptions = '<option value="">Выберите семестр</option>' +
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
ruleSemesterSelect.innerHTML = semesterOptions;
matrixSemesterSelect.innerHTML = semesterOptions;
syncSelects(ruleSemesterSelect, matrixSemesterSelect);
syncSelects(ruleSemesterSelect);
}
function populateSpecialtySelects() {
matrixSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
syncSelects(matrixSpecialtySelect);
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarSpecialtySelect);
}
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
select.innerHTML = profiles.length
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateStudyFormSelect() {
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
syncSelects(calendarStudyFormSelect);
}
function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
fillActivitySelect.innerHTML = activityOptions;
syncSelects(fillActivitySelect);
}
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
syncSelects(editorCalendarSelect);
}
function populateFillCourseSelect() {
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
Array.from(courses).sort((a, b) => Number(a) - Number(b))
.map(course => `<option value="${course}">${course} курс</option>`).join('');
syncSelects(fillCourseSelect);
}
function options(items, selectedValue) {
@@ -850,8 +946,8 @@ export async function initSchedule() {
}).join('');
}
function activityOptions() {
return Object.entries(ACTIVITY_LABELS).map(([value, label]) => ({ value, label }));
function toActivityOption(type) {
return { value: type.id, label: `${type.code} - ${type.name}` };
}
function toTimeSlotOption(slot) {
@@ -871,9 +967,25 @@ export async function initSchedule() {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
}
function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
}
function theoryActivityId() {
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
}
function activityIdByCode(code) {
return activityTypes.find(type => type.code === code)?.id || null;
}
function activityById(id) {
return activityTypes.find(type => String(type.id) === String(id));
}
function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} ${semester.endDate})`;
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
}
function formatSemester(rule) {
@@ -888,7 +1000,7 @@ export async function initSchedule() {
function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} ${name}` : String(code);
return name ? `${code} - ${name}` : String(code);
}
function trimTime(value) {
@@ -896,6 +1008,34 @@ export async function initSchedule() {
return String(value).slice(0, 5);
}
function formatShortDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function enumerateDates(startIso, endIso) {
const result = [];
const current = new Date(`${startIso}T00:00:00Z`);
const end = new Date(`${endIso}T00:00:00Z`);
while (current <= end) {
const iso = current.toISOString().slice(0, 10);
const jsDay = current.getUTCDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setUTCDate(current.getUTCDate() + 1);
}
return result;
}
function groupBy(items, keyFn) {
const grouped = new Map();
items.forEach(item => {
const key = keyFn(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
return grouped;
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));

View File

@@ -55,20 +55,20 @@
</div>
</div>
<!-- Table Container -->
<!-- Контейнер таблицы -->
<div class="workload-grid-container">
<table class="workload-table" id="workload-table">
<thead>
<tr id="workload-header-row">
<th class="top-left-cell">
<span class="top-label">Аудитория</span>
<span class="bottom-label">Время</span>
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
</th>
<!-- Rendered by JS -->
<!-- Заполняется через JS -->
</tr>
</thead>
<tbody id="workload-tbody">
<!-- Rendered by JS -->
<!-- Заполняется через JS -->
</tbody>
</table>
</div>

View File

@@ -40,6 +40,47 @@
</div>
</div>
<div class="card create-card">
<h2>Профили обучения</h2>
<form id="create-profile-form">
<div class="form-row">
<div class="form-group">
<label for="profile-specialty">Специальность</label>
<select id="profile-specialty" required>
<option value="">Сначала создайте специальность</option>
</select>
</div>
<div class="form-group">
<label for="profile-name">Название профиля</label>
<input type="text" id="profile-name" placeholder="Например: Безопасность автоматизированных систем" required>
</div>
<div class="form-group">
<label for="profile-description">Описание</label>
<input type="text" id="profile-description" placeholder="Необязательно">
</div>
<button type="submit" class="btn-primary">Создать профиль</button>
</div>
<div class="form-alert" id="create-profile-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Профиль</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="4" class="loading-row">Выберите специальность</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
@@ -124,3 +165,25 @@
</form>
</div>
</div>
<div class="modal-overlay" id="modal-edit-profile">
<div class="modal-content card">
<h2>Редактирование профиля</h2>
<button class="modal-close" id="modal-edit-profile-close">&times;</button>
<form id="edit-profile-form">
<input type="hidden" id="edit-profile-id">
<div class="form-row">
<div class="form-group">
<label for="edit-profile-name">Название профиля</label>
<input type="text" id="edit-profile-name" required>
</div>
<div class="form-group">
<label for="edit-profile-description">Описание</label>
<input type="text" id="edit-profile-description">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<!-- ===== Groups Tab ===== -->
<!-- ===== Вкладка групп ===== -->
<div class="card create-card">
<h2>Новая группа</h2>
<form id="create-group-form">
@@ -33,6 +33,12 @@
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="new-group-profile">Профиль обучения</label>
<select id="new-group-profile">
<option value="">Выберите специальность</option>
</select>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
@@ -60,14 +66,114 @@
<th>Кафедра</th>
<th>Курс</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="8" class="loading-row">Загрузка...</td>
<td colspan="9" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Назначение календарного графика</h2>
<form id="group-calendar-form">
<div class="form-row">
<div class="form-group">
<label for="calendar-group">Группа</label>
<select id="calendar-group">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-year">Учебный год</label>
<select id="calendar-year">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="calendar-id">Календарный график</label>
<select id="calendar-id">
<option value="">Выберите группу и год</option>
</select>
</div>
<button type="submit" class="btn-primary">Назначить</button>
</div>
<div class="form-alert" id="group-calendar-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table>
<thead>
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="group-calendar-tbody">
<tr>
<td colspan="3" class="loading-row">Выберите группу</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модальное окно редактирования группы -->
<div class="modal-overlay" id="modal-edit-group">
<div class="modal-content card">
<h2>Редактировать группу</h2>
<button class="modal-close" id="modal-edit-group-close">&times;</button>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group">
<label for="edit-group-name">Название группы</label>
<input type="text" id="edit-group-name" required>
</div>
<div class="form-group">
<label for="edit-group-size">Численность группы</label>
<input type="number" id="edit-group-size" min="1" required>
</div>
<div class="form-group">
<label for="edit-group-ef">Форма обучения</label>
<select id="edit-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="form-row" style="margin-top: 1rem;">
<div class="form-group">
<label for="edit-group-department">Кафедра</label>
<select id="edit-group-department">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="edit-group-yearStartStudy">Год начала обучения</label>
<input type="number" id="edit-group-yearStartStudy" min="2000" max="2100" required>
</div>
<div class="form-group">
<label for="edit-group-speciality-code">Специальность</label>
<select id="edit-group-speciality-code">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="edit-group-profile">Профиль обучения</label>
<select id="edit-group-profile">
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button>
</div>
</div>
<div class="form-alert" id="edit-group-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -213,41 +213,72 @@
</div>
<div class="card create-card">
<h2 id="holiday-form-title">Праздники и исключения</h2>
<form id="holiday-form" novalidate>
<input type="hidden" id="holiday-id">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="holiday-year">Учебный год</label>
<select id="holiday-year" required>
<label for="academic-calendar-title">Название</label>
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
</div>
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="holiday-date">Дата</label>
<input type="date" id="holiday-date" required>
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="holiday-description">Описание</label>
<input type="text" id="holiday-description" placeholder="Праздничный день">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<button type="submit" class="btn-primary">Сохранить дату</button>
<button type="button" class="btn-edit-classroom" id="holiday-reset">Очистить</button>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="holiday-alert" role="alert"></div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Дата</th>
<th>Описание</th>
<th>Название</th>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="holidays-tbody">
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="3" class="loading-row">Выберите учебный год</td>
<td colspan="7" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
@@ -255,27 +286,41 @@
</div>
<div class="card create-card">
<h2>Матрица учебного графика</h2>
<h2>Редактор годового графика</h2>
<div class="form-row">
<div class="form-group">
<label for="matrix-semester">Семестр</label>
<select id="matrix-semester">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="matrix-course">Курс</label>
<input type="number" id="matrix-course" min="1" step="1" placeholder="1">
</div>
<div class="form-group">
<label for="matrix-specialty">Специальность</label>
<select id="matrix-specialty">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-primary" id="matrix-load">Загрузить</button>
<button type="button" class="btn-primary" id="matrix-save">Сохранить</button>
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div>
<div class="form-alert" id="matrix-alert" role="alert"></div>
<div id="matrix-grid" class="matrix-grid"></div>
<div class="calendar-fill-panel">
<div class="form-group">
<label for="calendar-fill-course">Курс</label>
<select id="calendar-fill-course">
<option value="">Все курсы</option>
</select>
</div>
<div class="form-group">
<label for="calendar-fill-start">С даты</label>
<input type="date" id="calendar-fill-start">
</div>
<div class="form-group">
<label for="calendar-fill-end">По дату</label>
<input type="date" id="calendar-fill-end">
</div>
<div class="form-group">
<label for="calendar-fill-activity">Код</label>
<select id="calendar-fill-activity">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button>
</div>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div>
</div>