#2 - тест, #5 - готово. Разовое редактирование занятия без изменений правила

This commit is contained in:
Zuev
2026-07-22 00:15:35 +03:00
parent ee876f1acd
commit 92ff87a917
33 changed files with 3324 additions and 251 deletions

View File

@@ -2,7 +2,10 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.Semester;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -11,6 +14,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@RestController
@@ -21,9 +26,20 @@ public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleQueryService scheduleQueryService;
private final SemesterRepository semesterRepository;
public ScheduleController(ScheduleQueryService scheduleQueryService) {
public ScheduleController(ScheduleQueryService scheduleQueryService,
SemesterRepository semesterRepository) {
this.scheduleQueryService = scheduleQueryService;
this.semesterRepository = semesterRepository;
}
@GetMapping("/semesters")
public List<SemesterDto> getSemesters() {
return semesterRepository.findAll().stream()
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
.map(this::toSemesterDto)
.toList();
}
@GetMapping
@@ -54,4 +70,15 @@ public class ScheduleController {
endDate
));
}
private SemesterDto toSemesterDto(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleOverrideService;
import org.springframework.http.ResponseEntity;
@@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/edu-office/schedule/overrides")
@@ -22,8 +24,19 @@ public class ScheduleOverrideController {
}
@GetMapping
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideService.getAll();
public List<ScheduleOverrideDto> getAll(
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate
) {
return scheduleOverrideService.getAll(startDate, endDate);
}
@GetMapping("/availability")
public ScheduleOverrideAvailabilityDto getAvailability(
@RequestParam Long baseRuleSlotId,
@RequestParam LocalDate lessonDate
) {
return scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
}
@PostMapping

View File

@@ -12,6 +12,7 @@ import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.service.TimeSlotService;
import com.magistr.app.service.EffectiveTimeSlotService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -35,15 +36,18 @@ public class TimeSlotAdminController {
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final TimeSlotService timeSlotService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository,
TimeSlotService timeSlotService) {
TimeSlotService timeSlotService,
EffectiveTimeSlotService effectiveTimeSlotService) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
this.timeSlotService = timeSlotService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
}
@GetMapping
@@ -55,7 +59,7 @@ public class TimeSlotAdminController {
@GetMapping("/effective")
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
return ResponseEntity.ok(effectiveTimeSlotService.findEffective(date).stream().map(this::toDto).toList());
}
@GetMapping("/scopes")
@@ -167,35 +171,6 @@ public class TimeSlotAdminController {
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
}
private List<TimeSlot> effectiveSlots(LocalDate date) {
TimeSlotScope defaultScope = defaultScope();
TimeSlotScope effectiveScope = effectiveScope(date);
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
if (effectiveScope.getId().equals(defaultScope.getId())) {
return defaultSlots;
}
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
return new ArrayList<>(slotsByOrder.values());
}
private TimeSlotScope effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()))
.orElseGet(this::defaultScope);
}
private TimeSlotScope defaultScope() {
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
}
private int nextManualDisplayOrder() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(TimeSlotScope::getDisplayOrder)

View File

@@ -37,6 +37,79 @@ public record RenderedLessonDto(
Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson,
Integer remainingLessonTypeAcademicHoursAfterLesson,
com.magistr.app.model.ScheduleParity ruleParity
com.magistr.app.model.ScheduleParity ruleParity,
Long scheduleOverrideId,
String overrideAction,
LocalDate originalLessonDate
) {
public 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,
String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
List<Long> groupIds,
List<String> groupNames,
String activityType,
Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson,
Integer remainingLessonTypeAcademicHoursAfterLesson,
com.magistr.app.model.ScheduleParity ruleParity
) {
this(
scheduleRuleId,
scheduleRuleSlotId,
date,
dayOfWeek,
dayName,
weekNumber,
parity,
timeSlotId,
timeSlotOrder,
startTime,
endTime,
subjectId,
subjectName,
teacherId,
teacherName,
classroomId,
classroomName,
lessonTypeId,
lessonTypeName,
lessonFormat,
subgroupId,
subgroupName,
subgroupIds,
subgroupNames,
groupIds,
groupNames,
activityType,
lessonTypeAcademicHours,
consumedLessonTypeAcademicHoursBeforeLesson,
remainingLessonTypeAcademicHoursAfterLesson,
ruleParity,
null,
null,
null
);
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record ScheduleOverrideAvailabilityDto(
Long semesterId,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
List<LocalDate> availableDates
) {
}

View File

@@ -2,11 +2,14 @@ package com.magistr.app.dto;
import java.time.LocalDate;
import java.time.Instant;
import java.time.LocalTime;
import java.util.List;
public record ScheduleOverrideDto(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetLessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
@@ -14,6 +17,109 @@ public record ScheduleOverrideDto(
String newLessonFormat,
String comment,
Long createdBy,
Instant createdAt
Instant createdAt,
Long semesterId,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
Long sourceTimeSlotId,
Integer sourceTimeSlotOrder,
LocalTime sourceStartTime,
LocalTime sourceEndTime,
Long sourceTeacherId,
String sourceTeacherName,
Long sourceClassroomId,
String sourceClassroomName,
String sourceLessonFormat,
String subjectName,
String lessonTypeName,
List<String> groupNames
) {
public ScheduleOverrideDto(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetLessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment,
Long createdBy,
Instant createdAt
) {
this(
id,
baseRuleSlotId,
lessonDate,
targetLessonDate,
action,
newTimeSlotId,
newClassroomId,
newTeacherId,
newLessonFormat,
comment,
createdBy,
createdAt,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
}
public ScheduleOverrideDto(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String newLessonFormat,
String comment,
Long createdBy,
Instant createdAt
) {
this(
id,
baseRuleSlotId,
lessonDate,
null,
action,
newTimeSlotId,
newClassroomId,
newTeacherId,
newLessonFormat,
comment,
createdBy,
createdAt,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
}
}

View File

@@ -20,6 +20,9 @@ public class ScheduleOverride {
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(name = "target_lesson_date")
private LocalDate targetLessonDate;
@Column(nullable = false, length = 20)
private String action;
@@ -67,6 +70,14 @@ public class ScheduleOverride {
this.lessonDate = lessonDate;
}
public LocalDate getTargetLessonDate() {
return targetLessonDate;
}
public void setTargetLessonDate(LocalDate targetLessonDate) {
this.targetLessonDate = targetLessonDate;
}
public String getAction() {
return action;
}

View File

@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -17,10 +18,13 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
select o
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
""")
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate,
@@ -29,8 +33,62 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
@Query("select o.lessonDate from ScheduleOverride o where o.id = :id")
Optional<LocalDate> findLessonDateById(@Param("id") Long id);
@Query("""
select distinct o
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
join fetch baseSlot.timeSlot
join fetch baseSlot.teacher
join fetch baseSlot.classroom
join fetch baseSlot.lessonType
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findAllWithRegistryDetails();
@Query("""
select distinct o
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
join fetch baseSlot.timeSlot
join fetch baseSlot.teacher
join fetch baseSlot.classroom
join fetch baseSlot.lessonType
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findForRegistry(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
@Query("""
select o
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate in :dates
or o.targetLessonDate in :dates
""")
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from ScheduleOverride o where o.id = :id")

View File

@@ -0,0 +1,76 @@
package com.magistr.app.service;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class EffectiveTimeSlotService {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
public EffectiveTimeSlotService(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
}
@Transactional(readOnly = true)
public List<TimeSlot> findEffective(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Дата временной сетки обязательна");
}
TimeSlotScope defaultScope = defaultScope();
TimeSlotScope effectiveScope = effectiveScope(date, defaultScope);
List<TimeSlot> defaultSlots = timeSlotRepository
.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
if (Objects.equals(effectiveScope.getId(), defaultScope.getId())) {
return defaultSlots;
}
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
return new ArrayList<>(slotsByOrder.values());
}
@Transactional(readOnly = true)
public boolean isEffectiveOnDate(Long timeSlotId, LocalDate date) {
return timeSlotId != null && findEffective(date).stream()
.anyMatch(slot -> Objects.equals(slot.getId(), timeSlotId));
}
private TimeSlotScope effectiveScope(LocalDate date, TimeSlotScope defaultScope) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()))
.orElse(defaultScope);
}
private TimeSlotScope defaultScope() {
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
@@ -17,9 +18,11 @@ import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
@@ -31,7 +34,9 @@ import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
@Service
@@ -49,7 +54,10 @@ public class ScheduleOverrideService {
private final ScheduleQueryService scheduleQueryService;
private final ScheduleGeneratorService scheduleGeneratorService;
private final PostgreSqlAdvisoryLock advisoryLock;
private final AcademicDateService academicDateService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
@Autowired
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
@@ -58,7 +66,9 @@ public class ScheduleOverrideService {
SubgroupRepository subgroupRepository,
ScheduleQueryService scheduleQueryService,
ScheduleGeneratorService scheduleGeneratorService,
PostgreSqlAdvisoryLock advisoryLock) {
PostgreSqlAdvisoryLock advisoryLock,
AcademicDateService academicDateService,
EffectiveTimeSlotService effectiveTimeSlotService) {
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository;
@@ -68,42 +78,124 @@ public class ScheduleOverrideService {
this.scheduleQueryService = scheduleQueryService;
this.scheduleGeneratorService = scheduleGeneratorService;
this.advisoryLock = advisoryLock;
this.academicDateService = academicDateService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
}
ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository,
ClassroomRepository classroomRepository,
UserRepository userRepository,
SubgroupRepository subgroupRepository,
ScheduleQueryService scheduleQueryService,
ScheduleGeneratorService scheduleGeneratorService,
PostgreSqlAdvisoryLock advisoryLock) {
this(
scheduleOverrideRepository,
scheduleRuleSlotRepository,
timeSlotRepository,
classroomRepository,
userRepository,
subgroupRepository,
scheduleQueryService,
scheduleGeneratorService,
advisoryLock,
null,
null
);
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
if ((startDate == null) != (endDate == null)) {
throw new IllegalArgumentException("Дата начала и дата окончания должны быть указаны вместе");
}
if (startDate != null) {
validateRange(startDate, endDate);
}
List<ScheduleOverride> overrides = startDate == null
? scheduleOverrideRepository.findAllWithRegistryDetails()
: scheduleOverrideRepository.findForRegistry(startDate, endDate);
return overrides.stream()
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream()
.map(this::toDto)
return getAll(null, null);
}
@Transactional(readOnly = true)
public ScheduleOverrideAvailabilityDto getAvailability(Long baseRuleSlotId, LocalDate lessonDate) {
if (baseRuleSlotId == null || lessonDate == null) {
throw new IllegalArgumentException("Базовый слот и исходная дата пары обязательны");
}
ScheduleRuleSlot slot = scheduleRuleSlotRepository.findById(baseRuleSlotId)
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
ensureSourceOccurrenceExists(baseRuleSlotId, lessonDate);
var rule = slot.getScheduleRule();
if (rule == null || rule.getSemester() == null) {
throw new IllegalArgumentException("Для базового слота не найден семестр");
}
var semester = rule.getSemester();
if (lessonDate.isBefore(semester.getStartDate()) || lessonDate.isAfter(semester.getEndDate())) {
throw new IllegalArgumentException("Исходная дата пары находится вне семестра правила");
}
if (academicDateService == null) {
throw new IllegalStateException("Сервис учебных дат не настроен");
}
List<com.magistr.app.model.StudentGroup> groups = rule.getGroups().stream()
.sorted(Comparator.comparing(com.magistr.app.model.StudentGroup::getName))
.toList();
var snapshot = academicDateService.createScheduleSnapshot(
groups,
List.of(semester),
semester.getEndDate()
);
List<LocalDate> availableDates = semester.getStartDate()
.datesUntil(semester.getEndDate().plusDays(1))
.filter(date -> rule.isActiveOn(date))
.filter(date -> rule.getSubject().isActiveOn(date))
.filter(date -> groups.stream().allMatch(group -> group.isActiveOn(date)
&& snapshot.isTheoryDay(group, semester, date)))
.toList();
return new ScheduleOverrideAvailabilityDto(
semester.getId(),
semester.getStartDate(),
semester.getEndDate(),
availableDates
);
}
@Transactional
public ScheduleOverrideDto create(ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideDate(command.lessonDate());
lockDatesInStableOrder(command.lessonDate(), command.effectiveDate());
ScheduleOverride current = scheduleOverrideRepository
scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.orElse(null);
return validateAndPersist(current, command);
.ifPresent(existing -> {
throw new ScheduleConflictException(
"Для выбранной базовой пары и даты уже существует точечное изменение"
);
});
return validateAndPersist(null, command);
}
@Transactional
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
lockDatesInStableOrder(observedOldDate, command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedOldDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
lockDatesInStableOrder(
current.getLessonDate(),
effectiveDate(current),
command.lessonDate(),
command.effectiveDate()
);
scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
@@ -119,24 +211,15 @@ public class ScheduleOverrideService {
@Transactional
public void delete(Long id) {
advisoryLock.lockScheduleOverrideId(id);
LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
advisoryLock.lockScheduleOverrideDate(observedDate);
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
if (!Objects.equals(observedDate, current.getLessonDate())) {
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
lockDatesInStableOrder(current.getLessonDate(), effectiveDate(current));
scheduleOverrideRepository.delete(current);
scheduleOverrideRepository.flush();
scheduleGeneratorService.clearCache();
}
private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
ResolvedReferences references = resolveReferences(command);
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
@@ -147,6 +230,8 @@ public class ScheduleOverrideService {
"На выбранную дату базовая пара не формируется по правилам расписания"
);
}
ResolvedReferences references = resolveReferences(command, sourceOccurrences);
validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);
ScheduleOverride candidate = buildCandidate(command, references);
if (!"CANCEL".equals(command.action())) {
@@ -169,22 +254,34 @@ public class ScheduleOverrideService {
List<RenderedLessonDto> baseDay,
List<RenderedLessonDto> sourceOccurrences) {
Long currentId = current == null ? null : current.getId();
List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate())
Set<LocalDate> affectedDates = new TreeSet<>(List.of(command.lessonDate(), command.effectiveDate()));
List<ScheduleOverride> storedOverrides = affectedDates.size() == 1
? scheduleOverrideRepository.findByLessonDateBetweenWithDetails(
command.lessonDate(),
command.lessonDate()
)
: scheduleOverrideRepository.findAffectingDatesWithDetails(affectedDates);
List<ScheduleOverride> effectiveOverrides = Optional.ofNullable(storedOverrides)
.orElseGet(List::of)
.stream()
.filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
.filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
.collect(Collectors.toCollection(ArrayList::new));
effectiveOverrides.add(candidate);
List<RenderedLessonDto> effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups(
baseDay,
command.lessonDate(),
effectiveOverrides
);
List<RenderedLessonDto> candidateOccurrences = effectiveDay.stream()
List<RenderedLessonDto> effectiveSchedule = Objects.equals(command.lessonDate(), command.effectiveDate())
? scheduleQueryService.buildEffectiveDayForAllGroups(
baseDay,
command.lessonDate(),
effectiveOverrides
)
: scheduleQueryService.buildEffectiveDaysForAllGroups(affectedDates, effectiveOverrides);
List<RenderedLessonDto> candidateOccurrences = effectiveSchedule.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
.filter(lesson -> Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|| (Objects.equals(command.lessonDate(), command.effectiveDate())
&& lesson.originalLessonDate() == null))
.toList();
if (candidateOccurrences.isEmpty()) {
throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
@@ -203,13 +300,22 @@ public class ScheduleOverrideService {
);
}
List<RenderedLessonDto> otherOccurrences = effectiveDay.stream()
.filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
List<RenderedLessonDto> otherOccurrences = effectiveSchedule.stream()
.filter(lesson -> !isCandidateOccurrence(lesson, command))
.filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
.toList();
Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
}
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|| (Objects.equals(command.lessonDate(), command.effectiveDate())
&& lesson.originalLessonDate() == null))
&& Objects.equals(lesson.date(), command.effectiveDate());
}
private void validateActionSpecificChange(String action,
List<RenderedLessonDto> sourceOccurrences,
List<RenderedLessonDto> candidateOccurrences) {
@@ -222,7 +328,7 @@ public class ScheduleOverrideService {
.collect(Collectors.toSet());
if (source.equals(candidate)) {
throw new IllegalArgumentException(
"Перенос должен фактически менять время пары или аудиторию"
"Перенос должен фактически менять дату, время пары или аудиторию"
);
}
}
@@ -331,7 +437,8 @@ public class ScheduleOverrideService {
.collect(Collectors.toSet());
}
private ResolvedReferences resolveReferences(OverrideCommand command) {
private ResolvedReferences resolveReferences(OverrideCommand command,
List<RenderedLessonDto> sourceOccurrences) {
ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
TimeSlot newTimeSlot = command.newTimeSlotId() == null
@@ -347,22 +454,73 @@ public class ScheduleOverrideService {
: userRepository.findById(command.newTeacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (newTeacher != null
&& (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) {
if ("CANCEL".equals(command.action())) {
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
}
LocalDate effectiveDate = command.effectiveDate();
User effectiveTeacher = newTeacher == null ? baseRuleSlot.getTeacher() : newTeacher;
Classroom effectiveClassroom = newClassroom == null ? baseRuleSlot.getClassroom() : newClassroom;
if (effectiveTeacher == null
|| effectiveTeacher.getRole() != Role.TEACHER
|| !effectiveTeacher.isActiveOn(effectiveDate)) {
throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
}
if (newClassroom != null
&& (!newClassroom.isActiveOn(command.lessonDate())
|| !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) {
if (effectiveClassroom == null
|| !effectiveClassroom.isActiveOn(effectiveDate)
|| !Boolean.TRUE.equals(effectiveClassroom.getIsAvailable())) {
throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
}
if (newTimeSlot != null
&& effectiveTimeSlotService != null
&& !effectiveTimeSlotService.isEffectiveOnDate(newTimeSlot.getId(), effectiveDate)) {
throw new IllegalArgumentException("Выбранный временной слот не действует на целевую дату");
}
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
}
private void validateTargetDate(OverrideCommand command,
ScheduleRuleSlot baseRuleSlot,
List<RenderedLessonDto> sourceOccurrences) {
if (command.targetLessonDate() == null) {
return;
}
if (academicDateService == null) {
throw new IllegalStateException("Сервис учебных дат не настроен");
}
if (baseRuleSlot.getScheduleRule() == null
|| baseRuleSlot.getScheduleRule().getSemester() == null) {
throw new IllegalArgumentException("Для базового слота не найден семестр");
}
var rule = baseRuleSlot.getScheduleRule();
var semester = rule.getSemester();
LocalDate targetDate = command.targetLessonDate();
if (targetDate.isBefore(semester.getStartDate()) || targetDate.isAfter(semester.getEndDate())) {
throw new IllegalArgumentException("Перенести занятие можно только в пределах того же семестра");
}
if (!rule.isActiveOn(targetDate) || !rule.getSubject().isActiveOn(targetDate)) {
throw new IllegalArgumentException("Правило или дисциплина не действуют на целевую дату");
}
Set<Long> affectedGroupIds = sourceOccurrences.stream()
.flatMap(lesson -> safeIds(lesson.groupIds()).stream())
.collect(Collectors.toSet());
List<com.magistr.app.model.StudentGroup> affectedGroups = rule.getGroups().stream()
.filter(group -> affectedGroupIds.contains(group.getId()))
.toList();
if (affectedGroups.size() != affectedGroupIds.size()
|| affectedGroups.stream().anyMatch(group -> !group.isActiveOn(targetDate)
|| !academicDateService.isTheoryDay(group, semester, targetDate))) {
throw new IllegalArgumentException(
"Целевая дата должна быть учебной для всех групп исходного занятия"
);
}
}
private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
ScheduleOverride candidate = new ScheduleOverride();
candidate.setBaseRuleSlot(references.baseRuleSlot());
candidate.setLessonDate(command.lessonDate());
candidate.setTargetLessonDate(command.targetLessonDate());
candidate.setAction(command.action());
candidate.setNewTimeSlot(references.newTimeSlot());
candidate.setNewClassroom(references.newClassroom());
@@ -374,6 +532,7 @@ public class ScheduleOverrideService {
private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
target.setBaseRuleSlot(source.getBaseRuleSlot());
target.setLessonDate(source.getLessonDate());
target.setTargetLessonDate(source.getTargetLessonDate());
target.setAction(source.getAction());
target.setNewTimeSlot(source.getNewTimeSlot());
target.setNewClassroom(source.getNewClassroom());
@@ -397,21 +556,34 @@ public class ScheduleOverrideService {
throw new IllegalArgumentException("Недопустимое действие изменения расписания");
}
String lessonFormat = clean(request.newLessonFormat());
LocalDate targetLessonDate = request.targetLessonDate();
if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
}
if ("CANCEL".equals(action)
&& (request.newTimeSlotId() != null
&& (targetLessonDate != null
|| request.newTimeSlotId() != null
|| request.newClassroomId() != null
|| request.newTeacherId() != null
|| lessonFormat != null)) {
throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми");
}
if (targetLessonDate != null && Objects.equals(targetLessonDate, request.lessonDate())) {
throw new IllegalArgumentException("Целевая дата должна отличаться от исходной даты пары");
}
if (targetLessonDate != null && request.newTimeSlotId() == null) {
throw new IllegalArgumentException("Для переноса даты выберите временной слот целевого дня");
}
if (targetLessonDate != null && !"MOVE".equals(action)) {
throw new IllegalArgumentException("Перенос даты должен использовать действие MOVE");
}
if ("MOVE".equals(action)
&& request.newTimeSlotId() == null
&& request.newClassroomId() == null) {
throw new IllegalArgumentException("Для переноса укажите новое время или аудиторию");
&& request.newTimeSlotId() == null) {
throw new IllegalArgumentException("Для переноса укажите новое время");
}
if ("REPLACE".equals(action) && request.newTimeSlotId() != null) {
throw new IllegalArgumentException("Изменение времени должно использовать действие MOVE");
}
if ("REPLACE".equals(action)
&& request.newTeacherId() == null
@@ -424,6 +596,7 @@ public class ScheduleOverrideService {
return new OverrideCommand(
request.baseRuleSlotId(),
request.lessonDate(),
targetLessonDate,
action,
request.newTimeSlotId(),
request.newClassroomId(),
@@ -433,8 +606,9 @@ public class ScheduleOverrideService {
);
}
private void lockDatesInStableOrder(LocalDate first, LocalDate second) {
List.of(first, second).stream()
private void lockDatesInStableOrder(LocalDate... dates) {
java.util.Arrays.stream(dates)
.filter(Objects::nonNull)
.distinct()
.sorted(Comparator.naturalOrder())
.forEach(advisoryLock::lockScheduleOverrideDate);
@@ -446,15 +620,55 @@ public class ScheduleOverrideService {
&& Objects.equals(override.getLessonDate(), lessonDate);
}
private LocalDate effectiveDate(ScheduleOverride override) {
return override.getTargetLessonDate() == null
? override.getLessonDate()
: override.getTargetLessonDate();
}
private void ensureSourceOccurrenceExists(Long baseRuleSlotId, LocalDate lessonDate) {
boolean exists = scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream()
.anyMatch(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId)
&& Objects.equals(lesson.date(), lessonDate));
if (!exists) {
throw new IllegalArgumentException(
"На выбранную дату базовая пара не формируется по правилам расписания"
);
}
}
private void validateRange(LocalDate startDate, LocalDate endDate) {
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
}
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
}
}
private NoSuchElementException notFound(Long id) {
return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
}
private ScheduleOverrideDto toDto(ScheduleOverride override) {
ScheduleRuleSlot baseSlot = override.getBaseRuleSlot();
var rule = baseSlot == null ? null : baseSlot.getScheduleRule();
var semester = rule == null ? null : rule.getSemester();
TimeSlot sourceTimeSlot = baseSlot == null ? null : baseSlot.getTimeSlot();
User sourceTeacher = baseSlot == null ? null : baseSlot.getTeacher();
Classroom sourceClassroom = baseSlot == null ? null : baseSlot.getClassroom();
List<String> groupNames = rule == null || rule.getGroups() == null
? List.of()
: rule.getGroups().stream()
.map(com.magistr.app.model.StudentGroup::getName)
.filter(Objects::nonNull)
.sorted()
.toList();
return new ScheduleOverrideDto(
override.getId(),
override.getBaseRuleSlot().getId(),
baseSlot == null ? null : baseSlot.getId(),
override.getLessonDate(),
override.getTargetLessonDate(),
override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
@@ -462,7 +676,24 @@ public class ScheduleOverrideService {
override.getNewLessonFormat(),
override.getComment(),
override.getCreatedBy(),
override.getCreatedAt()
override.getCreatedAt(),
semester == null ? null : semester.getId(),
semester == null ? null : semester.getStartDate(),
semester == null ? null : semester.getEndDate(),
sourceTimeSlot == null ? null : sourceTimeSlot.getId(),
sourceTimeSlot == null ? null : sourceTimeSlot.getOrderNumber(),
sourceTimeSlot == null ? null : sourceTimeSlot.getStartTime(),
sourceTimeSlot == null ? null : sourceTimeSlot.getEndTime(),
sourceTeacher == null ? null : sourceTeacher.getId(),
sourceTeacher == null ? null : ScheduleGeneratorService.displayUserName(sourceTeacher),
sourceClassroom == null ? null : sourceClassroom.getId(),
sourceClassroom == null ? null : sourceClassroom.getName(),
baseSlot == null ? null : baseSlot.getLessonFormat(),
rule == null || rule.getSubject() == null ? null : rule.getSubject().getName(),
baseSlot == null || baseSlot.getLessonType() == null
? null
: baseSlot.getLessonType().getLessonType(),
groupNames
);
}
@@ -473,6 +704,7 @@ public class ScheduleOverrideService {
private record OverrideCommand(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetLessonDate,
String action,
Long newTimeSlotId,
Long newClassroomId,
@@ -480,6 +712,9 @@ public class ScheduleOverrideService {
String newLessonFormat,
String comment
) {
private LocalDate effectiveDate() {
return targetLessonDate == null ? lessonDate : targetLessonDate;
}
}
private record ResolvedReferences(
@@ -491,6 +726,7 @@ public class ScheduleOverrideService {
}
private record EffectiveSourceTuple(
LocalDate date,
Long timeSlotId,
LocalTime startTime,
LocalTime endTime,
@@ -500,6 +736,7 @@ public class ScheduleOverrideService {
) {
private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
return new EffectiveSourceTuple(
lesson.date(),
lesson.timeSlotId(),
lesson.startTime(),
lesson.endTime(),
@@ -511,12 +748,13 @@ public class ScheduleOverrideService {
}
private record MoveTuple(
LocalDate date,
LocalTime startTime,
LocalTime endTime,
Long classroomId
) {
private static MoveTuple from(RenderedLessonDto lesson) {
return new MoveTuple(lesson.startTime(), lesson.endTime(), lesson.classroomId());
return new MoveTuple(lesson.date(), lesson.startTime(), lesson.endTime(), lesson.classroomId());
}
}

View File

@@ -5,6 +5,7 @@ import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -22,15 +23,26 @@ public class ScheduleQueryService {
private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicDateService academicDateService;
@Autowired
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService;
this.academicDateService = academicDateService;
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
}
/**
@@ -75,6 +87,31 @@ public class ScheduleQueryService {
return deduplicate(applyOverrides(safeBaseDay, date, overrides));
}
/**
* Строит несколько эффективных дней с учётом входящих и исходящих переносов.
* Исходная пара достраивается даже тогда, когда её дата не входит в результирующий набор.
*/
public List<RenderedLessonDto> buildEffectiveDaysForAllGroups(
Collection<LocalDate> dates,
Collection<ScheduleOverride> overrides
) {
Set<LocalDate> requestedDates = Optional.ofNullable(dates)
.orElseGet(List::of)
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toCollection(TreeSet::new));
if (requestedDates.isEmpty()) {
return List.of();
}
List<RenderedLessonDto> baseLessons = requestedDates.stream()
.flatMap(date -> buildBaseDayForAllGroups(date).stream())
.collect(Collectors.toCollection(ArrayList::new));
addOverrideSourceOccurrences(baseLessons, overrides, null);
return deduplicate(applyOverrides(baseLessons, null, overrides).stream()
.filter(lesson -> requestedDates.contains(lesson.date()))
.toList());
}
public List<RenderedLessonDto> search(Long groupId,
Long teacherId,
Long classroomId,
@@ -137,29 +174,26 @@ public class ScheduleQueryService {
boolean enforceInteractiveGroupLimit) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons;
List<StudentGroup> scopedGroups = null;
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
if (teacherOnlySearch) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else {
List<StudentGroup> groups = resolveGroups(
scopedGroups = resolveGroups(
groupId,
departmentId,
startDate,
endDate,
enforceInteractiveGroupLimit
);
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(scopedGroups, startDate, endDate);
}
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate);
if (teacherOnlySearch) {
generatedLessons = addOverrideSourceOccurrencesForTeacher(
generatedLessons,
teacherId,
overrideSnapshot
);
}
generatedLessons = new ArrayList<>(generatedLessons);
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
@@ -170,10 +204,10 @@ public class ScheduleQueryService {
return deduplicate(lessons);
}
private List<RenderedLessonDto> addOverrideSourceOccurrencesForTeacher(
private void addOverrideSourceOccurrences(
List<RenderedLessonDto> generatedLessons,
Long teacherId,
Collection<ScheduleOverride> overrideSnapshot
Collection<ScheduleOverride> overrideSnapshot,
List<StudentGroup> scopedGroups
) {
Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
.orElseGet(List::of)
@@ -182,8 +216,7 @@ public class ScheduleQueryService {
.filter(override -> override.getLessonDate() != null)
.filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getBaseRuleSlot().getId() != null)
.filter(override -> override.getNewTeacher() != null)
.filter(override -> Objects.equals(override.getNewTeacher().getId(), teacherId))
.filter(override -> !"CANCEL".equals(override.getAction()))
.collect(Collectors.groupingBy(
ScheduleOverride::getLessonDate,
TreeMap::new,
@@ -193,15 +226,27 @@ public class ScheduleQueryService {
)
));
if (relevantSlotsByDate.isEmpty()) {
return generatedLessons;
return;
}
List<RenderedLessonDto> supplementedLessons = new ArrayList<>(generatedLessons);
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> buildBaseDayForAllGroups(date).stream()
.filter(lesson -> date.equals(lesson.date()))
.filter(lesson -> baseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
.forEach(supplementedLessons::add));
return supplementedLessons;
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> {
Set<Long> missingBaseRuleSlotIds = baseRuleSlotIds.stream()
.filter(baseRuleSlotId -> generatedLessons.stream().noneMatch(lesson ->
date.equals(lesson.date())
&& Objects.equals(baseRuleSlotId, lesson.scheduleRuleSlotId())
))
.collect(Collectors.toCollection(LinkedHashSet::new));
if (missingBaseRuleSlotIds.isEmpty()) {
return;
}
List<RenderedLessonDto> sourceDay = scopedGroups == null
? buildBaseDayForAllGroups(date)
: deduplicate(scheduleGeneratorService.buildScheduleForGroups(scopedGroups, date, date));
sourceDay.stream()
.filter(lesson -> date.equals(lesson.date()))
.filter(lesson -> missingBaseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
.forEach(generatedLessons::add);
});
}
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
@@ -235,6 +280,26 @@ public class ScheduleQueryService {
if ("CANCEL".equals(override.getAction())) {
return null;
}
LocalDate effectiveDate = override.getTargetLessonDate() == null
? lesson.date()
: override.getTargetLessonDate();
Integer dayOfWeek = lesson.dayOfWeek();
String dayName = lesson.dayName();
Integer weekNumber = lesson.weekNumber();
com.magistr.app.model.ScheduleParity parity = lesson.parity();
if (!Objects.equals(effectiveDate, lesson.date())) {
if (academicDateService == null
|| override.getBaseRuleSlot() == null
|| override.getBaseRuleSlot().getScheduleRule() == null
|| override.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
throw new IllegalStateException("Невозможно пересчитать календарные параметры перенесённой пары");
}
var semester = override.getBaseRuleSlot().getScheduleRule().getSemester();
dayOfWeek = effectiveDate.getDayOfWeek().getValue();
dayName = dayName(effectiveDate.getDayOfWeek());
weekNumber = academicDateService.getWeekNumber(semester, effectiveDate);
parity = academicDateService.getParity(semester, effectiveDate);
}
Long timeSlotId = lesson.timeSlotId();
Integer timeSlotOrder = lesson.timeSlotOrder();
java.time.LocalTime startTime = lesson.startTime();
@@ -255,11 +320,11 @@ public class ScheduleQueryService {
return new RenderedLessonDto(
lesson.scheduleRuleId(),
lesson.scheduleRuleSlotId(),
lesson.date(),
lesson.dayOfWeek(),
lesson.dayName(),
lesson.weekNumber(),
lesson.parity(),
effectiveDate,
dayOfWeek,
dayName,
weekNumber,
parity,
timeSlotId,
timeSlotOrder,
startTime,
@@ -283,10 +348,25 @@ public class ScheduleQueryService {
lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
lesson.remainingLessonTypeAcademicHoursAfterLesson(),
lesson.ruleParity()
lesson.ruleParity(),
override.getId(),
override.getAction(),
lesson.date()
);
}
private String dayName(java.time.DayOfWeek dayOfWeek) {
return switch (dayOfWeek) {
case MONDAY -> "Понедельник";
case TUESDAY -> "Вторник";
case WEDNESDAY -> "Среда";
case THURSDAY -> "Четверг";
case FRIDAY -> "Пятница";
case SATURDAY -> "Суббота";
case SUNDAY -> "Воскресенье";
};
}
private String overrideKey(Long scheduleRuleSlotId, LocalDate date) {
return scheduleRuleSlotId + ":" + date;
}
@@ -356,7 +436,9 @@ public class ScheduleQueryService {
String.valueOf(lesson.timeSlotOrder()),
String.valueOf(lesson.teacherId()),
String.valueOf(lesson.subjectId()),
sortedSignature(lesson.subgroupIds())
sortedSignature(lesson.subgroupIds()),
String.valueOf(lesson.scheduleOverrideId()),
String.valueOf(lesson.originalLessonDate())
);
}
@@ -428,7 +510,10 @@ public class ScheduleQueryService {
source.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(),
source.remainingLessonTypeAcademicHoursAfterLesson(),
source.ruleParity()
source.ruleParity(),
source.scheduleOverrideId(),
source.overrideAction(),
source.originalLessonDate()
);
}
}