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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
-- ==========================================
-- Инварианты правил расписания
-- ==========================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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