diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..2ef8f23 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,21 @@ +#:schema https://developers.openai.com/codex/config-schema.json + +web_search = "live" + +developer_instructions = """ +Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. +Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. +При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. +Используй проектные скиллы из .agents/skills, когда задача соответствует их description. +""" + +[skills] +include_instructions = true + +[[skills.config]] +path = "../.agents/skills/auto-update-docs" +enabled = true + +[[skills.config]] +path = "../.agents/skills/frontend-design" +enabled = true diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md new file mode 100644 index 0000000..10dde97 --- /dev/null +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -0,0 +1,317 @@ +# Реализация динамического расписания + +Этот файл фиксирует текущий результат внедрения динамической системы расписания: что уже добавлено в проект, как связаны база данных, backend, API и frontend, как работает генератор расписания и какие ограничения остаются для следующего этапа. + +## Статус внедрения + +Реализован первый рабочий срез динамического расписания. Новая модель теперь является базовой схемой проекта и создаётся сразу в `V1__init.sql`. + +Старые таблицы `lessons` и `schedule_data` пока физически остаются в `V1__init.sql`, потому что связанный старый код будет удаляться отдельным шагом. Они больше не заполняются тестовыми данными и не являются источником для новой модели расписания. + +## Что добавлено + +### База данных + +Динамическая схема встроена в базовую Flyway-миграцию `V1__init.sql`. + +Она создаёт следующие таблицы: + +| Таблица | Назначение | +|---------|------------| +| `time_slots` | Настраиваемая сетка пар: номер, время начала, время окончания, длительность | +| `academic_years` | Учебные годы | +| `semesters` | Семестры учебного года, от `start_date` считается первая неделя | +| `holidays` | Праздники и исключённые даты, когда занятия не проводятся | +| `academic_calendar_matrix` | Матрица учебного графика по семестру, курсу, специальности и неделе | +| `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 + +Добавлены 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` | Генерация расписания группы или преподавателя на диапазон дат | + +Старый `LessonsController` пока физически присутствует в коде и помечен как `@Deprecated`. Это технический хвост до удаления старых экранов и таблиц, а не целевой слой совместимости. + +### 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/admin/time-slots` | CRUD временных слотов | +| `/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/schedule-rules` | CRUD правил расписания | + +### Авторизация + +Ответ `POST /api/auth/login` расширен полем `userId`. + +Frontend сохраняет его в `localStorage`, чтобы кабинет преподавателя мог запрашивать личное расписание через: + +```http +GET /api/schedule?teacherId={userId}&startDate=...&endDate=... +``` + +### Frontend + +Заглушки кабинетов заменены на базовые экраны просмотра расписания. + +#### Кабинет студента + +Файл: `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` | 0 | +| `schedule_data` | 0 | + +Также выполнен: + +```bash +git diff --check +``` + +Результат: замечаний по whitespace нет. + +## Ограничения текущего среза + +Этот срез создаёт рабочее ядро динамического расписания, но ещё не закрывает весь продуктовый объём. + +Ограничения: + +- полноценный UI конструктора правил в админке ещё не реализован; +- визуальный редактор матрицы учебного графика в админке ещё не реализован; +- UI управления праздниками и учебными годами пока доступен только через API; +- строгая проверка конфликтов преподавателей, аудиторий и групп при сохранении правил ещё не реализована; +- студент пока выбирает группу вручную, потому что в модели пользователя нет связи `student -> group`; +- старые таблицы и контроллеры `lessons` / `schedule_data` ещё физически есть, но больше не используются для seed-расписания. + +## Следующий этап + +Рекомендуемый следующий этап: + +1. Добавить в админку вкладку настройки временных слотов. +2. Добавить UI учебных годов, семестров и праздников. +3. Добавить визуальную матрицу учебного графика. +4. Добавить конструктор правил расписания. +5. Реализовать backend-валидацию конфликтов: + - преподаватель не может вести разные занятия одновременно; + - аудитория не может быть занята двумя разными занятиями одновременно; + - группа не может быть на двух занятиях одновременно, кроме корректного деления по подгруппам. +6. Удалить старые экраны, контроллеры, DTO, модели и репозитории `lessons` / `schedule_data`. +7. Удалить старые таблицы `lessons` и `schedule_data` из `V1__init.sql`. + +## Связанные документы + +- `SCHEDULE_PROPOSAL.md` — исходная концепция динамического расписания. +- `SCHEDULE_TASKS.md` — исходная декомпозиция задач. +- `docs/API.md` — описание REST API. +- `docs/DATABASE.md` — описание схемы БД. +- `docs/BUSINESS_LOGIC.md` — бизнес-правила. +- `docs/FRONTEND.md` — frontend-архитектура. diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java new file mode 100644 index 0000000..d3c1214 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -0,0 +1,317 @@ +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.Comparator; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/calendar") +public class AcademicCalendarAdminController { + + private final AcademicYearRepository academicYearRepository; + private final SemesterRepository semesterRepository; + private final HolidayRepository holidayRepository; + private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, + SemesterRepository semesterRepository, + HolidayRepository holidayRepository, + AcademicCalendarMatrixRepository calendarMatrixRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.academicYearRepository = academicYearRepository; + this.semesterRepository = semesterRepository; + this.holidayRepository = holidayRepository; + this.calendarMatrixRepository = calendarMatrixRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping("/years") + public List getYears() { + return academicYearRepository.findAll().stream() + .sorted(Comparator.comparing(AcademicYear::getStartDate).reversed()) + .map(this::toAcademicYearDto) + .toList(); + } + + @PostMapping("/years") + public ResponseEntity createYear(@RequestBody AcademicYearDto request) { + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (academicYearRepository.findByTitle(request.title()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год с таким названием уже существует")); + } + + AcademicYear year = new AcademicYear(); + applyYear(year, request); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @PutMapping("/years/{id}") + public ResponseEntity updateYear(@PathVariable Long id, @RequestBody AcademicYearDto request) { + AcademicYear year = academicYearRepository.findById(id).orElse(null); + if (year == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateYear(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applyYear(year, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toAcademicYearDto(academicYearRepository.save(year))); + } + + @DeleteMapping("/years/{id}") + public ResponseEntity deleteYear(@PathVariable Long id) { + if (!academicYearRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + academicYearRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Учебный год удалён")); + } + + @GetMapping("/years/{academicYearId}/semesters") + public List getSemesters(@PathVariable Long academicYearId) { + return semesterRepository.findByAcademicYearIdOrderByStartDateAsc(academicYearId).stream() + .map(this::toSemesterDto) + .toList(); + } + + @PostMapping("/years/{academicYearId}/semesters") + public ResponseEntity createSemester(@PathVariable Long academicYearId, @RequestBody SemesterDto request) { + AcademicYear year = academicYearRepository.findById(academicYearId).orElse(null); + if (year == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Учебный год не найден")); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (semesterRepository.findByAcademicYearIdAndSemesterType(academicYearId, request.semesterType()).isPresent()) { + return ResponseEntity.badRequest().body(Map.of("message", "Такой семестр уже есть в учебном году")); + } + + Semester semester = new Semester(); + semester.setAcademicYear(year); + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toSemesterDto(semesterRepository.save(semester))); + } + + @PutMapping("/semesters/{id}") + public ResponseEntity updateSemester(@PathVariable Long id, @RequestBody SemesterDto request) { + Semester semester = semesterRepository.findById(id).orElse(null); + if (semester == null) { + return ResponseEntity.notFound().build(); + } + String validationError = validateSemester(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + applySemester(semester, request); + scheduleGeneratorService.clearCache(); + 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()); + } + + @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", "Учебный год не найден")); + } + if (request.date() == null) { + 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()); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + } + + @PutMapping("/holidays/{id}") + public ResponseEntity updateHoliday(@PathVariable Long id, @RequestBody HolidayDto request) { + Holiday holiday = holidayRepository.findById(id).orElse(null); + if (holiday == null) { + return ResponseEntity.notFound().build(); + } + if (request.date() == null) { + return ResponseEntity.badRequest().body(Map.of("message", "Дата праздника обязательна")); + } + holiday.setDate(request.date()); + holiday.setDescription(request.description()); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toHolidayDto(holidayRepository.save(holiday))); + } + + @DeleteMapping("/holidays/{id}") + public ResponseEntity deleteHoliday(@PathVariable Long id) { + if (!holidayRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + holidayRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Праздник удалён")); + } + + @GetMapping("/matrix") + public ResponseEntity getMatrix(@RequestParam Long semesterId, + @RequestParam Integer courseNumber, + @RequestParam Long specialtyId) { + if (!semesterRepository.existsById(semesterId)) { + return ResponseEntity.badRequest().body(Map.of("message", "Семестр не найден")); + } + return ResponseEntity.ok(calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc(semesterId, courseNumber, specialtyId) + .stream() + .map(this::toMatrixDto) + .toList()); + } + + @PutMapping("/matrix") + @Transactional + public ResponseEntity saveMatrix(@RequestBody List rows) { + if (rows == null || rows.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну строку матрицы")); + } + + try { + for (AcademicCalendarMatrixDto row : rows) { + Semester semester = semesterRepository.findById(row.semesterId()) + .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); + if (row.courseNumber() == null || row.courseNumber() <= 0 + || row.specialtyId() == null + || row.weekNumber() == null || row.weekNumber() <= 0) { + throw new IllegalArgumentException("Курс, специальность и номер недели обязательны"); + } + AcademicCalendarMatrix matrix = calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + row.semesterId(), + row.courseNumber(), + row.specialtyId(), + row.weekNumber() + ) + .orElseGet(AcademicCalendarMatrix::new); + matrix.setSemester(semester); + matrix.setCourseNumber(row.courseNumber()); + matrix.setSpecialtyId(row.specialtyId()); + matrix.setWeekNumber(row.weekNumber()); + matrix.setActivityType(row.activityType() == null ? AcademicActivityType.THEORY : row.activityType()); + calendarMatrixRepository.save(matrix); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Матрица учебного графика сохранена")); + } + + private String validateYear(AcademicYearDto request) { + if (request.title() == null || request.title().isBlank()) { + return "Название учебного года обязательно"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания учебного года обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания не может быть раньше даты начала"; + } + return null; + } + + private String validateSemester(SemesterDto request) { + if (request.semesterType() == null) { + return "Тип семестра обязателен"; + } + if (request.startDate() == null || request.endDate() == null) { + return "Даты начала и окончания семестра обязательны"; + } + if (request.endDate().isBefore(request.startDate())) { + return "Дата окончания семестра не может быть раньше даты начала"; + } + return null; + } + + private void applyYear(AcademicYear year, AcademicYearDto request) { + year.setTitle(request.title().trim()); + year.setStartDate(request.startDate()); + year.setEndDate(request.endDate()); + } + + private void applySemester(Semester semester, SemesterDto request) { + semester.setSemesterType(request.semesterType()); + semester.setStartDate(request.startDate()); + semester.setEndDate(request.endDate()); + } + + private AcademicYearDto toAcademicYearDto(AcademicYear year) { + List semesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(year.getId()) + .stream() + .map(this::toSemesterDto) + .toList(); + return new AcademicYearDto(year.getId(), year.getTitle(), year.getStartDate(), year.getEndDate(), semesters); + } + + private SemesterDto toSemesterDto(Semester semester) { + return new SemesterDto( + semester.getId(), + semester.getAcademicYear().getId(), + semester.getAcademicYear().getTitle(), + semester.getSemesterType(), + semester.getStartDate(), + semester.getEndDate() + ); + } + + 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() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java index 14c7f2a..7ef5b69 100755 --- a/backend/src/main/java/com/magistr/app/controller/AuthController.java +++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java @@ -47,6 +47,6 @@ public class AuthController { String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); Long departmentId = user.getDepartmentId(); - return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId)); + return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId())); } } diff --git a/backend/src/main/java/com/magistr/app/controller/LessonsController.java b/backend/src/main/java/com/magistr/app/controller/LessonsController.java index 3fe103c..d0b4119 100755 --- a/backend/src/main/java/com/magistr/app/controller/LessonsController.java +++ b/backend/src/main/java/com/magistr/app/controller/LessonsController.java @@ -162,6 +162,7 @@ public class LessonsController { } //Запрос для получения всего списка занятий + @Deprecated @GetMapping public List getAllLessons() { logger.info("Запрос на получение всех занятий"); @@ -221,6 +222,7 @@ public class LessonsController { } //Запрос на получение всех занятий для конкретного преподавателя + @Deprecated @GetMapping("/{teacherId}") public ResponseEntity getLessonsById(@PathVariable Long teacherId) { logger.info("Запрос на получение занятий для преподавателя с ID: {}", teacherId); @@ -504,4 +506,4 @@ public class LessonsController { response.put("time", lesson.getTime()); return response; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java new file mode 100644 index 0000000..58cea9d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -0,0 +1,56 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.service.ScheduleGeneratorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule") +public class ScheduleController { + + private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class); + + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleController(ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public ResponseEntity getSchedule( + @RequestParam(required = false) Long groupId, + @RequestParam(required = false) Long teacherId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ) { + logger.info("Запрос динамического расписания: groupId={}, teacherId={}, startDate={}, endDate={}", + groupId, teacherId, startDate, endDate); + + if ((groupId == null && teacherId == null) || (groupId != null && teacherId != null)) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Передайте ровно один параметр: groupId или teacherId")); + } + + try { + List lessons = groupId != null + ? scheduleGeneratorService.buildScheduleForGroup(groupId, startDate, endDate) + : scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); + return ResponseEntity.ok(lessons); + } catch (IllegalArgumentException e) { + logger.info("Ошибка запроса динамического расписания: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (Exception e) { + logger.error("Ошибка генерации динамического расписания: {}", e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(Map.of("message", "Произошла ошибка при генерации расписания")); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java new file mode 100644 index 0000000..3787a44 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -0,0 +1,253 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.ScheduleRuleDto; +import com.magistr.app.dto.ScheduleRuleSlotDto; +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.time.DayOfWeek; +import java.util.*; + +@RestController +@RequestMapping("/api/admin/schedule-rules") +public class ScheduleRuleAdminController { + + private final ScheduleRuleRepository scheduleRuleRepository; + private final SubjectRepository subjectRepository; + private final SemesterRepository semesterRepository; + private final GroupRepository groupRepository; + private final TimeSlotRepository timeSlotRepository; + private final UserRepository userRepository; + private final ClassroomRepository classroomRepository; + private final LessonTypesRepository lessonTypesRepository; + private final ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository, + SubjectRepository subjectRepository, + SemesterRepository semesterRepository, + GroupRepository groupRepository, + TimeSlotRepository timeSlotRepository, + UserRepository userRepository, + ClassroomRepository classroomRepository, + LessonTypesRepository lessonTypesRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.subjectRepository = subjectRepository; + this.semesterRepository = semesterRepository; + this.groupRepository = groupRepository; + this.timeSlotRepository = timeSlotRepository; + this.userRepository = userRepository; + this.classroomRepository = classroomRepository; + this.lessonTypesRepository = lessonTypesRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @GetMapping + public List getAll(@RequestParam(required = false) Long semesterId, + @RequestParam(required = false) Long groupId) { + return scheduleRuleRepository.findAllWithDetails().stream() + .filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId)) + .filter(rule -> groupId == null || rule.getGroups().stream().anyMatch(group -> Objects.equals(group.getId(), groupId))) + .map(this::toDto) + .toList(); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return scheduleRuleRepository.findByIdWithDetails(id) + .>map(rule -> ResponseEntity.ok(toDto(rule))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + @Transactional + public ResponseEntity create(@RequestBody ScheduleRuleDto request) { + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + ScheduleRule rule = new ScheduleRule(); + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @PutMapping("/{id}") + @Transactional + public ResponseEntity update(@PathVariable Long id, @RequestBody ScheduleRuleDto request) { + ScheduleRule rule = scheduleRuleRepository.findByIdWithDetails(id).orElse(null); + if (rule == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validateRule(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + + try { + applyRule(rule, request); + ScheduleRule saved = scheduleRuleRepository.save(rule); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(toDto(saved)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!scheduleRuleRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + scheduleRuleRepository.deleteById(id); + scheduleGeneratorService.clearCache(); + return ResponseEntity.ok(Map.of("message", "Правило расписания удалено")); + } + + private String validateRule(ScheduleRuleDto request) { + if (request.subjectId() == null) { + return "Дисциплина обязательна"; + } + if (request.semesterId() == null) { + return "Семестр обязателен"; + } + if (request.activeFromDate() == null) { + return "Дата начала правила обязательна"; + } + if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) { + return "Количество академических часов должно быть больше нуля"; + } + if (request.groupIds() == null || request.groupIds().isEmpty()) { + return "Нужно выбрать хотя бы одну группу"; + } + if (request.slots() == null || request.slots().isEmpty()) { + return "Добавьте хотя бы один слот занятия"; + } + return null; + } + + private void applyRule(ScheduleRule rule, ScheduleRuleDto request) { + Subject subject = subjectRepository.findById(request.subjectId()) + .orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена")); + Semester semester = semesterRepository.findById(request.semesterId()) + .orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); + List groups = groupRepository.findAllById(request.groupIds()); + if (groups.size() != new HashSet<>(request.groupIds()).size()) { + throw new IllegalArgumentException("Одна или несколько групп не найдены"); + } + + rule.setSubject(subject); + rule.setSemester(semester); + rule.setActiveFromDate(request.activeFromDate()); + rule.setTotalAcademicHours(request.totalAcademicHours()); + rule.getGroups().clear(); + rule.getGroups().addAll(groups); + + rule.getSlots().clear(); + for (ScheduleRuleSlotDto slotDto : request.slots()) { + rule.getSlots().add(buildSlot(rule, slotDto)); + } + } + + private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) { + if (slotDto.dayOfWeek() == null || slotDto.dayOfWeek() < 1 || slotDto.dayOfWeek() > 7) { + throw new IllegalArgumentException("День недели должен быть от 1 до 7"); + } + if (slotDto.parity() == null) { + throw new IllegalArgumentException("Чётность недели обязательна"); + } + TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId()) + .orElseThrow(() -> new IllegalArgumentException("Временной слот не найден")); + User teacher = userRepository.findById(slotDto.teacherId()) + .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); + Classroom classroom = classroomRepository.findById(slotDto.classroomId()) + .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); + LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); + if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { + throw new IllegalArgumentException("Формат занятия обязателен"); + } + + ScheduleRuleSlot slot = new ScheduleRuleSlot(); + slot.setScheduleRule(rule); + slot.setDayOfWeek(slotDto.dayOfWeek()); + slot.setParity(slotDto.parity()); + slot.setTimeSlot(timeSlot); + slot.setSubgroupId(slotDto.subgroupId()); + slot.setTeacher(teacher); + slot.setClassroom(classroom); + slot.setLessonType(lessonType); + slot.setLessonFormat(slotDto.lessonFormat().trim()); + return slot; + } + + private ScheduleRuleDto toDto(ScheduleRule rule) { + List groups = rule.getGroups().stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + List slots = rule.getSlots().stream() + .sorted(Comparator + .comparing(ScheduleRuleSlot::getDayOfWeek) + .thenComparing(slot -> slot.getTimeSlot().getOrderNumber()) + .thenComparing(ScheduleRuleSlot::getId)) + .map(this::toSlotDto) + .toList(); + + return new ScheduleRuleDto( + rule.getId(), + rule.getSubject().getId(), + rule.getSubject().getName(), + rule.getSemester().getId(), + rule.getSemester().getAcademicYear().getTitle(), + rule.getSemester().getSemesterType(), + rule.getActiveFromDate(), + rule.getTotalAcademicHours(), + groups.stream().map(StudentGroup::getId).toList(), + groups.stream().map(StudentGroup::getName).toList(), + slots + ); + } + + private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { + TimeSlot timeSlot = slot.getTimeSlot(); + return new ScheduleRuleSlotDto( + slot.getId(), + slot.getDayOfWeek(), + dayName(slot.getDayOfWeek()), + slot.getParity(), + timeSlot.getId(), + timeSlot.getOrderNumber(), + formatTimeSlot(timeSlot), + slot.getSubgroupId(), + slot.getTeacher().getId(), + ScheduleGeneratorService.displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat() + ); + } + + private String dayName(Integer dayOfWeek) { + if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) { + return "Неизвестно"; + } + return ScheduleGeneratorService.dayName(DayOfWeek.of(dayOfWeek)); + } + + private String formatTimeSlot(TimeSlot timeSlot) { + return timeSlot.getStartTime() + " - " + timeSlot.getEndTime(); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java new file mode 100644 index 0000000..987308d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -0,0 +1,106 @@ +package com.magistr.app.controller; + +import com.magistr.app.dto.TimeSlotDto; +import com.magistr.app.model.TimeSlot; +import com.magistr.app.repository.TimeSlotRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/time-slots") +public class TimeSlotAdminController { + + private final TimeSlotRepository timeSlotRepository; + + public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) { + this.timeSlotRepository = timeSlotRepository; + } + + @GetMapping + public List getAll() { + return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream() + .map(this::toDto) + .toList(); + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeSlotDto request) { + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) { + return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + } + + TimeSlot timeSlot = new TimeSlot(); + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeSlotDto request) { + TimeSlot timeSlot = timeSlotRepository.findById(id).orElse(null); + if (timeSlot == null) { + return ResponseEntity.notFound().build(); + } + + String validationError = validate(request); + if (validationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", validationError)); + } + if (timeSlotRepository.existsByOrderNumber(request.orderNumber()) + && !timeSlot.getOrderNumber().equals(request.orderNumber())) { + return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует")); + } + + apply(timeSlot, request); + return ResponseEntity.ok(toDto(timeSlotRepository.save(timeSlot))); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + if (!timeSlotRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + timeSlotRepository.deleteById(id); + return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); + } + + private String validate(TimeSlotDto request) { + if (request.orderNumber() == null || request.orderNumber() <= 0) { + return "Номер пары должен быть больше нуля"; + } + if (request.startTime() == null || request.endTime() == null) { + return "Время начала и окончания обязательно"; + } + if (!request.startTime().isBefore(request.endTime())) { + return "Время начала должно быть раньше времени окончания"; + } + return null; + } + + private void apply(TimeSlot timeSlot, TimeSlotDto request) { + timeSlot.setOrderNumber(request.orderNumber()); + timeSlot.setStartTime(request.startTime()); + timeSlot.setEndTime(request.endTime()); + int duration = request.durationMinutes() != null && request.durationMinutes() > 0 + ? request.durationMinutes() + : (int) Duration.between(request.startTime(), request.endTime()).toMinutes(); + timeSlot.setDurationMinutes(duration); + } + + private TimeSlotDto toDto(TimeSlot timeSlot) { + return new TimeSlotDto( + timeSlot.getId(), + timeSlot.getOrderNumber(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + timeSlot.getDurationMinutes() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java new file mode 100644 index 0000000..6fad2dd --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicCalendarMatrixDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.AcademicActivityType; + +public record AcademicCalendarMatrixDto( + Long id, + Long semesterId, + Integer courseNumber, + Long specialtyId, + Integer weekNumber, + AcademicActivityType activityType +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java new file mode 100644 index 0000000..5671392 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/AcademicYearDto.java @@ -0,0 +1,13 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; +import java.util.List; + +public record AcademicYearDto( + Long id, + String title, + LocalDate startDate, + LocalDate endDate, + List semesters +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/HolidayDto.java b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java new file mode 100644 index 0000000..8c0bc19 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/HolidayDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +import java.time.LocalDate; + +public record HolidayDto( + Long id, + LocalDate date, + Long academicYearId, + String academicYearTitle, + String description +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java index 851619b..8b8b4e8 100755 --- a/backend/src/main/java/com/magistr/app/dto/LoginResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/LoginResponse.java @@ -8,17 +8,23 @@ public class LoginResponse { private String role; private String redirect; private Long departmentId; + private Long userId; public LoginResponse() { } public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId) { + this(success, message, token, role, redirect, departmentId, null); + } + + public LoginResponse(boolean success, String message, String token, String role, String redirect, Long departmentId, Long userId) { this.success = success; this.message = message; this.token = token; this.role = role; this.redirect = redirect; this.departmentId = departmentId; + this.userId = userId; } public boolean isSuccess() { @@ -68,4 +74,12 @@ public class LoginResponse { public void setDepartmentId(Long departmentId) { this.departmentId = departmentId; } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } } diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java new file mode 100644 index 0000000..59a5f25 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -0,0 +1,39 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.AcademicActivityType; +import com.magistr.app.model.ScheduleParity; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +public record RenderedLessonDto( + Long scheduleRuleId, + Long scheduleRuleSlotId, + LocalDate date, + Integer dayOfWeek, + String dayName, + Integer weekNumber, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + LocalTime startTime, + LocalTime endTime, + Long subjectId, + String subjectName, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat, + Long subgroupId, + List groupIds, + List groupNames, + AcademicActivityType activityType, + Integer totalAcademicHours, + Integer consumedAcademicHoursBeforeLesson, + Integer remainingAcademicHoursAfterLesson +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java new file mode 100644 index 0000000..be3b0ed --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -0,0 +1,21 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.time.LocalDate; +import java.util.List; + +public record ScheduleRuleDto( + Long id, + Long subjectId, + String subjectName, + Long semesterId, + String academicYearTitle, + SemesterType semesterType, + LocalDate activeFromDate, + Integer totalAcademicHours, + List groupIds, + List groupNames, + List slots +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java new file mode 100644 index 0000000..6f01af3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -0,0 +1,22 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.ScheduleParity; + +public record ScheduleRuleSlotDto( + Long id, + Integer dayOfWeek, + String dayName, + ScheduleParity parity, + Long timeSlotId, + Integer timeSlotOrder, + String timeSlotLabel, + Long subgroupId, + Long teacherId, + String teacherName, + Long classroomId, + String classroomName, + Long lessonTypeId, + String lessonTypeName, + String lessonFormat +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/SemesterDto.java b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java new file mode 100644 index 0000000..16b22d0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SemesterDto.java @@ -0,0 +1,15 @@ +package com.magistr.app.dto; + +import com.magistr.app.model.SemesterType; + +import java.time.LocalDate; + +public record SemesterDto( + Long id, + Long academicYearId, + String academicYearTitle, + SemesterType semesterType, + LocalDate startDate, + LocalDate endDate +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java new file mode 100644 index 0000000..33df851 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/TimeSlotDto.java @@ -0,0 +1,12 @@ +package com.magistr.app.dto; + +import java.time.LocalTime; + +public record TimeSlotDto( + Long id, + Integer orderNumber, + LocalTime startTime, + LocalTime endTime, + Integer durationMinutes +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java new file mode 100644 index 0000000..2b4cfb2 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicActivityType.java @@ -0,0 +1,8 @@ +package com.magistr.app.model; + +public enum AcademicActivityType { + THEORY, + EXAM, + VACATION, + PRACTICE +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java new file mode 100644 index 0000000..1d92917 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarMatrix.java @@ -0,0 +1,77 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "academic_calendar_matrix") +public class AcademicCalendarMatrix { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(name = "course_number", nullable = false) + private Integer courseNumber; + + @Column(name = "specialty_id", nullable = false) + private Long specialtyId; + + @Column(name = "week_number", nullable = false) + private Integer weekNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "activity_type", nullable = false, length = 20) + private AcademicActivityType activityType; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Semester getSemester() { + return semester; + } + + public void setSemester(Semester semester) { + this.semester = semester; + } + + public Integer getCourseNumber() { + return courseNumber; + } + + public void setCourseNumber(Integer courseNumber) { + this.courseNumber = courseNumber; + } + + public Long getSpecialtyId() { + return specialtyId; + } + + public void setSpecialtyId(Long specialtyId) { + this.specialtyId = specialtyId; + } + + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + + public AcademicActivityType getActivityType() { + return activityType; + } + + public void setActivityType(AcademicActivityType activityType) { + this.activityType = activityType; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicYear.java b/backend/src/main/java/com/magistr/app/model/AcademicYear.java new file mode 100644 index 0000000..87920c3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AcademicYear.java @@ -0,0 +1,55 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "academic_years") +public class AcademicYear { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String title; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + 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 LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Holiday.java b/backend/src/main/java/com/magistr/app/model/Holiday.java new file mode 100644 index 0000000..c01ca07 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Holiday.java @@ -0,0 +1,56 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "holidays") +public class Holiday { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate date; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @Column(length = 255) + private String description; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDate getDate() { + return date; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleParity.java b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java new file mode 100644 index 0000000..421a3a3 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleParity.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum ScheduleParity { + BOTH, + EVEN, + ODD +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java new file mode 100644 index 0000000..8cddb99 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -0,0 +1,97 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "schedule_rules") +public class ScheduleRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "subject_id", nullable = false) + private Subject subject; + + @ManyToOne(optional = false) + @JoinColumn(name = "semester_id", nullable = false) + private Semester semester; + + @Column(name = "active_from_date", nullable = false) + private LocalDate activeFromDate; + + @Column(name = "total_academic_hours", nullable = false) + private Integer totalAcademicHours; + + @ManyToMany + @JoinTable( + name = "schedule_rule_groups", + joinColumns = @JoinColumn(name = "schedule_rule_id"), + inverseJoinColumns = @JoinColumn(name = "group_id") + ) + private Set groups = new HashSet<>(); + + @OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true) + private Set slots = new HashSet<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Subject getSubject() { + return subject; + } + + public void setSubject(Subject subject) { + this.subject = subject; + } + + public Semester getSemester() { + return semester; + } + + public void setSemester(Semester semester) { + this.semester = semester; + } + + public LocalDate getActiveFromDate() { + return activeFromDate; + } + + public void setActiveFromDate(LocalDate activeFromDate) { + this.activeFromDate = activeFromDate; + } + + public Integer getTotalAcademicHours() { + return totalAcademicHours; + } + + public void setTotalAcademicHours(Integer totalAcademicHours) { + this.totalAcademicHours = totalAcademicHours; + } + + public Set getGroups() { + return groups; + } + + public void setGroups(Set groups) { + this.groups = groups; + } + + public Set getSlots() { + return slots; + } + + public void setSlots(Set slots) { + this.slots = slots; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java new file mode 100644 index 0000000..a837635 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -0,0 +1,125 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "schedule_rule_slots") +public class ScheduleRuleSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "schedule_rule_id", nullable = false) + private ScheduleRule scheduleRule; + + @Column(name = "day_of_week", nullable = false) + private Integer dayOfWeek; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private ScheduleParity parity; + + @ManyToOne(optional = false) + @JoinColumn(name = "time_slot_id", nullable = false) + private TimeSlot timeSlot; + + @Column(name = "subgroup_id") + private Long subgroupId; + + @ManyToOne(optional = false) + @JoinColumn(name = "teacher_id", nullable = false) + private User teacher; + + @ManyToOne(optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + private Classroom classroom; + + @ManyToOne(optional = false) + @JoinColumn(name = "lesson_type_id", nullable = false) + private LessonType lessonType; + + @Column(name = "lesson_format", nullable = false, length = 30) + private String lessonFormat; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public ScheduleRule getScheduleRule() { + return scheduleRule; + } + + public void setScheduleRule(ScheduleRule scheduleRule) { + this.scheduleRule = scheduleRule; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public ScheduleParity getParity() { + return parity; + } + + public void setParity(ScheduleParity parity) { + this.parity = parity; + } + + public TimeSlot getTimeSlot() { + return timeSlot; + } + + public void setTimeSlot(TimeSlot timeSlot) { + this.timeSlot = timeSlot; + } + + public Long getSubgroupId() { + return subgroupId; + } + + public void setSubgroupId(Long subgroupId) { + this.subgroupId = subgroupId; + } + + public User getTeacher() { + return teacher; + } + + public void setTeacher(User teacher) { + this.teacher = teacher; + } + + public Classroom getClassroom() { + return classroom; + } + + public void setClassroom(Classroom classroom) { + this.classroom = classroom; + } + + public LessonType getLessonType() { + return lessonType; + } + + public void setLessonType(LessonType lessonType) { + this.lessonType = lessonType; + } + + public String getLessonFormat() { + return lessonFormat; + } + + public void setLessonFormat(String lessonFormat) { + this.lessonFormat = lessonFormat; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/Semester.java b/backend/src/main/java/com/magistr/app/model/Semester.java new file mode 100644 index 0000000..85a79e1 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Semester.java @@ -0,0 +1,68 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "semesters") +public class Semester { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "academic_year_id", nullable = false) + private AcademicYear academicYear; + + @Enumerated(EnumType.STRING) + @Column(name = "semester_type", nullable = false, length = 20) + private SemesterType semesterType; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AcademicYear getAcademicYear() { + return academicYear; + } + + public void setAcademicYear(AcademicYear academicYear) { + this.academicYear = academicYear; + } + + public SemesterType getSemesterType() { + return semesterType; + } + + public void setSemesterType(SemesterType semesterType) { + this.semesterType = semesterType; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/TimeSlot.java b/backend/src/main/java/com/magistr/app/model/TimeSlot.java new file mode 100644 index 0000000..f1a3402 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/TimeSlot.java @@ -0,0 +1,66 @@ +package com.magistr.app.model; + +import jakarta.persistence.*; + +import java.time.LocalTime; + +@Entity +@Table(name = "time_slots") +public class TimeSlot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_number", nullable = false) + private Integer orderNumber; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public LocalTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public LocalTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + public Integer getDurationMinutes() { + return durationMinutes; + } + + public void setDurationMinutes(Integer durationMinutes) { + this.durationMinutes = durationMinutes; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java new file mode 100644 index 0000000..70964d6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarMatrixRepository.java @@ -0,0 +1,23 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarMatrix; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarMatrixRepository extends JpaRepository { + + Optional findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + Long semesterId, + Integer courseNumber, + Long specialtyId, + Integer weekNumber + ); + + List findBySemesterIdAndCourseNumberAndSpecialtyIdOrderByWeekNumberAsc( + Long semesterId, + Integer courseNumber, + Long specialtyId + ); +} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java new file mode 100644 index 0000000..ffc3846 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicYearRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicYear; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AcademicYearRepository extends JpaRepository { + + Optional findByTitle(String title); +} diff --git a/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java new file mode 100644 index 0000000..43ec732 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/HolidayRepository.java @@ -0,0 +1,16 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Holiday; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface HolidayRepository extends JpaRepository { + + List findByAcademicYearIdOrderByDateAsc(Long academicYearId); + + List findByAcademicYearIdAndDateBetween(Long academicYearId, LocalDate startDate, LocalDate endDate); + + boolean existsByAcademicYearIdAndDate(Long academicYearId, LocalDate date); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java new file mode 100644 index 0000000..b4573c6 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -0,0 +1,85 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRule; +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; + +public interface ScheduleRuleRepository extends JpaRepository { + + @Query(""" + select distinct r + from ScheduleRule r + join r.groups g + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where g.id = :groupId + and sem.id = :semesterId + """) + List findByGroupIdAndSemesterId( + @Param("groupId") Long groupId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + join r.slots teacherSlot + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where teacherSlot.teacher.id = :teacherId + and sem.id = :semesterId + """) + List findByTeacherIdAndSemesterId( + @Param("teacherId") Long teacherId, + @Param("semesterId") Long semesterId + ); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + order by r.id desc + """) + List findAllWithDetails(); + + @Query(""" + select distinct r + from ScheduleRule r + left join fetch r.subject + left join fetch r.semester sem + left join fetch sem.academicYear + left join fetch r.groups groups + left join fetch r.slots slots + left join fetch slots.timeSlot + left join fetch slots.teacher + left join fetch slots.classroom + left join fetch slots.lessonType + where r.id = :id + """) + java.util.Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java new file mode 100644 index 0000000..a6f2d74 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java @@ -0,0 +1,7 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.ScheduleRuleSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleRuleSlotRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java new file mode 100644 index 0000000..d347f61 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SemesterRepository.java @@ -0,0 +1,18 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.Semester; +import com.magistr.app.model.SemesterType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface SemesterRepository extends JpaRepository { + + Optional findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate); + + List findByAcademicYearIdOrderByStartDateAsc(Long academicYearId); + + Optional findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java new file mode 100644 index 0000000..7576c1a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/TimeSlotRepository.java @@ -0,0 +1,13 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.TimeSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TimeSlotRepository extends JpaRepository { + + List findAllByOrderByOrderNumberAsc(); + + boolean existsByOrderNumber(Integer orderNumber); +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java new file mode 100644 index 0000000..0a2248d --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -0,0 +1,87 @@ +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.SemesterRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +@Service +public class AcademicDateService { + + private final SemesterRepository semesterRepository; + private final HolidayRepository holidayRepository; + private final AcademicCalendarMatrixRepository calendarMatrixRepository; + private final boolean evenWeekIsUpper; + + public AcademicDateService(SemesterRepository semesterRepository, + HolidayRepository holidayRepository, + AcademicCalendarMatrixRepository calendarMatrixRepository, + @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { + this.semesterRepository = semesterRepository; + this.holidayRepository = holidayRepository; + this.calendarMatrixRepository = calendarMatrixRepository; + this.evenWeekIsUpper = evenWeekIsUpper; + } + + public Optional findSemester(LocalDate date) { + return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(date, date); + } + + public int getWeekNumber(Semester semester, LocalDate date) { + long days = ChronoUnit.DAYS.between(semester.getStartDate(), date); + if (days < 0) { + return 0; + } + return (int) (days / 7) + 1; + } + + public ScheduleParity getParity(Semester semester, LocalDate date) { + int weekNumber = getWeekNumber(semester, date); + boolean evenWeek = weekNumber % 2 == 0; + if (evenWeekIsUpper) { + return evenWeek ? ScheduleParity.ODD : ScheduleParity.EVEN; + } + 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; + } + + return calendarMatrixRepository + .findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber( + semester.getId(), + courseNumber, + group.getSpecialityCode(), + weekNumber + ) + .map(AcademicCalendarMatrix::getActivityType) + .orElse(AcademicActivityType.THEORY); + } + + public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { + return getActivityType(group, semester, date) == AcademicActivityType.THEORY + && !isHoliday(semester, date); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java new file mode 100644 index 0000000..a7554cc --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -0,0 +1,295 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +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.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class ScheduleGeneratorService { + + private static final int ACADEMIC_HOURS_PER_SLOT = 2; + private static final long MAX_RANGE_DAYS = 120; + + private final ScheduleRuleRepository scheduleRuleRepository; + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final AcademicDateService academicDateService; + private final Map consumedHoursCache = new ConcurrentHashMap<>(); + + public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository, + GroupRepository groupRepository, + UserRepository userRepository, + AcademicDateService academicDateService) { + this.scheduleRuleRepository = scheduleRuleRepository; + this.groupRepository = groupRepository; + this.userRepository = userRepository; + this.academicDateService = academicDateService; + } + + public List buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + StudentGroup group = groupRepository.findById(groupId) + .orElseThrow(() -> new IllegalArgumentException("Группа не найдена")); + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + if (!academicDateService.isTheoryDay(group, semester, date)) { + continue; + } + + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByGroupIdAndSemesterId(groupId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, group, null, result); + } + } + + return sortRenderedLessons(result); + } + + public List buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); + if (!userRepository.existsById(teacherId)) { + throw new IllegalArgumentException("Преподаватель не найден"); + } + + Map> rulesBySemester = new HashMap<>(); + List result = new ArrayList<>(); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty()) { + continue; + } + + Semester semester = semesterOpt.get(); + List rules = rulesBySemester.computeIfAbsent( + semester.getId(), + semesterId -> scheduleRuleRepository.findByTeacherIdAndSemesterId(teacherId, semesterId) + ); + + for (ScheduleRule rule : sortRules(rules)) { + renderRuleForDate(rule, date, null, teacherId, result); + } + } + + return sortRenderedLessons(result); + } + + public void clearCache() { + consumedHoursCache.clear(); + } + + private void validateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Дата начала и дата окончания обязательны"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала"); + } + if (java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) { + throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней"); + } + } + + private List sortRules(List rules) { + return rules.stream() + .sorted(Comparator + .comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo)) + .thenComparing(ScheduleRule::getId)) + .toList(); + } + + private List sortRenderedLessons(List lessons) { + return lessons.stream() + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private void renderRuleForDate(ScheduleRule rule, + LocalDate date, + StudentGroup targetGroup, + Long targetTeacherId, + List result) { + if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) { + return; + } + if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) { + return; + } + + Optional semesterOpt = academicDateService.findSemester(date); + if (semesterOpt.isEmpty() || !Objects.equals(semesterOpt.get().getId(), rule.getSemester().getId())) { + return; + } + + Semester semester = semesterOpt.get(); + if (academicDateService.isHoliday(semester, date)) { + return; + } + + List eligibleGroups = eligibleGroups(rule, targetGroup, semester, date); + if (eligibleGroups.isEmpty()) { + return; + } + + ScheduleParity dateParity = academicDateService.getParity(semester, date); + int dayOfWeek = date.getDayOfWeek().getValue(); + List slots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .filter(slot -> targetTeacherId == null || Objects.equals(slot.getTeacher().getId(), targetTeacherId)) + .sorted(Comparator + .comparing((ScheduleRuleSlot slot) -> slot.getTimeSlot().getOrderNumber(), Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ScheduleRuleSlot::getId)) + .toList(); + + if (slots.isEmpty()) { + return; + } + + int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date); + int weekNumber = academicDateService.getWeekNumber(semester, date); + + for (ScheduleRuleSlot slot : slots) { + if (consumedHours >= rule.getTotalAcademicHours()) { + break; + } + int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT); + result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson)); + consumedHours += ACADEMIC_HOURS_PER_SLOT; + } + } + + private List eligibleGroups(ScheduleRule rule, StudentGroup targetGroup, Semester semester, LocalDate date) { + if (targetGroup != null) { + return academicDateService.isTheoryDay(targetGroup, semester, date) ? List.of(targetGroup) : List.of(); + } + + return rule.getGroups().stream() + .filter(group -> academicDateService.isTheoryDay(group, semester, date)) + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + } + + private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { + Long groupId = targetGroup == null ? null : targetGroup.getId(); + String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate; + return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate)); + } + + private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) { + LocalDate start = rule.getActiveFromDate() == null + ? rule.getSemester().getStartDate() + : rule.getActiveFromDate(); + if (start.isBefore(rule.getSemester().getStartDate())) { + start = rule.getSemester().getStartDate(); + } + + int consumed = 0; + for (LocalDate date = start; date.isBefore(targetDate); date = date.plusDays(1)) { + Semester semester = rule.getSemester(); + if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) { + continue; + } + if (academicDateService.isHoliday(semester, date)) { + continue; + } + if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) { + continue; + } + + int dayOfWeek = date.getDayOfWeek().getValue(); + ScheduleParity dateParity = academicDateService.getParity(semester, date); + long activeSlots = rule.getSlots().stream() + .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) + .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) + .count(); + consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; + if (consumed >= rule.getTotalAcademicHours()) { + return consumed; + } + } + return consumed; + } + + private RenderedLessonDto toRenderedLesson(ScheduleRule rule, + ScheduleRuleSlot slot, + LocalDate date, + int weekNumber, + ScheduleParity dateParity, + List groups, + int consumedBeforeLesson, + int remainingAfterLesson) { + TimeSlot timeSlot = slot.getTimeSlot(); + List sortedGroups = groups.stream() + .sorted(Comparator.comparing(StudentGroup::getName)) + .toList(); + + return new RenderedLessonDto( + rule.getId(), + slot.getId(), + date, + date.getDayOfWeek().getValue(), + dayName(date.getDayOfWeek()), + weekNumber, + dateParity, + timeSlot.getId(), + timeSlot.getOrderNumber(), + timeSlot.getStartTime(), + timeSlot.getEndTime(), + rule.getSubject().getId(), + rule.getSubject().getName(), + slot.getTeacher().getId(), + displayUserName(slot.getTeacher()), + slot.getClassroom().getId(), + slot.getClassroom().getName(), + slot.getLessonType().getId(), + slot.getLessonType().getLessonType(), + slot.getLessonFormat(), + slot.getSubgroupId(), + sortedGroups.stream().map(StudentGroup::getId).toList(), + sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), + AcademicActivityType.THEORY, + rule.getTotalAcademicHours(), + consumedBeforeLesson, + remainingAfterLesson + ); + } + + public static String dayName(DayOfWeek dayOfWeek) { + String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU")); + return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1); + } + + public static String displayUserName(User user) { + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + return user.getUsername(); + } +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 7416e4d..2eb31e9 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -210,7 +210,227 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types ( ); -- ========================================== --- Основная таблица Расписания (Lessons) +-- Динамическое расписание: календарь, слоты и правила +-- ========================================== + +CREATE TABLE IF NOT EXISTS time_slots ( + id BIGSERIAL PRIMARY KEY, + order_number INT NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + duration_minutes INT NOT NULL, + CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0), + CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time), + CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT uq_time_slots_order UNIQUE (order_number), + CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time) +); + +INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES + (1, '08:00', '09:30', 90), + (2, '09:40', '11:10', 90), + (3, '11:40', '13:10', 90), + (4, '13:20', '14:50', 90), + (5, '15:00', '16:30', 90), + (6, '16:50', '18:20', 90), + (7, '18:30', '20:00', 90) +ON CONFLICT (start_time, end_time) DO NOTHING; + +CREATE TABLE IF NOT EXISTS academic_years ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(20) NOT NULL UNIQUE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date) +); + +INSERT INTO academic_years (title, start_date, end_date) VALUES + ('2024-2025', '2024-09-01', '2025-06-30'), + ('2025-2026', '2025-09-01', '2026-06-30') +ON CONFLICT (title) DO NOTHING; + +CREATE TABLE IF NOT EXISTS semesters ( + id BIGSERIAL PRIMARY KEY, + academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, + semester_type VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + CONSTRAINT chk_semesters_type CHECK (semester_type IN ('autumn', 'spring')), + CONSTRAINT chk_semesters_dates CHECK (start_date <= end_date), + CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type) +); + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31) +FROM academic_years +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date +FROM academic_years +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 ( + id BIGSERIAL PRIMARY KEY, + date DATE 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) +); + +CREATE INDEX IF NOT EXISTS idx_calendar_lookup + ON academic_calendar_matrix(semester_id, course_number, specialty_id, week_number); + +INSERT INTO academic_calendar_matrix (semester_id, course_number, specialty_id, week_number, activity_type) +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; + +CREATE TABLE IF NOT EXISTS schedule_rules ( + id BIGSERIAL PRIMARY KEY, + subject_id BIGINT NOT NULL REFERENCES subjects(id), + semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE, + active_from_date DATE NOT NULL, + total_academic_hours INT NOT NULL, + CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rules_subject ON schedule_rules(subject_id); + +CREATE TABLE IF NOT EXISTS schedule_rule_groups ( + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE, + PRIMARY KEY (schedule_rule_id, group_id) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_groups_group ON schedule_rule_groups(group_id); + +CREATE TABLE IF NOT EXISTS schedule_rule_slots ( + id BIGSERIAL PRIMARY KEY, + schedule_rule_id BIGINT NOT NULL REFERENCES schedule_rules(id) ON DELETE CASCADE, + day_of_week INT NOT NULL, + parity VARCHAR(10) NOT NULL, + time_slot_id BIGINT NOT NULL REFERENCES time_slots(id), + subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL, + teacher_id BIGINT NOT NULL REFERENCES users(id), + classroom_id BIGINT NOT NULL REFERENCES classrooms(id), + lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), + lesson_format VARCHAR(30) NOT NULL, + CONSTRAINT chk_schedule_rule_slots_day CHECK (day_of_week BETWEEN 1 AND 7), + CONSTRAINT chk_schedule_rule_slots_parity CHECK (parity IN ('BOTH', 'EVEN', 'ODD')), + CONSTRAINT chk_schedule_rule_slots_format CHECK (lesson_format IN ('Очно', 'Онлайн')) +); + +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); +CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); + +CREATE TEMP TABLE tmp_default_rules AS +SELECT + NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, + rule_key, + subject_id, + semester_id, + active_from_date, + total_academic_hours +FROM ( + SELECT + data.rule_key, + subj.id AS subject_id, + sem.id AS semester_id, + data.active_from_date, + data.total_academic_hours + FROM (VALUES + ('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72), + ('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54) + ) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours) + JOIN subjects subj ON subj.name = data.subject_name + JOIN academic_years ay ON ay.title = data.year_title + JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type +) prepared_rules; + +INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours) +SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours +FROM tmp_default_rules; + +INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) +SELECT + rules.schedule_rule_id, + sg.id +FROM (VALUES + ('stream_math', 'ИВТ-21-1'), + ('stream_math', 'ИБ-41м'), + ('ivt_informatics_lab', 'ИВТ-21-1'), + ('ivt_databases_practice', 'ИВТ-21-1'), + ('ib_philosophy', 'ИБ-41м') +) AS data(rule_key, group_name) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN student_groups sg ON sg.name = data.group_name +ON CONFLICT DO NOTHING; + +INSERT INTO schedule_rule_slots ( + schedule_rule_id, + day_of_week, + parity, + time_slot_id, + teacher_id, + classroom_id, + lesson_type_id, + lesson_format +) +SELECT + rules.schedule_rule_id, + data.day_of_week, + data.parity, + ts.id, + teacher.id, + classroom.id, + lt.id, + data.lesson_format +FROM (VALUES + ('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'), + ('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'), + ('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'), + ('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн') +) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format) +JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key +JOIN time_slots ts ON ts.order_number = data.time_slot_order +JOIN users teacher ON teacher.username = data.teacher_username +JOIN classrooms classroom ON classroom.name = data.classroom_name +JOIN lesson_types lt ON lt.name = data.lesson_type_name; + +-- ========================================== +-- Временные таблицы старой модели. Будут удалены после очистки старого кода. -- ========================================== CREATE TABLE IF NOT EXISTS lessons ( id BIGSERIAL PRIMARY KEY, @@ -225,17 +445,6 @@ CREATE TABLE IF NOT EXISTS lessons ( time VARCHAR(255) NOT NULL ); -INSERT INTO lessons (teacher_id, group_id, subject_id, lesson_format, type_lesson, classroom_id, day, week, time) VALUES - (2, 1, 1, 'Очно', 'Лекция', 1, 'Понедельник', 'Верхняя', '11:40 - 13:10'), - (1, 1, 2, 'Онлайн', 'Практическая работа', 2, 'Вторник', 'Нижняя', '15:00 - 16:30'), - (2, 1, 3, 'Очно', 'Лабораторная работа', 3, 'Среда', 'Верхняя', '8:00 - 9:30'), - (1, 1, 4, 'Онлайн', 'Лекция', 1, 'Четверг', 'Нижняя', '11:40 - 13:10'), - (2, 1, 5, 'Очно', 'Практическая работа', 2, 'Пятница', 'Верхняя', '15:00 - 16:30'), - (1, 1, 3, 'Онлайн', 'Лабораторная работа', 3, 'Суббота', 'Нижняя', '8:00 - 9:30'); - --- =============================== --- Создание таблицы данных расписания (schedule_data) --- =============================== CREATE TABLE IF NOT EXISTS schedule_data ( id BIGSERIAL PRIMARY KEY, department_id BIGINT NOT NULL REFERENCES departments(id), @@ -249,18 +458,6 @@ CREATE TABLE IF NOT EXISTS schedule_data ( period VARCHAR(255) NOT NULL ); -INSERT INTO schedule_data (department_id, group_id, subjects_id, lesson_type_id, number_of_hours, is_division, teacher_id, semester_type, period) -VALUES (1, 1, 1, 3, 2, true, 1, 'autumn', '2024-2025'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (3, 1, 2, 1, 3, true, 1, 'autumn', '2023-2024'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (2, 2, 3, 2, 1, false, 2, 'spring', '2025-2026'), - (1, 1, 1, 1, 2, true, 2, 'autumn', '2024-2025'), - (1, 2, 2, 3, 4, false, 2, 'autumn', '2024-2025'), - (1, 1, 4, 2, 1, false, 1, 'autumn', '2024-2025'), - (1, 2, 5, 1, 7, true, 1, 'autumn', '2024-2025'); - -- ========================================== -- Функция обновления timestamp -- ========================================== @@ -281,10 +478,18 @@ CREATE TRIGGER update_users_updated_at -- Комментарии к таблицам и полям (для документации) -- ========================================== COMMENT ON TABLE users IS 'Пользователи системы (студенты, преподаватели, администраторы)'; -COMMENT ON TABLE lessons IS 'Основное расписание занятий'; +COMMENT ON TABLE lessons IS 'Временная таблица старой модели расписания до удаления старого кода'; COMMENT ON TABLE departments IS 'Кафедры'; COMMENT ON TABLE specialties IS 'Специальности'; -COMMENT ON TABLE schedule_data IS 'Данные к составлению расписания'; +COMMENT ON TABLE schedule_data 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 schedule_rules IS 'Правила динамической генерации расписания'; +COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; +COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON COLUMN schedule_data.department_id IS 'Идентификатор кафедры'; COMMENT ON COLUMN schedule_data.group_id IS 'Идентификатор группы'; COMMENT ON COLUMN schedule_data.subjects_id IS 'Идентификатор предмета'; @@ -324,6 +529,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.year_start_study IS 'Год начала обучения'; COMMENT ON COLUMN student_groups.created_at IS 'Дата и время создания'; COMMENT ON COLUMN subgroups.id IS 'ID подгруппы'; @@ -388,4 +595,4 @@ COMMENT ON COLUMN specialties.specialty_code IS 'Код специальност 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 типа занятия'; \ No newline at end of file +COMMENT ON COLUMN teacher_lesson_types.lesson_type_id IS 'ID типа занятия'; diff --git a/docs/API.md b/docs/API.md index 675c31c..8ec374f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -25,7 +25,9 @@ "message": "OK", "token": "550e8400-e29b-41d4-a716-446655440000", "role": "ADMIN", - "redirect": "/admin/" + "redirect": "/admin/", + "departmentId": 1, + "userId": 1 } ``` @@ -36,7 +38,9 @@ "message": "Неверное имя пользователя или пароль", "token": null, "role": null, - "redirect": null + "redirect": null, + "departmentId": null, + "userId": null } ``` @@ -95,7 +99,145 @@ --- -## Расписание (Lessons) +## Динамическое расписание + +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат. + +### `GET /api/schedule` + +Получение расписания группы или преподавателя за период. + +**Параметры:** + +| Параметр | Обязателен | Описание | +|----------|------------|----------| +| `groupId` | Да, если нет `teacherId` | ID учебной группы | +| `teacherId` | Да, если нет `groupId` | ID преподавателя | +| `startDate` | Да | Начало периода в формате `YYYY-MM-DD` | +| `endDate` | Да | Конец периода в формате `YYYY-MM-DD` | + +Передаётся ровно один параметр: `groupId` или `teacherId`. Максимальный диапазон — 120 дней. + +**Пример:** +```http +GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 +``` + +**Ответ:** +```json +[ + { + "scheduleRuleId": 10, + "scheduleRuleSlotId": 31, + "date": "2026-04-27", + "dayOfWeek": 1, + "dayName": "Понедельник", + "weekNumber": 13, + "parity": "ODD", + "timeSlotId": 3, + "timeSlotOrder": 3, + "startTime": "11:40:00", + "endTime": "13:10:00", + "subjectId": 1, + "subjectName": "Высшая математика", + "teacherId": 2, + "teacherName": "Петров Препод Петрович", + "classroomId": 1, + "classroomName": "101 Ленинская", + "lessonTypeId": 1, + "lessonTypeName": "Лекция", + "lessonFormat": "Очно", + "subgroupId": null, + "groupIds": [1], + "groupNames": ["ИВТ-21-1"], + "activityType": "THEORY", + "totalAcademicHours": 72, + "consumedAcademicHoursBeforeLesson": 24, + "remainingAcademicHoursAfterLesson": 46 + } +] +``` + +### `GET /api/admin/time-slots` + +Список временных слотов занятий. CRUD доступен по: + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/time-slots` | Список слотов | +| `POST` | `/api/admin/time-slots` | Создать слот | +| `PUT` | `/api/admin/time-slots/{id}` | Обновить слот | +| `DELETE` | `/api/admin/time-slots/{id}` | Удалить слот | + +**Тело создания/обновления:** +```json +{ + "orderNumber": 1, + "startTime": "08:00:00", + "endTime": "09:30:00", + "durationMinutes": 90 +} +``` + +### Календарный график администратора + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/calendar/years` | Учебные годы с семестрами | +| `POST` | `/api/admin/calendar/years` | Создать учебный год | +| `PUT` | `/api/admin/calendar/years/{id}` | Обновить учебный год | +| `DELETE` | `/api/admin/calendar/years/{id}` | Удалить учебный год | +| `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` | Массовое сохранение матрицы | + +### `POST /api/admin/schedule-rules` + +Создание правила динамического расписания. + +```json +{ + "subjectId": 1, + "semesterId": 1, + "activeFromDate": "2026-02-01", + "totalAcademicHours": 72, + "groupIds": [1, 2], + "slots": [ + { + "dayOfWeek": 1, + "parity": "BOTH", + "timeSlotId": 3, + "subgroupId": null, + "teacherId": 2, + "classroomId": 1, + "lessonTypeId": 1, + "lessonFormat": "Очно" + } + ] +} +``` + +CRUD доступен по: + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` | +| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | +| `POST` | `/api/admin/schedule-rules` | Создать правило | +| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | +| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило | + +--- + +## Расписание (Lessons, deprecated) + +Старые эндпоинты ещё физически есть в коде до удаления старых контроллеров и таблиц. Новый просмотр расписания использует `GET /api/schedule`, а новые seed-данные создаются сразу в динамической модели. ### `GET /api/users/lessons` diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 08cf64e..a87d978 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -41,7 +41,8 @@ ### Учебные группы (Student Groups) -- **Поля:** Название (уникальное), численность, форма обучения, кафедра, курс (1–6) +- **Поля:** Название (уникальное), численность, форма обучения, кафедра, специальность, год начала обучения +- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) ### Аудитории (Classrooms) @@ -66,7 +67,33 @@ ## Логика расписания -### Сущность «Занятие» (Lesson) +### Динамическая модель расписания + +Основная модель расписания строится из правил, а не из отдельных статических пар. + +| Компонент | Назначение | +|-----------|------------| +| `academic_years` / `semesters` | Учебные годы и семестры. Неделя 1 считается от `semesters.start_date` | +| `holidays` | Даты, когда занятия не проводятся и часы не списываются | +| `academic_calendar_matrix` | Тип недели для курса и специальности: теория, сессия, каникулы, практика | +| `time_slots` | Настраиваемая сетка пар для тенанта | +| `schedule_rules` | Лимит часов дисциплины в семестре | +| `schedule_rule_groups` | Группы правила, включая потоковые лекции | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | + +Генератор `ScheduleGeneratorService` рендерит расписание по запросу: +1. Определяет семестр для каждой даты диапазона. +2. Вычисляет номер недели и чётность. +3. Проверяет праздники и матрицу учебного графика. +4. Загружает правила группы или преподавателя. +5. Симулирует уже проведённые занятия от `active_from_date`. +6. Останавливает вывод правила, когда достигнут `total_academic_hours`. + +Праздник считается пропуском: занятие не переносится и не списывает академические часы. + +### Временная старая сущность «Занятие» (Lesson) + +`lessons` пока физически остаётся в схеме до удаления старого кода, но базовая миграция больше не заполняет её тестовыми данными. Новые экраны просмотра используют `GET /api/schedule`. Каждая запись в расписании содержит: @@ -84,7 +111,7 @@ ### Временны́е слоты -Система использует 7 фиксированных слотов по 90 минут: +Сетка пар хранится в `time_slots` и настраивается для каждого тенанта. При миграции создаются базовые слоты: | № | Время | |---|-------| @@ -104,9 +131,9 @@ - **Тип:** только `Лекция`, `Практическая работа`, `Лабораторная работа` - Все ID (преподаватель, группа, дисциплина, аудитория) обязательны и не могут быть 0 -### Данные к составлению расписания (Schedule Data) +### Временные старые данные к составлению расписания (Schedule Data) -Таблица `schedule_data` хранит **плановую нагрузку** для составления расписания: +Таблица `schedule_data` пока физически остаётся в схеме до удаления старого кода. Новая базовая миграция создаёт `schedule_rules` и `schedule_rule_groups` напрямую, без ETL из `schedule_data`. | Поле | Описание | |------|----------| diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c025547..8e5d67e 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 + BIGINT specialty_code FK + BIGINT year_start_study TIMESTAMP created_at } @@ -142,15 +143,82 @@ erDiagram VARCHAR semester_type VARCHAR period } + + time_slots { + BIGSERIAL id PK + INT order_number UK + TIME start_time + TIME end_time + INT duration_minutes + } + + academic_years { + BIGSERIAL id PK + VARCHAR title UK + DATE start_date + DATE end_date + } + + semesters { + BIGSERIAL id PK + BIGINT academic_year_id FK + VARCHAR semester_type + DATE start_date + DATE end_date + } + + holidays { + BIGSERIAL id PK + DATE date + BIGINT academic_year_id FK + VARCHAR description + } + + academic_calendar_matrix { + BIGSERIAL id PK + BIGINT semester_id FK + INT course_number + BIGINT specialty_id FK + INT week_number + VARCHAR activity_type + } + + schedule_rules { + BIGSERIAL id PK + BIGINT subject_id FK + BIGINT semester_id FK + DATE active_from_date + INT total_academic_hours + } + + schedule_rule_groups { + BIGINT schedule_rule_id FK,PK + BIGINT group_id FK,PK + } + + schedule_rule_slots { + BIGSERIAL id PK + BIGINT schedule_rule_id FK + INT day_of_week + VARCHAR parity + BIGINT time_slot_id FK + BIGINT subgroup_id FK + BIGINT teacher_id FK + BIGINT classroom_id FK + BIGINT lesson_type_id FK + VARCHAR lesson_format + } departments ||--o{ users : "department_id" departments ||--o{ student_groups : "department_id" departments ||--o{ subjects : "department_id" departments ||--o{ schedule_data : "department_id" education_forms ||--o{ student_groups : "education_form_id" + specialties ||--o{ student_groups : "specialty_code" student_groups ||--o{ subgroups : "group_id" student_groups ||--o{ lessons : "group_id" student_groups ||--o{ schedule_data : "group_id" + student_groups ||--o{ schedule_rule_groups : "group_id" users ||--o{ lessons : "teacher_id" users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_lesson_types : "user_id" @@ -159,11 +227,24 @@ erDiagram subjects ||--o{ teacher_subjects : "subject_id" subjects ||--o{ teacher_lesson_types : "subject_id" subjects ||--o{ schedule_data : "subjects_id" + subjects ||--o{ schedule_rules : "subject_id" lesson_types ||--o{ teacher_lesson_types : "lesson_type_id" lesson_types ||--o{ schedule_data : "lesson_type_id" + lesson_types ||--o{ schedule_rule_slots : "lesson_type_id" classrooms ||--o{ lessons : "classroom_id" + classrooms ||--o{ schedule_rule_slots : "classroom_id" 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" + semesters ||--o{ schedule_rules : "semester_id" + specialties ||--o{ academic_calendar_matrix : "specialty_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" + subgroups ||--o{ schedule_rule_slots : "subgroup_id" + users ||--o{ schedule_rule_slots : "teacher_id" ``` --- @@ -220,7 +301,8 @@ erDiagram | `group_size` | BIGINT | Количество студентов | | `education_form_id` | BIGINT FK → education_forms | Форма обучения | | `department_id` | BIGINT FK → departments | Кафедра | -| `course` | INT CHECK(1–6) | Курс | +| `specialty_code` | BIGINT FK → specialties | Специальность | +| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | #### `subgroups` — Подгруппы | Колонка | Тип | Описание | @@ -270,7 +352,10 @@ erDiagram ### Расписание -#### `lessons` — Основное расписание занятий +#### `lessons` — Временная таблица старой модели + +Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не заполняет её тестовыми данными. Целевой источник расписания — `schedule_rules` и `schedule_rule_slots`. + | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | @@ -309,7 +394,10 @@ erDiagram | `subject_id` | BIGINT PK, FK → subjects (CASCADE) | Дисциплина | | `lesson_type_id` | BIGINT PK, FK → lesson_types (CASCADE) | Тип занятия | -#### `schedule_data` — Данные к составлению расписания +#### `schedule_data` — Временная таблица старой модели нагрузки + +Таблица пока остаётся в схеме до удаления старого кода, но новая базовая миграция больше не использует её как источник seed-данных. + | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | @@ -324,6 +412,81 @@ erDiagram | `semester_type` | VARCHAR(255) | Весенний / Осенний | | `period` | VARCHAR(255) | Учебный год | +### Динамическое расписание + +#### `time_slots` — Временные слоты занятий +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `order_number` | INT UNIQUE | Номер пары в дне | +| `start_time` | TIME | Время начала | +| `end_time` | TIME | Время окончания | +| `duration_minutes` | INT | Длительность в минутах | + +#### `academic_years` — Учебные годы +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `title` | VARCHAR(20) UNIQUE | Название, например `2025-2026` | +| `start_date` | DATE | Дата начала | +| `end_date` | DATE | Дата окончания | + +#### `semesters` — Семестры +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `semester_type` | VARCHAR(20) | `autumn` или `spring` | +| `start_date` | DATE | Дата начала, от неё считается неделя 1 | +| `end_date` | DATE | Дата окончания | + +#### `holidays` — Праздники и исключения +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `date` | DATE | Неучебная дата | +| `academic_year_id` | BIGINT FK → academic_years | Учебный год | +| `description` | VARCHAR(255) | Описание | + +#### `academic_calendar_matrix` — Матрица учебного графика +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `semester_id` | BIGINT FK → semesters | Семестр | +| `course_number` | INT | Курс | +| `specialty_id` | BIGINT FK → specialties | Специальность | +| `week_number` | INT | Номер недели семестра | +| `activity_type` | VARCHAR(20) | `THEORY`, `EXAM`, `VACATION`, `PRACTICE` | + +#### `schedule_rules` — Правила расписания +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `subject_id` | BIGINT FK → subjects | Дисциплина | +| `semester_id` | BIGINT FK → semesters | Семестр | +| `active_from_date` | DATE | Дата начала действия правила | +| `total_academic_hours` | INT | Лимит академических часов | + +#### `schedule_rule_groups` — Группы правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `schedule_rule_id` | BIGINT PK, FK → schedule_rules | Правило | +| `group_id` | BIGINT PK, FK → student_groups | Группа | + +#### `schedule_rule_slots` — Слоты правила +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID | +| `schedule_rule_id` | BIGINT FK → schedule_rules | Правило | +| `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | +| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | +| `time_slot_id` | BIGINT FK → time_slots | Временной слот | +| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | +| `teacher_id` | BIGINT FK → users | Преподаватель | +| `classroom_id` | BIGINT FK → classrooms | Аудитория | +| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | +| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | + --- ## Flyway миграции @@ -340,7 +503,7 @@ erDiagram | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: все таблицы, тестовые данные, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, динамическое расписание, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 2a224ca..391558d 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -7,7 +7,7 @@ | **Фреймворк** | Нет (Vanilla JavaScript) | | **Модульная система** | ES6 Modules (`import`/`export`) | | **Стили** | CSS (модульный подход) | -| **Шрифт** | [Inter](https://fonts.google.com/specimen/Inter) (Google Fonts) | +| **Шрифт** | Inter на странице входа, системный шрифт в кабинетах расписания | | **Веб-сервер** | Apache httpd:alpine | --- @@ -70,10 +70,10 @@ frontend/ │ └── general.html # Общие настройки (заглушка) │ ├── teacher/ # 👩‍🏫 Интерфейс преподавателя -│ └── index.html # Просмотр расписания +│ └── index.html # Недельный просмотр динамического расписания преподавателя │ └── student/ # 🎓 Интерфейс студента - └── index.html # Просмотр расписания (read-only) + └── index.html # Недельный просмотр динамического расписания группы ``` --- @@ -106,7 +106,7 @@ frontend/ | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Расписание | `/api/users/lessons` | +| `schedule` | Старый админский экран расписания до удаления | `/api/users/lessons`; новые правила — `/api/admin/schedule-rules` | | `database` | Тенанты | `/api/database` | | `department` | Кафедры | `/api/departments` | | `departments-data` | Создание кафедры/специальности | `/api/departments` | @@ -166,6 +166,8 @@ export const api = { 3. При успехе сохраняет в `localStorage`: - `token` — UUID-токен - `role` — роль пользователя + - `departmentId` — кафедра пользователя + - `userId` — ID пользователя для личного расписания преподавателя 4. Перенаправляет на соответствующий интерфейс: - `ADMIN` → `/admin/` - `TEACHER` → `/teacher/` @@ -188,6 +190,32 @@ export function isAuthenticatedAsAdmin() { --- +## Кабинеты расписания + +### Преподаватель (`/teacher/`) + +Страница показывает недельную сетку занятий преподавателя. ID преподавателя берётся из `localStorage.userId`, который сохраняется после `POST /api/auth/login`. + +Основные элементы: +- навигация по неделям: предыдущая, текущая, следующая; +- выбор даты через `input[type="date"]`; +- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила. + +Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. + +### Студент (`/student/`) + +В текущей модели студент не связан с конкретной группой, поэтому страница использует выбор группы из `/api/groups`. + +Основные элементы: +- селект группы с сохранением выбора в `localStorage.studentGroupId`; +- недельная сетка по дням; +- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; +- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия. + +--- + ## CSS-архитектура ### Модульный подход diff --git a/frontend/script.js b/frontend/script.js index 80bdf31..cf3632f 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -144,6 +144,7 @@ if (data.token) localStorage.setItem('token', data.token); if (data.role) localStorage.setItem('role', data.role); if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); + if (data.userId) localStorage.setItem('userId', data.userId); const redirect = data.redirect || '/'; setTimeout(() => { window.location.href = redirect; }, 400); diff --git a/frontend/student/index.html b/frontend/student/index.html old mode 100755 new mode 100644 index 131afe2..4ca5a2f --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -3,240 +3,498 @@ - Панель студента - + Расписание студента - -
-
-
-
- -
-
- - - - +
+
+
+

Расписание студента

+ Загрузка периода...
-

Панель студента

-

Раздел в разработке.
Ожидайте обновлений!

- Выйти -
+
+ + Выйти +
+ - +
+ +
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file + diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html old mode 100755 new mode 100644 index de0df15..43d82f4 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -1,263 +1,527 @@ - - Панель преподавателя - + Расписание преподавателя - -
-
-
-
- -
-
- - - - +
+
+
+

Расписание преподавателя

+ Загрузка периода...
-

Панель преподавателя

-

Раздел в разработке.
Ожидайте обновлений!

- Выйти -
+
+ + Выйти +
+ - +
+
+ + + +
+ +
+ +
+
+ + + - - \ No newline at end of file +