начало работы построения динамического расписания и документация

This commit is contained in:
Zuev
2026-04-29 15:42:05 +03:00
parent 813e81be70
commit 8f71b9b2b5
43 changed files with 3894 additions and 457 deletions

View File

@@ -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<AcademicYearDto> 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<SemesterDto> 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<AcademicCalendarMatrixDto> rows) {
if (rows == null || rows.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "Передайте хотя бы одну строку матрицы"));
}
try {
for (AcademicCalendarMatrixDto row : rows) {
Semester semester = semesterRepository.findById(row.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
if (row.courseNumber() == null || row.courseNumber() <= 0
|| row.specialtyId() == null
|| row.weekNumber() == null || row.weekNumber() <= 0) {
throw new IllegalArgumentException("Курс, специальность и номер недели обязательны");
}
AcademicCalendarMatrix matrix = calendarMatrixRepository
.findBySemesterIdAndCourseNumberAndSpecialtyIdAndWeekNumber(
row.semesterId(),
row.courseNumber(),
row.specialtyId(),
row.weekNumber()
)
.orElseGet(AcademicCalendarMatrix::new);
matrix.setSemester(semester);
matrix.setCourseNumber(row.courseNumber());
matrix.setSpecialtyId(row.specialtyId());
matrix.setWeekNumber(row.weekNumber());
matrix.setActivityType(row.activityType() == null ? AcademicActivityType.THEORY : row.activityType());
calendarMatrixRepository.save(matrix);
}
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Матрица учебного графика сохранена"));
}
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<SemesterDto> 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()
);
}
}

View File

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

View File

@@ -162,6 +162,7 @@ public class LessonsController {
}
//Запрос для получения всего списка занятий
@Deprecated
@GetMapping
public List<LessonResponse> 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;
}
}
}

View File

@@ -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<RenderedLessonDto> 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", "Произошла ошибка при генерации расписания"));
}
}
}

View File

@@ -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<ScheduleRuleDto> 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)
.<ResponseEntity<?>>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<StudentGroup> 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<StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<ScheduleRuleSlotDto> 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Long> groupIds,
List<String> groupNames,
List<ScheduleRuleSlotDto> slots
) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum ScheduleParity {
BOTH,
EVEN,
ODD
}

View File

@@ -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<StudentGroup> groups = new HashSet<>();
@OneToMany(mappedBy = "scheduleRule", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<ScheduleRuleSlot> 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<StudentGroup> getGroups() {
return groups;
}
public void setGroups(Set<StudentGroup> groups) {
this.groups = groups;
}
public Set<ScheduleRuleSlot> getSlots() {
return slots;
}
public void setSlots(Set<ScheduleRuleSlot> slots) {
this.slots = slots;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<AcademicYear, Long> {
Optional<AcademicYear> findByTitle(String title);
}

View File

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

View File

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

View File

@@ -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<ScheduleRuleSlot, Long> {
}

View File

@@ -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<Semester, Long> {
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(LocalDate startDate, LocalDate endDate);
List<Semester> findByAcademicYearIdOrderByStartDateAsc(Long academicYearId);
Optional<Semester> findByAcademicYearIdAndSemesterType(Long academicYearId, SemesterType semesterType);
}

View File

@@ -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<TimeSlot, Long> {
List<TimeSlot> findAllByOrderByOrderNumberAsc();
boolean existsByOrderNumber(Integer orderNumber);
}

View File

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

View File

@@ -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<String, Integer> 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<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
validateRange(startDate, endDate);
StudentGroup group = groupRepository.findById(groupId)
.orElseThrow(() -> new IllegalArgumentException("Группа не найдена"));
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
if (!academicDateService.isTheoryDay(group, semester, date)) {
continue;
}
List<ScheduleRule> 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<RenderedLessonDto> buildScheduleForTeacher(Long teacherId, LocalDate startDate, LocalDate endDate) {
validateRange(startDate, endDate);
if (!userRepository.existsById(teacherId)) {
throw new IllegalArgumentException("Преподаватель не найден");
}
Map<Long, List<ScheduleRule>> rulesBySemester = new HashMap<>();
List<RenderedLessonDto> result = new ArrayList<>();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
Optional<Semester> semesterOpt = academicDateService.findSemester(date);
if (semesterOpt.isEmpty()) {
continue;
}
Semester semester = semesterOpt.get();
List<ScheduleRule> 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<ScheduleRule> sortRules(List<ScheduleRule> rules) {
return rules.stream()
.sorted(Comparator
.comparing((ScheduleRule rule) -> rule.getSubject().getName(), Comparator.nullsLast(String::compareTo))
.thenComparing(ScheduleRule::getId))
.toList();
}
private List<RenderedLessonDto> sortRenderedLessons(List<RenderedLessonDto> 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<RenderedLessonDto> result) {
if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) {
return;
}
if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) {
return;
}
Optional<Semester> 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<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
if (eligibleGroups.isEmpty()) {
return;
}
ScheduleParity dateParity = academicDateService.getParity(semester, date);
int dayOfWeek = date.getDayOfWeek().getValue();
List<ScheduleRuleSlot> 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<StudentGroup> 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<StudentGroup> groups,
int consumedBeforeLesson,
int remainingAfterLesson) {
TimeSlot timeSlot = slot.getTimeSlot();
List<StudentGroup> 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();
}
}