diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index c5eb562..257be18 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -46,7 +46,9 @@ public class GroupController { g.getEducationForm().getId(), g.getEducationForm().getName(), g.getDepartmentId(), + g.getEnrollmentYear(), g.getCourse(), + g.getSemester(), g.getSpecialityCode() )) .toList(); @@ -82,8 +84,8 @@ public class GroupController { @PostMapping public ResponseEntity createGroup(@RequestBody CreateGroupRequest request) { - logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, course = {}", - request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getCourse()); + logger.info("Получен запрос на создание новой группы: name = {}, groupSize = {}, educationFormId = {}, departmentId = {}, enrollmentYear = {}", + request.getName(), request.getGroupSize(), request.getEducationFormId(), request.getDepartmentId(), request.getEnrollmentYear()); try { if (request.getName() == null || request.getName().isBlank()) { String errorMessage = "Название группы обязательно"; @@ -110,8 +112,8 @@ public class GroupController { logger.error("Ошибка валидации: {}", errorMessage); return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); } - if (request.getCourse() == null || request.getCourse() == 0) { - String errorMessage = "Курс обязателен"; + if (request.getEnrollmentYear() == null || request.getEnrollmentYear() == 0) { + String errorMessage = "Год начала обучения обязателен"; logger.error("Ошибка валидации: {}", errorMessage); return ResponseEntity.badRequest().body(Map.of("message", errorMessage)); } @@ -131,7 +133,7 @@ public class GroupController { group.setGroupSize(request.getGroupSize()); group.setEducationForm(efOpt.get()); group.setDepartmentId(request.getDepartmentId()); - group.setCourse(request.getCourse()); + group.setEnrollmentYear(request.getEnrollmentYear()); group.setSpecialityCode(request.getSpecialityCode()); groupRepository.save(group); @@ -144,7 +146,9 @@ public class GroupController { group.getEducationForm().getId(), group.getEducationForm().getName(), group.getDepartmentId(), + group.getEnrollmentYear(), group.getCourse(), + group.getSemester(), group.getSpecialityCode())); } catch (Exception e ) { logger.error("Ошибка при создании группы: {}", e.getMessage(), e); diff --git a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java index 94561b0..c8963e6 100755 --- a/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java +++ b/backend/src/main/java/com/magistr/app/dto/CreateGroupRequest.java @@ -6,7 +6,7 @@ public class CreateGroupRequest { private Long groupSize; private Long educationFormId; private Long departmentId; - private Integer course; + private Integer enrollmentYear; private Long specialityCode; public String getName() { @@ -41,12 +41,12 @@ public class CreateGroupRequest { this.departmentId = departmentId; } - public Integer getCourse() { - return course; + public Integer getEnrollmentYear() { + return enrollmentYear; } - public void setCourse(Integer course) { - this.course = course; + public void setEnrollmentYear(Integer enrollmentYear) { + this.enrollmentYear = enrollmentYear; } public Long getSpecialityCode() { diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index b93cd93..7356515 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -8,17 +8,24 @@ public class GroupResponse { private Long educationFormId; private String educationFormName; private Long departmentId; + private Integer enrollmentYear; private Integer course; + private Integer semester; private Long specialityCode; - public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, String educationFormName, Long departmentId, Integer course, Long specialityCode) { + public GroupResponse(Long id, String name, Long groupSize, Long educationFormId, + String educationFormName, Long departmentId, + Integer enrollmentYear, 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.enrollmentYear = enrollmentYear; this.course = course; + this.semester = semester; this.specialityCode = specialityCode; } @@ -46,10 +53,18 @@ public class GroupResponse { return departmentId; } + public Integer getEnrollmentYear() { + return enrollmentYear; + } + public Integer getCourse() { return course; } + public Integer getSemester() { + return semester; + } + public Long getSpecialityCode() { return specialityCode; } diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroup.java b/backend/src/main/java/com/magistr/app/model/StudentGroup.java index f901887..93cac8d 100755 --- a/backend/src/main/java/com/magistr/app/model/StudentGroup.java +++ b/backend/src/main/java/com/magistr/app/model/StudentGroup.java @@ -1,5 +1,6 @@ package com.magistr.app.model; +import com.magistr.app.utils.CourseCalculator; import jakarta.persistence.*; @Entity @@ -23,8 +24,8 @@ public class StudentGroup { @Column(name = "department_id", nullable = false) private Long departmentId; - @Column(name = "course", nullable = false) - private Integer course; + @Column(name = "enrollment_year", nullable = false) + private Integer enrollmentYear; @Column(name="specialty_code", nullable = false) private Long specialityCode; @@ -72,12 +73,30 @@ public class StudentGroup { this.departmentId = departmentId; } - public Integer getCourse() { - return course; + public Integer getEnrollmentYear() { + return enrollmentYear; } - public void setCourse(Integer course) { - this.course = course; + public void setEnrollmentYear(Integer enrollmentYear) { + this.enrollmentYear = enrollmentYear; + } + + /** + * Вычисляемый курс на основе года начала обучения. + */ + @Transient + public Integer getCourse() { + if (enrollmentYear == null) return null; + return CourseCalculator.calculateCourse(enrollmentYear); + } + + /** + * Вычисляемый семестр на основе года начала обучения. + */ + @Transient + public Integer getSemester() { + if (enrollmentYear == null) return null; + return CourseCalculator.calculateSemester(enrollmentYear); } public Long getSpecialityCode() { diff --git a/backend/src/main/java/com/magistr/app/utils/CourseCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseCalculator.java new file mode 100644 index 0000000..ecc3bff --- /dev/null +++ b/backend/src/main/java/com/magistr/app/utils/CourseCalculator.java @@ -0,0 +1,53 @@ +package com.magistr.app.utils; + +import java.time.LocalDate; + +/** + * Утилитный класс для вычисления курса и семестра группы + * на основе года начала обучения. + */ +public final class CourseCalculator { + + private CourseCalculator() { + } + + /** + * Вычисляет текущий курс группы. + * До сентября студент ещё на старом курсе, с сентября — на следующем. + * + * @param enrollmentYear год начала обучения (напр. 2023) + * @return номер курса (1, 2, 3, ...) + */ + public static int calculateCourse(int enrollmentYear) { + LocalDate now = LocalDate.now(); + int currentYear = now.getYear(); + int currentMonth = now.getMonthValue(); + + // С сентября начинается новый учебный год + if (currentMonth >= 9) { + return currentYear - enrollmentYear + 1; + } else { + return currentYear - enrollmentYear; + } + } + + /** + * Вычисляет текущий семестр группы. + * Сентябрь–январь → нечётный (осенний) семестр, февраль–август → чётный (весенний). + * + * @param enrollmentYear год начала обучения (напр. 2023) + * @return номер семестра (1, 2, 3, ...) + */ + public static int calculateSemester(int enrollmentYear) { + int course = calculateCourse(enrollmentYear); + int currentMonth = LocalDate.now().getMonthValue(); + + // Сентябрь–январь: осенний (нечётный) семестр + // Февраль–август: весенний (чётный) семестр + if (currentMonth >= 9 || currentMonth <= 1) { + return (course - 1) * 2 + 1; + } else { + return (course - 1) * 2 + 2; + } + } +} diff --git a/backend/src/main/resources/db/migration/V2__editScheduleData.sql b/backend/src/main/resources/db/migration/V2__editScheduleData.sql index a99481c..405747f 100644 --- a/backend/src/main/resources/db/migration/V2__editScheduleData.sql +++ b/backend/src/main/resources/db/migration/V2__editScheduleData.sql @@ -26,4 +26,25 @@ VALUES (1, 1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), (1, 1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), (1, 2, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), (1, 3, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), - (1, 4, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); \ No newline at end of file + (1, 4, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); + +-- ========================================== +-- Год начала обучения вместо статического курса +-- ========================================== + +ALTER TABLE student_groups +ADD COLUMN IF NOT EXISTS enrollment_year INT; + +-- Обратный расчёт: enrollment_year = текущий_год - course + 1 +-- (для месяцев до сентября курс ещё не увеличился) +UPDATE student_groups +SET enrollment_year = EXTRACT(YEAR FROM NOW())::INT - course + + CASE WHEN EXTRACT(MONTH FROM NOW()) >= 9 THEN 1 ELSE 0 END; + +ALTER TABLE student_groups +ALTER COLUMN enrollment_year SET NOT NULL; + +ALTER TABLE student_groups +DROP COLUMN IF EXISTS course; + +COMMENT ON COLUMN student_groups.enrollment_year IS 'Год начала обучения группы'; \ No newline at end of file diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c025547..05c0d75 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -51,7 +51,8 @@ erDiagram BIGINT group_size BIGINT education_form_id FK BIGINT department_id FK - INT course + INT enrollment_year + INT specialty_code FK TIMESTAMP created_at } @@ -220,7 +221,10 @@ erDiagram | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `course` | INT CHECK(1–6) | Курс | +| `enrollment_year` | INT NOT NULL | Год начала обучения (напр. 2023) | +| `specialty_code` | INT FK → specialties | Код специальности | + +> **Примечание:** Курс и семестр **вычисляются динамически** на основе `enrollment_year` и текущей даты (утилита `CourseCalculator.java`). В БД не хранятся. #### `subgroups` — Подгруппы | Колонка | Тип | Описание | @@ -341,6 +345,7 @@ erDiagram | Файл | Описание | |------|----------| | `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | +| `V2__editScheduleData.sql` | Добавление `specialty_code`, тестовые данные расписания, замена `course` → `enrollment_year` | ### Накатывание на существующих тенантов diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index cd23644..33b7a65 100755 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -17,7 +17,7 @@ export async function initGroups() { populateEfSelects(educationForms); await loadGroups(); } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки данных'; + groupsTbody.innerHTML = 'Ошибка загрузки данных'; } } @@ -26,7 +26,7 @@ export async function initGroups() { allGroups = await api.get('/api/groups'); applyGroupFilter(); } catch (e) { - groupsTbody.innerHTML = 'Ошибка загрузки'; + groupsTbody.innerHTML = 'Ошибка загрузки'; } } @@ -61,7 +61,7 @@ export async function initGroups() { function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + groupsTbody.innerHTML = 'Нет групп'; return; } groupsTbody.innerHTML = groups.map(g => ` @@ -71,7 +71,9 @@ export async function initGroups() { ${escapeHtml(g.groupSize)} ${escapeHtml(g.educationFormName)} ${g.departmentId || '-'} + ${g.enrollmentYear || '-'} ${g.course || '-'} + ${g.semester || '-'} `).join(''); } @@ -83,13 +85,13 @@ export async function initGroups() { const groupSize = document.getElementById('new-group-size').value; const educationFormId = newGroupEfSelect.value; const departmentId = document.getElementById('new-group-department').value; - const course = document.getElementById('new-group-course').value; + const enrollmentYear = document.getElementById('new-group-enrollment-year').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', 'Введите ID кафедры', 'error'); return; } - if (!course) { showAlert('create-group-alert', 'Введите курс', 'error'); return; } + if (!enrollmentYear) { showAlert('create-group-alert', 'Введите год начала обучения', 'error'); return; } try { const data = await api.post('/api/groups', { @@ -97,7 +99,7 @@ export async function initGroups() { groupSize: Number(groupSize), educationFormId: Number(educationFormId), departmentId: Number(departmentId), - course: Number(course) + enrollmentYear: Number(enrollmentYear) }); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || name)}" создана`, 'success'); createGroupForm.reset(); diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index ae7880b..c74748c 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -22,8 +22,8 @@
- - + +
@@ -50,13 +50,15 @@ Численность (чел.) Форма обучения ID кафедры + Год начала Курс + Семестр Действия - Загрузка... + Загрузка... diff --git a/tz2.md b/tz2.md new file mode 100644 index 0000000..594cb47 --- /dev/null +++ b/tz2.md @@ -0,0 +1,120 @@ +# План выполнения работ по новым интерфейсам расписания + +На основе предоставленного технического задания составлен следующий детализированный план разработки макетов и функционала новой подсистемы составления и просмотра расписания. + +## 1. Вкладка "Загрузка аудиторий" + +**Концепция:** Динамическая таблица, визуализирующая текущее использование аудиторного фонда в конкретную учебную неделю. + +### Интерфейс +* **Сетка данных:** + * **Столбцы:** Аудитории. + * **Строки:** Время пар (расписание звонков). + * **Ячейки:** Информация о проходящем занятии (Группа, Преподаватель, Дисциплина). +* **Элементы управления:** + * **Календарь недель:** Выпадающий список или слайдер для переключения между учебными неделями семестра. Учитывает изменения в графике (сессии, приезд заочников и т.д.). + * **Фильтр аудиторий:** Чекбоксы, мультиселект или группировка по корпусам/типам, позволяющие скрывать неотображаемые аудитории для удобства просмотра. + +### Функционал +* Отображение данных на основе сохраненного расписания из БД с привязкой к выбранной неделе. +* **Интерактивность:** Возможность клика по пустой ячейке для добавления нового занятия. Открывается модальное окно с предзаполненными полями `Аудитория`, `Время` и `Неделя`. + +--- + +## 2. Вкладка "Загруженность преподавателей" + +**Концепция:** Интерфейс, дублирующий логику загрузки аудиторий, но с фокусом на профессорско-преподавательский состав (ППС). + +### Интерфейс +* **Сетка данных:** + * **Столбцы:** Список преподавателей. + * **Строки:** Время пар. + * **Ячейки:** Информация о занятии (Группа, Аудитория, Дисциплина). +* **Элементы управления:** + * Календарь недель (аналогично аудиториям). + * Поиск/фильтрация по ФИО преподавателя или кафедре. + +--- + +## 3. Рабочее окно составителя расписания + +**Концепция:** Основной инструмент диспетчера. Интерактивная среда для распределения нагрузки. + +### Интерфейс +* **Сетка расписания:** + * **Столбцы:** Учебные группы. + * **Строки:** Время пар. +* **Панель нагрузки:** Боковая панель или вызываемое окно со списком нераспределенных предметов для выбранной группы/курса. + +### Алгоритм работы (User Flow) +1. **Старт:** Диспетчер кликает в конкретную ячейку сетки (выбирает группу и время проведения пары). +2. **Выбор предмета:** Появляется меню со списком предметов, которые необходимо поставить данной группе. Диспетчер выбирает нужный. +3. **Выбор преподавателя и проверка его занятости:** + * Система автоматически подтягивает преподавателя (или список возможных), закрепленного за этой дисциплиной. + * Отображается **карта свободных слотов преподавателя**, чтобы убедиться, что он не ведет пару в это же время у другой группы (предотвращение накладок). +4. **Выбор аудитории (Умный подбор):** + * Если преподаватель свободен, всплывает **карта загрузки аудиторий**. + * Аудитории отображаются с цветовой индикацией: + * 🟢 **Зеленый:** Аудитория свободна и её характеристики (тип, вместимость) полностью подходят для занятия. + * 🟡 **Желтый:** Аудитория свободна, но не подходит по требованиям (например, это лекционный зал для маленькой группы, или обычная аудитория для компьютерного практикума). + * 🔴 **Красный:** Аудитория занята (при наведении или клике показывается, кто именно там занимается). + * Диспетчер выбирает подходящую аудиторию. +5. **Финал:** Занятие фиксируется в сетке, предмет вычитается из пула нераспределенной нагрузки. + +--- + +## Этапы реализации и анализ архитектуры БД + +На основе анализа существующей базы данных проекта (см. `docs/DATABASE.md`) выявлено, что значительная часть необходимых данных уже присутствует, однако для полного удовлетворения ТЗ требуются точечные доработки структуры БД. + +### Анализ требований ТЗ и текущей БД + +1. **"Аудитории: нет пункта о том, в каком корпусе она находится"**: + * **Текущее состояние в БД**: В таблице `classrooms` **уже существуют** поля `building` (Корпус) и `floor` (Этаж). + * **Вывод**: Добавление характеристик корпуса в БД не требуется. Информацию нужно просто вывести через Backend API на Frontend. +2. **Динамическое расписание и календарь недель ("закончился семестр у магистров", "заочники")**: + * **Текущее состояние в БД**: Таблица `lessons` содержит поле `week` (с текстовыми значениями `Верхняя / Нижняя / Обе`), что подразумевает статический цикличный график (раз в 2 недели). + * **Чего не хватает**: Текущая схема не позволяет гибко привязывать занятия к конкретным календарным датам или конкретным учебным неделям семестра (например, с 1 по 18 неделю). + * **Вывод**: Потребуется миграция БД для внедрения календаря (например, таблица `academic_weeks` или изменение структуры `lessons`). +3. **Умный подбор аудиторий (желтая индикация — "не подходит оборудование")**: + * **Текущее состояние в БД**: Есть таблица `classroom_equipments`, описывающая инвентарь аудитории. + * **Чего не хватает**: В системе отсутствует информация о том, какое оборудование **требуется** для конкретной дисциплины. + * **Вывод**: Необходимо добавить новую связующую таблицу (например, `subject_equipments` или `lesson_type_equipments`), чтобы алгоритм мог сопоставлять требования предмета с оснащением выбранной аудитории. +4. **Списки нагрузки для распределения**: + * **Текущее состояние в БД**: Присутствует таблица `schedule_data` со столбцом `number_of_hours` (часы, подлежащие распределению). + * **Вывод**: Архитектура готова. Потребуется лишь бизнес-логика для связывания созданных записей `lessons` с нераспределенной нагрузкой `schedule_data` (для вычета распределенных часов). + +--- + +### Детализированный план реализации + +#### Этап 1: Доработка базы данных (Flyway миграции) +* **Миграция БД (Календарь):** Проектирование и создание механизма привязки расписания к конкретным неделям/датам, отход от жесткой привязки "Верхняя/Нижняя". +* **Миграция БД (Оборудование):** Создание таблицы для хранения технических требований дисциплин к аудиториям (`subject_equipments`), чтобы стала возможна "желтая" индикация. +* *(Напоминание: все миграции создаются как новые файлы `V2__...sql`, `V3__...sql` в директории `db/migration/`, изменение `V1__init.sql` запрещено).* + +#### Этап 2: Разработка Backend API (Java Spring Boot) +* **Эндпоинты получения видов (View API):** + * API для сетки аудиторий: агрегация занятий по аудиториям с учетом выбранной недели. + * API для сетки преподавателей: агрегация занятий по преподавателям. + * API нераспределенной нагрузки: получение остатка часов из `schedule_data` для выбранной группы. +* **Интеллектуальные алгоритмы проверок (Service Layer):** + * Логика проверки накладок преподавателей. + * Алгоритм "Цветофор" для аудитории: + * Красный (занятость по времени). + * Желтый (сопоставление вместимости `capacity` с `group_size` + проверка наличия нужного оборудования). + * Зеленый (все проверки пройдены). + +#### Этап 3: UI-разработка (Frontend) +* Верстка трех основных табличных сеток (Audience Load, Teacher Workload, Schedule Maker). +* Реализация календаря/селектора недель (влияющего на выводимые данные). +* Программирование интерактивного Flow диспетчера в Vanilla JS: + 1. Клик в ячейку. + 2. Вызов списка нагрузки -> выбор предмета. + 3. Отображение свободных слотов преподавателя. + 4. Вывод карты аудиторий с динамической цветовой индикацией. + 5. Сохранение результата. + +#### Этап 4: Интеграция и стабилизация +* Интеграция Front и Back-частей. +* Сквозное тестирование сценариев создания, редактирования и удаления занятий с пересчетом часов нагрузки.