This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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("Базовая сетка времени не настроена"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +873,7 @@ CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
target_lesson_date DATE,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
new_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
new_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
@@ -888,6 +889,10 @@ CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
|
||||
ON schedule_overrides(lesson_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_target_date
|
||||
ON schedule_overrides(target_lesson_date)
|
||||
WHERE target_lesson_date IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
|
||||
ON schedule_overrides(new_teacher_id);
|
||||
|
||||
@@ -1121,6 +1126,8 @@ COMMENT ON TABLE teacher_department_assignments IS 'История принад
|
||||
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
|
||||
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
|
||||
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
|
||||
COMMENT ON COLUMN schedule_overrides.lesson_date IS 'Исходная дата занятия, сформированного базовым правилом';
|
||||
COMMENT ON COLUMN schedule_overrides.target_lesson_date IS 'Новая дата единственного занятия при действии MOVE';
|
||||
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
||||
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
||||
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
||||
@@ -1288,12 +1295,14 @@ DECLARE
|
||||
move_violations BIGINT;
|
||||
replace_violations BIGINT;
|
||||
format_violations BIGINT;
|
||||
target_date_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO cancel_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'CANCEL'
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
AND (target_lesson_date IS NOT NULL
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL);
|
||||
@@ -1302,16 +1311,16 @@ BEGIN
|
||||
INTO move_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'MOVE'
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL;
|
||||
AND new_time_slot_id IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO replace_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'REPLACE'
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL;
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
OR (new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL));
|
||||
|
||||
SELECT count(*)
|
||||
INTO format_violations
|
||||
@@ -1319,18 +1328,28 @@ BEGIN
|
||||
WHERE new_lesson_format IS NOT NULL
|
||||
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
|
||||
|
||||
SELECT count(*)
|
||||
INTO target_date_violations
|
||||
FROM schedule_overrides
|
||||
WHERE target_lesson_date IS NOT NULL
|
||||
AND (target_lesson_date = lesson_date
|
||||
OR action <> 'MOVE'
|
||||
OR new_time_slot_id IS NULL);
|
||||
|
||||
IF cancel_violations > 0
|
||||
OR move_violations > 0
|
||||
OR replace_violations > 0
|
||||
OR format_violations > 0 THEN
|
||||
OR format_violations > 0
|
||||
OR target_date_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
format_violations
|
||||
format_violations,
|
||||
target_date_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1340,7 +1359,8 @@ ALTER TABLE schedule_overrides
|
||||
CHECK (
|
||||
action <> 'CANCEL'
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
target_lesson_date IS NULL
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_lesson_format IS NULL
|
||||
@@ -1350,19 +1370,32 @@ ALTER TABLE schedule_overrides
|
||||
CHECK (
|
||||
action <> 'MOVE'
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_replace_payload
|
||||
CHECK (
|
||||
action <> 'REPLACE'
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
AND (
|
||||
new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
)
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_format
|
||||
CHECK (
|
||||
new_lesson_format IS NULL
|
||||
OR new_lesson_format IN ('Очно', 'Онлайн')
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_target_date
|
||||
CHECK (
|
||||
target_lesson_date IS NULL
|
||||
OR (
|
||||
target_lesson_date <> lesson_date
|
||||
AND action = 'MOVE'
|
||||
AND new_time_slot_id IS NOT NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
@@ -1377,6 +1410,9 @@ ALTER TABLE schedule_overrides
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_format;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_target_date;
|
||||
|
||||
-- ==========================================
|
||||
-- Инварианты правил расписания
|
||||
-- ==========================================
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsSemestersForReadOnlyScheduleFilterInNewestFirstOrder() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
SemesterRepository semesterRepository = mock(SemesterRepository.class);
|
||||
ScheduleController controller = new ScheduleController(scheduleQueryService, semesterRepository);
|
||||
|
||||
Semester autumn = semester(1L, SemesterType.autumn,
|
||||
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 1, 31));
|
||||
Semester spring = semester(2L, SemesterType.spring,
|
||||
LocalDate.of(2026, 2, 9), LocalDate.of(2026, 6, 30));
|
||||
when(semesterRepository.findAll()).thenReturn(List.of(autumn, spring));
|
||||
|
||||
var result = controller.getSemesters();
|
||||
|
||||
assertThat(result).extracting(item -> item.id()).containsExactly(2L, 1L);
|
||||
assertThat(result.get(0).academicYearTitle()).isEqualTo("2025/2026");
|
||||
assertThat(result.get(0).semesterType()).isEqualTo(SemesterType.spring);
|
||||
assertThat(result.get(0).startDate()).isEqualTo(LocalDate.of(2026, 2, 9));
|
||||
assertThat(result.get(0).endDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||
}
|
||||
|
||||
private Semester semester(Long id, SemesterType type, LocalDate startDate, LocalDate endDate) {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(10L);
|
||||
academicYear.setTitle("2025/2026");
|
||||
academicYear.setStartDate(LocalDate.of(2025, 9, 1));
|
||||
academicYear.setEndDate(LocalDate.of(2026, 8, 31));
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setId(id);
|
||||
semester.setAcademicYear(academicYear);
|
||||
semester.setSemesterType(type);
|
||||
semester.setStartDate(startDate);
|
||||
semester.setEndDate(endDate);
|
||||
return semester;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.service.ScheduleConflictException;
|
||||
import com.magistr.app.service.ScheduleOverrideService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -8,9 +9,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -18,6 +24,9 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
|
||||
|
||||
class ScheduleOverrideControllerTest {
|
||||
|
||||
private static final LocalDate SOURCE_DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -51,4 +60,43 @@ class ScheduleOverrideControllerTest {
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Конфликт расписания: преподаватель уже занят в указанное время"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsRegistryBySourceOrTargetRangeWithTargetDate() throws Exception {
|
||||
ScheduleOverrideDto override = new ScheduleOverrideDto(
|
||||
70L, 101L, SOURCE_DATE, TARGET_DATE, "MOVE", 2L,
|
||||
null, null, null, "Перенос", 5L, null
|
||||
);
|
||||
when(service.getAll(SOURCE_DATE, TARGET_DATE)).thenReturn(List.of(override));
|
||||
|
||||
mockMvc.perform(get("/api/edu-office/schedule/overrides")
|
||||
.param("startDate", SOURCE_DATE.toString())
|
||||
.param("endDate", TARGET_DATE.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].lessonDate[0]").value(2026))
|
||||
.andExpect(jsonPath("$[0].lessonDate[1]").value(9))
|
||||
.andExpect(jsonPath("$[0].lessonDate[2]").value(1))
|
||||
.andExpect(jsonPath("$[0].targetLessonDate[2]").value(10));
|
||||
|
||||
verify(service).getAll(SOURCE_DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSemesterAvailabilityForSourceOccurrence() throws Exception {
|
||||
when(service.getAvailability(101L, SOURCE_DATE)).thenReturn(new ScheduleOverrideAvailabilityDto(
|
||||
8L,
|
||||
LocalDate.of(2026, 9, 1),
|
||||
LocalDate.of(2027, 1, 31),
|
||||
List.of(SOURCE_DATE, TARGET_DATE)
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/edu-office/schedule/overrides/availability")
|
||||
.param("baseRuleSlotId", "101")
|
||||
.param("lessonDate", SOURCE_DATE.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.semesterId").value(8))
|
||||
.andExpect(jsonPath("$.availableDates[1][0]").value(2026))
|
||||
.andExpect(jsonPath("$.availableDates[1][1]").value(9))
|
||||
.andExpect(jsonPath("$.availableDates[1][2]").value(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +44,54 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
LocalDate validDate = LocalDate.of(2026, 3, 2);
|
||||
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'schedule_overrides'
|
||||
AND column_name = 'target_lesson_date'
|
||||
"""))
|
||||
.as("V1 должна создавать целевую дату переноса")
|
||||
.isEqualTo(1L);
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'schedule_overrides'
|
||||
AND indexname = 'idx_schedule_overrides_target_date'
|
||||
"""))
|
||||
.as("V1 должна создавать индекс целевой даты")
|
||||
.isEqualTo(1L);
|
||||
|
||||
insertOverride(connection, seedIds, validDate, "CANCEL", null, null, null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(1), "MOVE", seedIds.classroomId(), null, null);
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
validDate.plusDays(1),
|
||||
null,
|
||||
"MOVE",
|
||||
seedIds.timeSlotId(),
|
||||
seedIds.classroomId(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(2), "REPLACE", null, seedIds.teacherId(), null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, null, "Онлайн");
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
validDate.plusDays(4),
|
||||
validDate.plusDays(9),
|
||||
"MOVE",
|
||||
seedIds.timeSlotId(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
|
||||
.as("Все допустимые варианты точечного изменения должны сохраниться")
|
||||
.isEqualTo(4L);
|
||||
.isEqualTo(5L);
|
||||
|
||||
LocalDate invalidDate = LocalDate.of(2026, 4, 6);
|
||||
assertCheckViolation(
|
||||
@@ -94,6 +134,14 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
"Смешанный",
|
||||
"chk_schedule_overrides_format"
|
||||
);
|
||||
assertTargetDateCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate.plusDays(4),
|
||||
invalidDate.plusDays(4),
|
||||
"MOVE",
|
||||
seedIds.timeSlotId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +158,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
return new SeedIds(
|
||||
queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM classrooms ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1")
|
||||
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM time_slots ORDER BY id LIMIT 1")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,27 +171,79 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat
|
||||
) throws SQLException {
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
lessonDate,
|
||||
null,
|
||||
action,
|
||||
null,
|
||||
classroomId,
|
||||
teacherId,
|
||||
lessonFormat
|
||||
);
|
||||
}
|
||||
|
||||
private void insertOverride(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long timeSlotId,
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat
|
||||
) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_overrides (
|
||||
base_rule_slot_id,
|
||||
lesson_date,
|
||||
target_lesson_date,
|
||||
action,
|
||||
new_time_slot_id,
|
||||
new_classroom_id,
|
||||
new_teacher_id,
|
||||
new_lesson_format
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
statement.setLong(1, seedIds.baseRuleSlotId());
|
||||
statement.setDate(2, Date.valueOf(lessonDate));
|
||||
statement.setString(3, action);
|
||||
setNullableLong(statement, 4, classroomId);
|
||||
setNullableLong(statement, 5, teacherId);
|
||||
statement.setString(6, lessonFormat);
|
||||
statement.setObject(3, targetLessonDate == null ? null : Date.valueOf(targetLessonDate));
|
||||
statement.setString(4, action);
|
||||
setNullableLong(statement, 5, timeSlotId);
|
||||
setNullableLong(statement, 6, classroomId);
|
||||
setNullableLong(statement, 7, teacherId);
|
||||
statement.setString(8, lessonFormat);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertTargetDateCheckViolation(Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long timeSlotId) {
|
||||
Throwable failure = catchThrowable(() -> insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
lessonDate,
|
||||
targetLessonDate,
|
||||
action,
|
||||
timeSlotId,
|
||||
action.equals("MOVE") && timeSlotId == null ? seedIds.classroomId() : null,
|
||||
action.equals("REPLACE") ? seedIds.teacherId() : null,
|
||||
null
|
||||
));
|
||||
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo("23514");
|
||||
assertThat(sqlFailure.getMessage()).contains("chk_schedule_overrides_target_date");
|
||||
}
|
||||
|
||||
private void assertCheckViolation(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
@@ -197,6 +298,6 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId, long timeSlotId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,70 @@ class PostgreSqlAdvisoryLockIntegrationTest {
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesConcurrentMovesToSameResourcesAndTargetDate() throws Exception {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schedule_move_concurrency_probe (
|
||||
source_date DATE NOT NULL,
|
||||
target_date DATE NOT NULL,
|
||||
teacher_id BIGINT NOT NULL,
|
||||
classroom_id BIGINT NOT NULL
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.update("DELETE FROM schedule_move_concurrency_probe");
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Future<Boolean> first = executor.submit(() -> tryMove(
|
||||
TEST_DATE.minusDays(7), TEST_DATE, 11L, 21L, ready, start
|
||||
));
|
||||
Future<Boolean> second = executor.submit(() -> tryMove(
|
||||
TEST_DATE.minusDays(14), TEST_DATE, 11L, 21L, ready, start
|
||||
));
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
|
||||
assertThat(java.util.List.of(
|
||||
first.get(5, TimeUnit.SECONDS),
|
||||
second.get(5, TimeUnit.SECONDS)
|
||||
)).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM schedule_move_concurrency_probe",
|
||||
Long.class
|
||||
)).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private boolean tryMove(LocalDate sourceDate,
|
||||
LocalDate targetDate,
|
||||
long teacherId,
|
||||
long classroomId,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
ready.countDown();
|
||||
await(start, "Конкурентные переносы не получили общий сигнал запуска");
|
||||
Boolean saved = transactionTemplate.execute(status -> {
|
||||
advisoryLock.lockScheduleOverrideDate(targetDate);
|
||||
Long conflicts = jdbcTemplate.queryForObject("""
|
||||
SELECT count(*)
|
||||
FROM schedule_move_concurrency_probe
|
||||
WHERE target_date = ?
|
||||
AND (teacher_id = ? OR classroom_id = ?)
|
||||
""", Long.class, targetDate, teacherId, classroomId);
|
||||
if (conflicts != null && conflicts > 0) {
|
||||
return false;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO schedule_move_concurrency_probe (
|
||||
source_date, target_date, teacher_id, classroom_id
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""", sourceDate, targetDate, teacherId, classroomId);
|
||||
return true;
|
||||
});
|
||||
return Boolean.TRUE.equals(saved);
|
||||
}
|
||||
|
||||
private void awaitAdvisoryLockWait(int backendPid) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
do {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
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;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
@@ -30,6 +35,7 @@ import java.time.LocalTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -45,6 +51,7 @@ import static org.mockito.Mockito.when;
|
||||
class ScheduleOverrideServiceTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
private static final Long BASE_SLOT_ID = 101L;
|
||||
private static final Long OTHER_SLOT_ID = 202L;
|
||||
private static final Long SOURCE_TIME_SLOT_ID = 1L;
|
||||
@@ -76,6 +83,10 @@ class ScheduleOverrideServiceTest {
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
@Mock
|
||||
private PostgreSqlAdvisoryLock advisoryLock;
|
||||
@Mock
|
||||
private AcademicDateService academicDateService;
|
||||
@Mock
|
||||
private EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private ScheduleRuleSlot baseSlot;
|
||||
@@ -96,6 +107,10 @@ class ScheduleOverrideServiceTest {
|
||||
newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
|
||||
sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
|
||||
newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
|
||||
baseSlot.setTimeSlot(sourceTimeSlot);
|
||||
baseSlot.setTeacher(sourceTeacher);
|
||||
baseSlot.setClassroom(sourceClassroom);
|
||||
baseSlot.setLessonFormat("Очно");
|
||||
sourceLesson = lesson(
|
||||
BASE_SLOT_ID,
|
||||
SOURCE_TIME_SLOT_ID,
|
||||
@@ -117,7 +132,9 @@ class ScheduleOverrideServiceTest {
|
||||
subgroupRepository,
|
||||
scheduleQueryService,
|
||||
scheduleGeneratorService,
|
||||
advisoryLock
|
||||
advisoryLock,
|
||||
academicDateService,
|
||||
effectiveTimeSlotService
|
||||
);
|
||||
|
||||
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
|
||||
@@ -133,6 +150,7 @@ class ScheduleOverrideServiceTest {
|
||||
when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson));
|
||||
when(effectiveTimeSlotService.isEffectiveOnDate(any(Long.class), any(LocalDate.class))).thenReturn(true);
|
||||
when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
|
||||
List<Long> subgroupIds = invocation.getArgument(0);
|
||||
return subgroupIds.stream()
|
||||
@@ -148,6 +166,47 @@ class ScheduleOverrideServiceTest {
|
||||
.hasMessage("Передайте данные изменения расписания");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryRangeReturnsOverrideWhoseTargetDateIsInsidePeriod() {
|
||||
ScheduleOverride moved = override(720L, baseSlot, "MOVE", newTimeSlot);
|
||||
moved.setTargetLessonDate(TARGET_DATE);
|
||||
when(scheduleOverrideRepository.findForRegistry(TARGET_DATE, TARGET_DATE)).thenReturn(List.of(moved));
|
||||
|
||||
List<ScheduleOverrideDto> result = service.getAll(TARGET_DATE, TARGET_DATE);
|
||||
|
||||
assertThat(result).singleElement().satisfies(item -> {
|
||||
assertThat(item.lessonDate()).isEqualTo(DATE);
|
||||
assertThat(item.targetLessonDate()).isEqualTo(TARGET_DATE);
|
||||
});
|
||||
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void availabilityContainsOnlyStudyDatesOfSourceSemester() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
StudentGroup group = baseSlot.getScheduleRule().getGroups().iterator().next();
|
||||
AcademicDateService.ScheduleSnapshot snapshot = org.mockito.Mockito.mock(
|
||||
AcademicDateService.ScheduleSnapshot.class
|
||||
);
|
||||
when(academicDateService.createScheduleSnapshot(
|
||||
any(Collection.class),
|
||||
any(Collection.class),
|
||||
eq(semester.getEndDate())
|
||||
)).thenReturn(snapshot);
|
||||
when(snapshot.isTheoryDay(eq(group), eq(semester), any(LocalDate.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
LocalDate date = invocation.getArgument(2);
|
||||
return date.equals(DATE) || date.equals(TARGET_DATE);
|
||||
});
|
||||
|
||||
ScheduleOverrideAvailabilityDto result = service.getAvailability(BASE_SLOT_ID, DATE);
|
||||
|
||||
assertThat(result.semesterId()).isEqualTo(semester.getId());
|
||||
assertThat(result.semesterStartDate()).isEqualTo(semester.getStartDate());
|
||||
assertThat(result.semesterEndDate()).isEqualTo(semester.getEndDate());
|
||||
assertThat(result.availableDates()).containsExactly(DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredRequestReferences() {
|
||||
assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null)))
|
||||
@@ -203,7 +262,7 @@ class ScheduleOverrideServiceTest {
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для переноса укажите новое время или аудиторию");
|
||||
.hasMessage("Для переноса укажите новое время");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
|
||||
@@ -289,10 +348,10 @@ class ScheduleOverrideServiceTest {
|
||||
stubEffectiveDay(sourceLesson);
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, SOURCE_CLASSROOM_ID, null, null
|
||||
BASE_SLOT_ID, DATE, "MOVE", SOURCE_TIME_SLOT_ID, SOURCE_CLASSROOM_ID, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
.hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -315,7 +374,7 @@ class ScheduleOverrideServiceTest {
|
||||
BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
.hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -440,7 +499,6 @@ class ScheduleOverrideServiceTest {
|
||||
@Test
|
||||
void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
|
||||
ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null);
|
||||
when(scheduleOverrideRepository.findLessonDateById(500L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
@@ -470,7 +528,6 @@ class ScheduleOverrideServiceTest {
|
||||
@Test
|
||||
void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
|
||||
ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot);
|
||||
when(scheduleOverrideRepository.findLessonDateById(501L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
@@ -489,6 +546,262 @@ class ScheduleOverrideServiceTest {
|
||||
verify(scheduleOverrideRepository).saveAndFlush(stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCombinedMoveToStudyDateInSameSemester() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(true);
|
||||
when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
|
||||
when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
|
||||
.thenReturn(List.of(relocatedLesson(
|
||||
BASE_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_TEACHER_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
"Онлайн"
|
||||
)));
|
||||
|
||||
ScheduleOverrideDto saved = service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
NEW_TEACHER_ID,
|
||||
"Онлайн"
|
||||
));
|
||||
|
||||
assertThat(saved.targetLessonDate()).isEqualTo(TARGET_DATE);
|
||||
assertThat(saved.action()).isEqualTo("MOVE");
|
||||
assertThat(saved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
|
||||
assertThat(saved.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
|
||||
assertThat(saved.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
|
||||
assertThat(saved.newLessonFormat()).isEqualTo("Онлайн");
|
||||
verify(advisoryLock).lockScheduleOverrideDate(DATE);
|
||||
verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateOutsideSourceSemester() {
|
||||
configureRuleForTarget();
|
||||
LocalDate outsideSemester = LocalDate.of(2027, 2, 1);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
outsideSemester,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенести занятие можно только в пределах того же семестра");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateThatIsNotStudyDayForEveryGroup() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Целевая дата должна быть учебной для всех групп исходного занятия");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTimeSlotThatIsNotEffectiveOnTargetDate() {
|
||||
configureRuleForTarget();
|
||||
when(effectiveTimeSlotService.isEffectiveOnDate(NEW_TIME_SLOT_ID, TARGET_DATE)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Выбранный временной слот не действует на целевую дату");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResourceThatIsInactiveOnTargetDate() {
|
||||
configureRuleForTarget();
|
||||
sourceTeacher.setActiveTo(DATE);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Активный преподаватель с ролью TEACHER не найден");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResourceConflictInEffectiveTargetDay() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(true);
|
||||
when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
|
||||
when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
|
||||
.thenReturn(List.of(
|
||||
relocatedLesson(
|
||||
BASE_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
SOURCE_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
"Очно"
|
||||
),
|
||||
relocatedLesson(
|
||||
OTHER_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
SOURCE_TEACHER_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
"Очно"
|
||||
)
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateWithReplaceAction() {
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"REPLACE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос даты должен использовать действие MOVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMovedOverrideLocksBothDatesAndReturnsRuleAfterCacheRefresh() {
|
||||
ScheduleOverride stored = override(710L, baseSlot, "MOVE", newTimeSlot);
|
||||
stored.setTargetLessonDate(TARGET_DATE);
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(710L)).thenReturn(Optional.of(stored));
|
||||
|
||||
service.delete(710L);
|
||||
|
||||
verify(advisoryLock).lockScheduleOverrideDate(DATE);
|
||||
verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
|
||||
verify(scheduleOverrideRepository).delete(stored);
|
||||
verify(scheduleOverrideRepository).flush();
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
private Semester configureRuleForTarget() {
|
||||
Semester semester = new Semester();
|
||||
semester.setId(81L);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2027, 1, 31));
|
||||
|
||||
Subject subject = new Subject();
|
||||
subject.setId(501L);
|
||||
subject.setName("Тестовая дисциплина");
|
||||
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(GROUP_ID);
|
||||
group.setName("Группа 31");
|
||||
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setId(900L);
|
||||
rule.setSemester(semester);
|
||||
rule.setSubject(subject);
|
||||
rule.setGroups(Set.of(group));
|
||||
baseSlot.setScheduleRule(rule);
|
||||
return semester;
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto targetRequest(LocalDate targetDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String format) {
|
||||
return new ScheduleOverrideDto(
|
||||
null,
|
||||
BASE_SLOT_ID,
|
||||
DATE,
|
||||
targetDate,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
format,
|
||||
"Перенос конкретного занятия",
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private RenderedLessonDto relocatedLesson(Long ruleSlotId,
|
||||
Long timeSlotId,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
String format) {
|
||||
return new RenderedLessonDto(
|
||||
900L,
|
||||
ruleSlotId,
|
||||
TARGET_DATE,
|
||||
4,
|
||||
"Четверг",
|
||||
2,
|
||||
ScheduleParity.EVEN,
|
||||
timeSlotId,
|
||||
2,
|
||||
LocalTime.of(10, 40),
|
||||
LocalTime.of(12, 10),
|
||||
501L,
|
||||
"Тестовая дисциплина",
|
||||
teacherId,
|
||||
"Тестовый преподаватель",
|
||||
classroomId,
|
||||
"Тестовая аудитория",
|
||||
601L,
|
||||
"Лекция",
|
||||
format,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(GROUP_ID),
|
||||
List.of("Группа 31"),
|
||||
"Лекция",
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
ScheduleParity.BOTH,
|
||||
null,
|
||||
"MOVE",
|
||||
DATE
|
||||
);
|
||||
}
|
||||
|
||||
private void stubEffectiveDay(RenderedLessonDto... lessons) {
|
||||
when(scheduleQueryService.buildEffectiveDayForAllGroups(
|
||||
eq(List.of(sourceLesson)),
|
||||
|
||||
@@ -5,23 +5,32 @@ import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleQueryServiceOverrideTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
|
||||
@Test
|
||||
void appliesAllReplacementFieldsToProvidedDaySnapshot() {
|
||||
@@ -95,6 +104,74 @@ class ScheduleQueryServiceOverrideTest {
|
||||
assertThat(result).singleElement().extracting(RenderedLessonDto::scheduleRuleSlotId).isEqualTo(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesOccurrenceToTargetDateAndRecalculatesCalendarFields() {
|
||||
AcademicDateService academicDateService = mock(AcademicDateService.class);
|
||||
ScheduleQueryService service = service(academicDateService);
|
||||
ScheduleOverride override = relocationOverride();
|
||||
when(academicDateService.getWeekNumber(any(Semester.class), eq(TARGET_DATE))).thenReturn(2);
|
||||
when(academicDateService.getParity(any(Semester.class), eq(TARGET_DATE))).thenReturn(ScheduleParity.EVEN);
|
||||
|
||||
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
|
||||
List.of(lesson()),
|
||||
DATE,
|
||||
List.of(override)
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(moved -> {
|
||||
assertThat(moved.date()).isEqualTo(TARGET_DATE);
|
||||
assertThat(moved.dayOfWeek()).isEqualTo(4);
|
||||
assertThat(moved.dayName()).isEqualTo("Четверг");
|
||||
assertThat(moved.weekNumber()).isEqualTo(2);
|
||||
assertThat(moved.parity()).isEqualTo(ScheduleParity.EVEN);
|
||||
assertThat(moved.timeSlotId()).isEqualTo(2L);
|
||||
assertThat(moved.scheduleOverrideId()).isEqualTo(700L);
|
||||
assertThat(moved.overrideAction()).isEqualTo("MOVE");
|
||||
assertThat(moved.originalLessonDate()).isEqualTo(DATE);
|
||||
assertThat(moved.lessonTypeAcademicHours()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIncludesIncomingMoveWhenOnlyTargetDateIsRequested() {
|
||||
ScheduleGeneratorService generator = mock(ScheduleGeneratorService.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
StudentGroupLifecycleService lifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
AcademicDateService academicDateService = mock(AcademicDateService.class);
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
generator,
|
||||
groupRepository,
|
||||
overrideRepository,
|
||||
lifecycleService,
|
||||
academicDateService
|
||||
);
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(31L);
|
||||
group.setName("Группа 31");
|
||||
ScheduleOverride override = relocationOverride();
|
||||
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group));
|
||||
when(lifecycleService.mayHaveScheduleInRange(eq(group), any(LocalDate.class), any(LocalDate.class)))
|
||||
.thenReturn(true);
|
||||
when(generator.buildScheduleForGroups(anyList(), eq(TARGET_DATE), eq(TARGET_DATE))).thenReturn(List.of());
|
||||
when(generator.buildScheduleForGroups(anyList(), eq(DATE), eq(DATE))).thenReturn(List.of(lesson()));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(TARGET_DATE, TARGET_DATE))
|
||||
.thenReturn(List.of(override));
|
||||
when(academicDateService.getWeekNumber(any(Semester.class), eq(TARGET_DATE))).thenReturn(2);
|
||||
when(academicDateService.getParity(any(Semester.class), eq(TARGET_DATE))).thenReturn(ScheduleParity.EVEN);
|
||||
|
||||
List<RenderedLessonDto> result = service.search(
|
||||
null, null, null, null, null, null, null, null, TARGET_DATE, TARGET_DATE
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(moved -> {
|
||||
assertThat(moved.date()).isEqualTo(TARGET_DATE);
|
||||
assertThat(moved.originalLessonDate()).isEqualTo(DATE);
|
||||
assertThat(moved.scheduleRuleSlotId()).isEqualTo(101L);
|
||||
});
|
||||
}
|
||||
|
||||
private ScheduleQueryService service() {
|
||||
return new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
@@ -104,6 +181,37 @@ class ScheduleQueryServiceOverrideTest {
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleQueryService service(AcademicDateService academicDateService) {
|
||||
return new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(GroupRepository.class),
|
||||
mock(ScheduleOverrideRepository.class),
|
||||
mock(StudentGroupLifecycleService.class),
|
||||
academicDateService
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleOverride relocationOverride() {
|
||||
ScheduleOverride override = override("MOVE");
|
||||
ReflectionTestUtils.setField(override, "id", 700L);
|
||||
override.setTargetLessonDate(TARGET_DATE);
|
||||
TimeSlot targetTime = new TimeSlot();
|
||||
targetTime.setId(2L);
|
||||
targetTime.setOrderNumber(2);
|
||||
targetTime.setStartTime(LocalTime.of(10, 40));
|
||||
targetTime.setEndTime(LocalTime.of(12, 10));
|
||||
override.setNewTimeSlot(targetTime);
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setId(80L);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2027, 1, 31));
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setSemester(semester);
|
||||
override.getBaseRuleSlot().setScheduleRule(rule);
|
||||
return override;
|
||||
}
|
||||
|
||||
private ScheduleOverride override(String action) {
|
||||
ScheduleRuleSlot baseSlot = new ScheduleRuleSlot();
|
||||
baseSlot.setId(101L);
|
||||
|
||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -115,7 +116,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Совпавшая базовая и достроенная пара остаётся в результате один раз")
|
||||
@DisplayName("Уже загруженная базовая пара не достраивается повторно")
|
||||
void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() {
|
||||
RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
@@ -129,8 +130,8 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
assertThat(result).singleElement().satisfies(lesson ->
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verifyBaseDayBatch();
|
||||
verify(groupRepository, never()).findAll();
|
||||
verify(generatorService, never()).buildScheduleForGroups(any(), eq(DATE), eq(DATE));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user