1 Commits

10 changed files with 270 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
(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 'Год начала обучения группы';

View File

@@ -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(16) | Курс |
| `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` |
### Накатывание на существующих тенантов

View File

@@ -17,7 +17,7 @@ export async function initGroups() {
populateEfSelects(educationForms);
await loadGroups();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки данных</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
@@ -26,7 +26,7 @@ export async function initGroups() {
allGroups = await api.get('/api/groups');
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Ошибка загрузки</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Ошибка загрузки</td></tr>';
}
}
@@ -61,7 +61,7 @@ export async function initGroups() {
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет групп</td></tr>';
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
@@ -71,7 +71,9 @@ export async function initGroups() {
<td>${escapeHtml(g.groupSize)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td>${g.departmentId || '-'}</td>
<td>${g.enrollmentYear || '-'}</td>
<td>${g.course || '-'}</td>
<td>${g.semester || '-'}</td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).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();

View File

@@ -22,8 +22,8 @@
<input type="number" id="new-group-department" placeholder="ID" required>
</div>
<div class="form-group">
<label for="new-group-course">Курс</label>
<input type="number" id="new-group-course" placeholder="1-6" min="1" max="6" required>
<label for="new-group-enrollment-year">Год начала обучения</label>
<input type="number" id="new-group-enrollment-year" placeholder="2023" min="2000" max="2100" required>
</div>
<button type="submit" class="btn-primary">Создать</button>
</div>
@@ -50,13 +50,15 @@
<th>Численность (чел.)</th>
<th>Форма обучения</th>
<th>ID кафедры</th>
<th>Год начала</th>
<th>Курс</th>
<th>Семестр</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
<td colspan="9" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>

120
tz2.md Normal file
View File

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