#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.config.auth.RequireRoles;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.Role; 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 com.magistr.app.service.ScheduleQueryService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -11,6 +14,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@@ -21,9 +26,20 @@ public class ScheduleController {
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class); private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
private final SemesterRepository semesterRepository;
public ScheduleController(ScheduleQueryService scheduleQueryService) { public ScheduleController(ScheduleQueryService scheduleQueryService,
SemesterRepository semesterRepository) {
this.scheduleQueryService = scheduleQueryService; 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 @GetMapping
@@ -54,4 +70,15 @@ public class ScheduleController {
endDate 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.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleOverrideDto; import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleOverrideService; import com.magistr.app.service.ScheduleOverrideService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDate;
@RestController @RestController
@RequestMapping("/api/edu-office/schedule/overrides") @RequestMapping("/api/edu-office/schedule/overrides")
@@ -22,8 +24,19 @@ public class ScheduleOverrideController {
} }
@GetMapping @GetMapping
public List<ScheduleOverrideDto> getAll() { public List<ScheduleOverrideDto> getAll(
return scheduleOverrideService.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 @PostMapping

View File

@@ -12,6 +12,7 @@ import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository; import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository; import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.service.TimeSlotService; import com.magistr.app.service.TimeSlotService;
import com.magistr.app.service.EffectiveTimeSlotService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -35,15 +36,18 @@ public class TimeSlotAdminController {
private final TimeSlotScopeRepository timeSlotScopeRepository; private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository; private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final TimeSlotService timeSlotService; private final TimeSlotService timeSlotService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository, public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository, TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository, TimeSlotDateAssignmentRepository dateAssignmentRepository,
TimeSlotService timeSlotService) { TimeSlotService timeSlotService,
EffectiveTimeSlotService effectiveTimeSlotService) {
this.timeSlotRepository = timeSlotRepository; this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository; this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository; this.dateAssignmentRepository = dateAssignmentRepository;
this.timeSlotService = timeSlotService; this.timeSlotService = timeSlotService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
} }
@GetMapping @GetMapping
@@ -55,7 +59,7 @@ public class TimeSlotAdminController {
@GetMapping("/effective") @GetMapping("/effective")
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) { 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") @GetMapping("/scopes")
@@ -167,35 +171,6 @@ public class TimeSlotAdminController {
return ResponseEntity.ok(Map.of("message", "Временной слот удалён")); 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() { private int nextManualDisplayOrder() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream() return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(TimeSlotScope::getDisplayOrder) .map(TimeSlotScope::getDisplayOrder)

View File

@@ -37,6 +37,79 @@ public record RenderedLessonDto(
Integer lessonTypeAcademicHours, Integer lessonTypeAcademicHours,
Integer consumedLessonTypeAcademicHoursBeforeLesson, Integer consumedLessonTypeAcademicHoursBeforeLesson,
Integer remainingLessonTypeAcademicHoursAfterLesson, 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.LocalDate;
import java.time.Instant; import java.time.Instant;
import java.time.LocalTime;
import java.util.List;
public record ScheduleOverrideDto( public record ScheduleOverrideDto(
Long id, Long id,
Long baseRuleSlotId, Long baseRuleSlotId,
LocalDate lessonDate, LocalDate lessonDate,
LocalDate targetLessonDate,
String action, String action,
Long newTimeSlotId, Long newTimeSlotId,
Long newClassroomId, Long newClassroomId,
@@ -14,6 +17,109 @@ public record ScheduleOverrideDto(
String newLessonFormat, String newLessonFormat,
String comment, String comment,
Long createdBy, 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) @Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate; private LocalDate lessonDate;
@Column(name = "target_lesson_date")
private LocalDate targetLessonDate;
@Column(nullable = false, length = 20) @Column(nullable = false, length = 20)
private String action; private String action;
@@ -67,6 +70,14 @@ public class ScheduleOverride {
this.lessonDate = lessonDate; this.lessonDate = lessonDate;
} }
public LocalDate getTargetLessonDate() {
return targetLessonDate;
}
public void setTargetLessonDate(LocalDate targetLessonDate) {
this.targetLessonDate = targetLessonDate;
}
public String getAction() { public String getAction() {
return action; return action;
} }

View File

@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -17,10 +18,13 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
select o select o
from ScheduleOverride o from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot 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.newTimeSlot
left join fetch o.newClassroom left join fetch o.newClassroom
left join fetch o.newTeacher left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
""") """)
List<ScheduleOverride> findByLessonDateBetweenWithDetails( List<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate, @Param("startDate") LocalDate startDate,
@@ -29,8 +33,62 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate); Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
@Query("select o.lessonDate from ScheduleOverride o where o.id = :id") @Query("""
Optional<LocalDate> findLessonDateById(@Param("id") Long id); 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) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from ScheduleOverride o where o.id = :id") @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.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.dto.ScheduleOverrideDto; import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom; import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
@@ -17,9 +18,11 @@ import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
@@ -31,7 +34,9 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -49,7 +54,10 @@ public class ScheduleOverrideService {
private final ScheduleQueryService scheduleQueryService; private final ScheduleQueryService scheduleQueryService;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final PostgreSqlAdvisoryLock advisoryLock; private final PostgreSqlAdvisoryLock advisoryLock;
private final AcademicDateService academicDateService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
@Autowired
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository, public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository, ScheduleRuleSlotRepository scheduleRuleSlotRepository,
TimeSlotRepository timeSlotRepository, TimeSlotRepository timeSlotRepository,
@@ -58,7 +66,9 @@ public class ScheduleOverrideService {
SubgroupRepository subgroupRepository, SubgroupRepository subgroupRepository,
ScheduleQueryService scheduleQueryService, ScheduleQueryService scheduleQueryService,
ScheduleGeneratorService scheduleGeneratorService, ScheduleGeneratorService scheduleGeneratorService,
PostgreSqlAdvisoryLock advisoryLock) { PostgreSqlAdvisoryLock advisoryLock,
AcademicDateService academicDateService,
EffectiveTimeSlotService effectiveTimeSlotService) {
this.scheduleOverrideRepository = scheduleOverrideRepository; this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.timeSlotRepository = timeSlotRepository; this.timeSlotRepository = timeSlotRepository;
@@ -68,42 +78,124 @@ public class ScheduleOverrideService {
this.scheduleQueryService = scheduleQueryService; this.scheduleQueryService = scheduleQueryService;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.advisoryLock = advisoryLock; 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) @Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll() { public List<ScheduleOverrideDto> getAll() {
return scheduleOverrideRepository.findAll().stream() return getAll(null, null);
.map(this::toDto) }
@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(); .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 @Transactional
public ScheduleOverrideDto create(ScheduleOverrideDto request) { public ScheduleOverrideDto create(ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request); OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideDate(command.lessonDate()); lockDatesInStableOrder(command.lessonDate(), command.effectiveDate());
ScheduleOverride current = scheduleOverrideRepository scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate()) .findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
.orElse(null); .ifPresent(existing -> {
return validateAndPersist(current, command); throw new ScheduleConflictException(
"Для выбранной базовой пары и даты уже существует точечное изменение"
);
});
return validateAndPersist(null, command);
} }
@Transactional @Transactional
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) { public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request); OverrideCommand command = validateAndNormalize(request);
advisoryLock.lockScheduleOverrideId(id); advisoryLock.lockScheduleOverrideId(id);
LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
lockDatesInStableOrder(observedOldDate, command.lessonDate());
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id) ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id)); .orElseThrow(() -> notFound(id));
if (!Objects.equals(observedOldDate, current.getLessonDate())) { lockDatesInStableOrder(
throw new ScheduleConflictException( current.getLessonDate(),
"Точечное изменение расписания было изменено параллельно. Повторите запрос" effectiveDate(current),
); command.lessonDate(),
} command.effectiveDate()
);
scheduleOverrideRepository scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate()) .findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
@@ -119,24 +211,15 @@ public class ScheduleOverrideService {
@Transactional @Transactional
public void delete(Long id) { public void delete(Long id) {
advisoryLock.lockScheduleOverrideId(id); advisoryLock.lockScheduleOverrideId(id);
LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
.orElseThrow(() -> notFound(id));
advisoryLock.lockScheduleOverrideDate(observedDate);
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id) ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id)); .orElseThrow(() -> notFound(id));
if (!Objects.equals(observedDate, current.getLessonDate())) { lockDatesInStableOrder(current.getLessonDate(), effectiveDate(current));
throw new ScheduleConflictException(
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
);
}
scheduleOverrideRepository.delete(current); scheduleOverrideRepository.delete(current);
scheduleOverrideRepository.flush(); scheduleOverrideRepository.flush();
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
} }
private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) { private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
ResolvedReferences references = resolveReferences(command);
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate()); List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
List<RenderedLessonDto> sourceOccurrences = baseDay.stream() List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) .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); ScheduleOverride candidate = buildCandidate(command, references);
if (!"CANCEL".equals(command.action())) { if (!"CANCEL".equals(command.action())) {
@@ -169,22 +254,34 @@ public class ScheduleOverrideService {
List<RenderedLessonDto> baseDay, List<RenderedLessonDto> baseDay,
List<RenderedLessonDto> sourceOccurrences) { List<RenderedLessonDto> sourceOccurrences) {
Long currentId = current == null ? null : current.getId(); Long currentId = current == null ? null : current.getId();
List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository Set<LocalDate> affectedDates = new TreeSet<>(List.of(command.lessonDate(), command.effectiveDate()));
.findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate()) 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() .stream()
.filter(override -> currentId == null || !Objects.equals(override.getId(), currentId)) .filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
.filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate())) .filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
.collect(Collectors.toCollection(ArrayList::new)); .collect(Collectors.toCollection(ArrayList::new));
effectiveOverrides.add(candidate); effectiveOverrides.add(candidate);
List<RenderedLessonDto> effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups( List<RenderedLessonDto> effectiveSchedule = Objects.equals(command.lessonDate(), command.effectiveDate())
baseDay, ? scheduleQueryService.buildEffectiveDayForAllGroups(
command.lessonDate(), baseDay,
effectiveOverrides command.lessonDate(),
); effectiveOverrides
List<RenderedLessonDto> candidateOccurrences = effectiveDay.stream() )
: scheduleQueryService.buildEffectiveDaysForAllGroups(affectedDates, effectiveOverrides);
List<RenderedLessonDto> candidateOccurrences = effectiveSchedule.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) .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(); .toList();
if (candidateOccurrences.isEmpty()) { if (candidateOccurrences.isEmpty()) {
throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару"); throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
@@ -203,13 +300,22 @@ public class ScheduleOverrideService {
); );
} }
List<RenderedLessonDto> otherOccurrences = effectiveDay.stream() List<RenderedLessonDto> otherOccurrences = effectiveSchedule.stream()
.filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) .filter(lesson -> !isCandidateOccurrence(lesson, command))
.filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
.toList(); .toList();
Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences); Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup); 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, private void validateActionSpecificChange(String action,
List<RenderedLessonDto> sourceOccurrences, List<RenderedLessonDto> sourceOccurrences,
List<RenderedLessonDto> candidateOccurrences) { List<RenderedLessonDto> candidateOccurrences) {
@@ -222,7 +328,7 @@ public class ScheduleOverrideService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
if (source.equals(candidate)) { if (source.equals(candidate)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Перенос должен фактически менять время пары или аудиторию" "Перенос должен фактически менять дату, время пары или аудиторию"
); );
} }
} }
@@ -331,7 +437,8 @@ public class ScheduleOverrideService {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private ResolvedReferences resolveReferences(OverrideCommand command) { private ResolvedReferences resolveReferences(OverrideCommand command,
List<RenderedLessonDto> sourceOccurrences) {
ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId()) ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден")); .orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
TimeSlot newTimeSlot = command.newTimeSlotId() == null TimeSlot newTimeSlot = command.newTimeSlotId() == null
@@ -347,22 +454,73 @@ public class ScheduleOverrideService {
: userRepository.findById(command.newTeacherId()) : userRepository.findById(command.newTeacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден")); .orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
if (newTeacher != null if ("CANCEL".equals(command.action())) {
&& (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) { 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 не найден"); throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
} }
if (newClassroom != null if (effectiveClassroom == null
&& (!newClassroom.isActiveOn(command.lessonDate()) || !effectiveClassroom.isActiveOn(effectiveDate)
|| !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) { || !Boolean.TRUE.equals(effectiveClassroom.getIsAvailable())) {
throw new IllegalArgumentException("Активная и доступная аудитория не найдена"); throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
} }
if (newTimeSlot != null
&& effectiveTimeSlotService != null
&& !effectiveTimeSlotService.isEffectiveOnDate(newTimeSlot.getId(), effectiveDate)) {
throw new IllegalArgumentException("Выбранный временной слот не действует на целевую дату");
}
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher); 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) { private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
ScheduleOverride candidate = new ScheduleOverride(); ScheduleOverride candidate = new ScheduleOverride();
candidate.setBaseRuleSlot(references.baseRuleSlot()); candidate.setBaseRuleSlot(references.baseRuleSlot());
candidate.setLessonDate(command.lessonDate()); candidate.setLessonDate(command.lessonDate());
candidate.setTargetLessonDate(command.targetLessonDate());
candidate.setAction(command.action()); candidate.setAction(command.action());
candidate.setNewTimeSlot(references.newTimeSlot()); candidate.setNewTimeSlot(references.newTimeSlot());
candidate.setNewClassroom(references.newClassroom()); candidate.setNewClassroom(references.newClassroom());
@@ -374,6 +532,7 @@ public class ScheduleOverrideService {
private void copyCandidate(ScheduleOverride source, ScheduleOverride target) { private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
target.setBaseRuleSlot(source.getBaseRuleSlot()); target.setBaseRuleSlot(source.getBaseRuleSlot());
target.setLessonDate(source.getLessonDate()); target.setLessonDate(source.getLessonDate());
target.setTargetLessonDate(source.getTargetLessonDate());
target.setAction(source.getAction()); target.setAction(source.getAction());
target.setNewTimeSlot(source.getNewTimeSlot()); target.setNewTimeSlot(source.getNewTimeSlot());
target.setNewClassroom(source.getNewClassroom()); target.setNewClassroom(source.getNewClassroom());
@@ -397,21 +556,34 @@ public class ScheduleOverrideService {
throw new IllegalArgumentException("Недопустимое действие изменения расписания"); throw new IllegalArgumentException("Недопустимое действие изменения расписания");
} }
String lessonFormat = clean(request.newLessonFormat()); String lessonFormat = clean(request.newLessonFormat());
LocalDate targetLessonDate = request.targetLessonDate();
if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) { if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»"); throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
} }
if ("CANCEL".equals(action) if ("CANCEL".equals(action)
&& (request.newTimeSlotId() != null && (targetLessonDate != null
|| request.newTimeSlotId() != null
|| request.newClassroomId() != null || request.newClassroomId() != null
|| request.newTeacherId() != null || request.newTeacherId() != null
|| lessonFormat != null)) { || lessonFormat != null)) {
throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми"); 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) if ("MOVE".equals(action)
&& request.newTimeSlotId() == null && request.newTimeSlotId() == null) {
&& request.newClassroomId() == null) { throw new IllegalArgumentException("Для переноса укажите новое время");
throw new IllegalArgumentException("Для переноса укажите новое время или аудиторию"); }
if ("REPLACE".equals(action) && request.newTimeSlotId() != null) {
throw new IllegalArgumentException("Изменение времени должно использовать действие MOVE");
} }
if ("REPLACE".equals(action) if ("REPLACE".equals(action)
&& request.newTeacherId() == null && request.newTeacherId() == null
@@ -424,6 +596,7 @@ public class ScheduleOverrideService {
return new OverrideCommand( return new OverrideCommand(
request.baseRuleSlotId(), request.baseRuleSlotId(),
request.lessonDate(), request.lessonDate(),
targetLessonDate,
action, action,
request.newTimeSlotId(), request.newTimeSlotId(),
request.newClassroomId(), request.newClassroomId(),
@@ -433,8 +606,9 @@ public class ScheduleOverrideService {
); );
} }
private void lockDatesInStableOrder(LocalDate first, LocalDate second) { private void lockDatesInStableOrder(LocalDate... dates) {
List.of(first, second).stream() java.util.Arrays.stream(dates)
.filter(Objects::nonNull)
.distinct() .distinct()
.sorted(Comparator.naturalOrder()) .sorted(Comparator.naturalOrder())
.forEach(advisoryLock::lockScheduleOverrideDate); .forEach(advisoryLock::lockScheduleOverrideDate);
@@ -446,15 +620,55 @@ public class ScheduleOverrideService {
&& Objects.equals(override.getLessonDate(), lessonDate); && 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) { private NoSuchElementException notFound(Long id) {
return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено"); return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
} }
private ScheduleOverrideDto toDto(ScheduleOverride override) { 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( return new ScheduleOverrideDto(
override.getId(), override.getId(),
override.getBaseRuleSlot().getId(), baseSlot == null ? null : baseSlot.getId(),
override.getLessonDate(), override.getLessonDate(),
override.getTargetLessonDate(),
override.getAction(), override.getAction(),
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(), override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(), override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
@@ -462,7 +676,24 @@ public class ScheduleOverrideService {
override.getNewLessonFormat(), override.getNewLessonFormat(),
override.getComment(), override.getComment(),
override.getCreatedBy(), 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( private record OverrideCommand(
Long baseRuleSlotId, Long baseRuleSlotId,
LocalDate lessonDate, LocalDate lessonDate,
LocalDate targetLessonDate,
String action, String action,
Long newTimeSlotId, Long newTimeSlotId,
Long newClassroomId, Long newClassroomId,
@@ -480,6 +712,9 @@ public class ScheduleOverrideService {
String newLessonFormat, String newLessonFormat,
String comment String comment
) { ) {
private LocalDate effectiveDate() {
return targetLessonDate == null ? lessonDate : targetLessonDate;
}
} }
private record ResolvedReferences( private record ResolvedReferences(
@@ -491,6 +726,7 @@ public class ScheduleOverrideService {
} }
private record EffectiveSourceTuple( private record EffectiveSourceTuple(
LocalDate date,
Long timeSlotId, Long timeSlotId,
LocalTime startTime, LocalTime startTime,
LocalTime endTime, LocalTime endTime,
@@ -500,6 +736,7 @@ public class ScheduleOverrideService {
) { ) {
private static EffectiveSourceTuple from(RenderedLessonDto lesson) { private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
return new EffectiveSourceTuple( return new EffectiveSourceTuple(
lesson.date(),
lesson.timeSlotId(), lesson.timeSlotId(),
lesson.startTime(), lesson.startTime(),
lesson.endTime(), lesson.endTime(),
@@ -511,12 +748,13 @@ public class ScheduleOverrideService {
} }
private record MoveTuple( private record MoveTuple(
LocalDate date,
LocalTime startTime, LocalTime startTime,
LocalTime endTime, LocalTime endTime,
Long classroomId Long classroomId
) { ) {
private static MoveTuple from(RenderedLessonDto lesson) { 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.model.StudentGroup;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository; import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
@@ -22,15 +23,26 @@ public class ScheduleQueryService {
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository; private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService; private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicDateService academicDateService;
@Autowired
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository, GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository, ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) { StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository; this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService; 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)); 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, public List<RenderedLessonDto> search(Long groupId,
Long teacherId, Long teacherId,
Long classroomId, Long classroomId,
@@ -137,29 +174,26 @@ public class ScheduleQueryService {
boolean enforceInteractiveGroupLimit) { boolean enforceInteractiveGroupLimit) {
validateRange(startDate, endDate); validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons; List<RenderedLessonDto> generatedLessons;
List<StudentGroup> scopedGroups = null;
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null; boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
if (teacherOnlySearch) { if (teacherOnlySearch) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else { } else {
List<StudentGroup> groups = resolveGroups( scopedGroups = resolveGroups(
groupId, groupId,
departmentId, departmentId,
startDate, startDate,
endDate, endDate,
enforceInteractiveGroupLimit enforceInteractiveGroupLimit
); );
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate); generatedLessons = scheduleGeneratorService.buildScheduleForGroups(scopedGroups, startDate, endDate);
} }
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
.findByLessonDateBetweenWithDetails(startDate, endDate); .findByLessonDateBetweenWithDetails(startDate, endDate);
if (teacherOnlySearch) { generatedLessons = new ArrayList<>(generatedLessons);
generatedLessons = addOverrideSourceOccurrencesForTeacher( addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
generatedLessons,
teacherId,
overrideSnapshot
);
}
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream() 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 -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId)) .filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId)) .filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
@@ -170,10 +204,10 @@ public class ScheduleQueryService {
return deduplicate(lessons); return deduplicate(lessons);
} }
private List<RenderedLessonDto> addOverrideSourceOccurrencesForTeacher( private void addOverrideSourceOccurrences(
List<RenderedLessonDto> generatedLessons, List<RenderedLessonDto> generatedLessons,
Long teacherId, Collection<ScheduleOverride> overrideSnapshot,
Collection<ScheduleOverride> overrideSnapshot List<StudentGroup> scopedGroups
) { ) {
Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot) Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
.orElseGet(List::of) .orElseGet(List::of)
@@ -182,8 +216,7 @@ public class ScheduleQueryService {
.filter(override -> override.getLessonDate() != null) .filter(override -> override.getLessonDate() != null)
.filter(override -> override.getBaseRuleSlot() != null) .filter(override -> override.getBaseRuleSlot() != null)
.filter(override -> override.getBaseRuleSlot().getId() != null) .filter(override -> override.getBaseRuleSlot().getId() != null)
.filter(override -> override.getNewTeacher() != null) .filter(override -> !"CANCEL".equals(override.getAction()))
.filter(override -> Objects.equals(override.getNewTeacher().getId(), teacherId))
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
ScheduleOverride::getLessonDate, ScheduleOverride::getLessonDate,
TreeMap::new, TreeMap::new,
@@ -193,15 +226,27 @@ public class ScheduleQueryService {
) )
)); ));
if (relevantSlotsByDate.isEmpty()) { if (relevantSlotsByDate.isEmpty()) {
return generatedLessons; return;
} }
List<RenderedLessonDto> supplementedLessons = new ArrayList<>(generatedLessons); relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> {
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> buildBaseDayForAllGroups(date).stream() Set<Long> missingBaseRuleSlotIds = baseRuleSlotIds.stream()
.filter(lesson -> date.equals(lesson.date())) .filter(baseRuleSlotId -> generatedLessons.stream().noneMatch(lesson ->
.filter(lesson -> baseRuleSlotIds.contains(lesson.scheduleRuleSlotId())) date.equals(lesson.date())
.forEach(supplementedLessons::add)); && Objects.equals(baseRuleSlotId, lesson.scheduleRuleSlotId())
return supplementedLessons; ))
.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, private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
@@ -235,6 +280,26 @@ public class ScheduleQueryService {
if ("CANCEL".equals(override.getAction())) { if ("CANCEL".equals(override.getAction())) {
return null; 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(); Long timeSlotId = lesson.timeSlotId();
Integer timeSlotOrder = lesson.timeSlotOrder(); Integer timeSlotOrder = lesson.timeSlotOrder();
java.time.LocalTime startTime = lesson.startTime(); java.time.LocalTime startTime = lesson.startTime();
@@ -255,11 +320,11 @@ public class ScheduleQueryService {
return new RenderedLessonDto( return new RenderedLessonDto(
lesson.scheduleRuleId(), lesson.scheduleRuleId(),
lesson.scheduleRuleSlotId(), lesson.scheduleRuleSlotId(),
lesson.date(), effectiveDate,
lesson.dayOfWeek(), dayOfWeek,
lesson.dayName(), dayName,
lesson.weekNumber(), weekNumber,
lesson.parity(), parity,
timeSlotId, timeSlotId,
timeSlotOrder, timeSlotOrder,
startTime, startTime,
@@ -283,10 +348,25 @@ public class ScheduleQueryService {
lesson.lessonTypeAcademicHours(), lesson.lessonTypeAcademicHours(),
lesson.consumedLessonTypeAcademicHoursBeforeLesson(), lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
lesson.remainingLessonTypeAcademicHoursAfterLesson(), 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) { private String overrideKey(Long scheduleRuleSlotId, LocalDate date) {
return scheduleRuleSlotId + ":" + date; return scheduleRuleSlotId + ":" + date;
} }
@@ -356,7 +436,9 @@ public class ScheduleQueryService {
String.valueOf(lesson.timeSlotOrder()), String.valueOf(lesson.timeSlotOrder()),
String.valueOf(lesson.teacherId()), String.valueOf(lesson.teacherId()),
String.valueOf(lesson.subjectId()), 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.lessonTypeAcademicHours(),
source.consumedLessonTypeAcademicHoursBeforeLesson(), source.consumedLessonTypeAcademicHoursBeforeLesson(),
source.remainingLessonTypeAcademicHoursAfterLesson(), 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, id BIGSERIAL PRIMARY KEY,
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id), base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL, lesson_date DATE NOT NULL,
target_lesson_date DATE,
action VARCHAR(20) NOT NULL, action VARCHAR(20) NOT NULL,
new_time_slot_id BIGINT REFERENCES time_slots(id), new_time_slot_id BIGINT REFERENCES time_slots(id),
new_classroom_id BIGINT REFERENCES classrooms(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 CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
ON schedule_overrides(lesson_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 CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
ON schedule_overrides(new_teacher_id); 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 teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам'; COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
COMMENT ON TABLE schedule_overrides 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.lecture_academic_hours IS 'Лимит академических часов лекций';
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ'; COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик'; COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
@@ -1288,12 +1295,14 @@ DECLARE
move_violations BIGINT; move_violations BIGINT;
replace_violations BIGINT; replace_violations BIGINT;
format_violations BIGINT; format_violations BIGINT;
target_date_violations BIGINT;
BEGIN BEGIN
SELECT count(*) SELECT count(*)
INTO cancel_violations INTO cancel_violations
FROM schedule_overrides FROM schedule_overrides
WHERE action = 'CANCEL' 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_classroom_id IS NOT NULL
OR new_teacher_id IS NOT NULL OR new_teacher_id IS NOT NULL
OR new_lesson_format IS NOT NULL); OR new_lesson_format IS NOT NULL);
@@ -1302,16 +1311,16 @@ BEGIN
INTO move_violations INTO move_violations
FROM schedule_overrides FROM schedule_overrides
WHERE action = 'MOVE' WHERE action = 'MOVE'
AND new_time_slot_id IS NULL AND new_time_slot_id IS NULL;
AND new_classroom_id IS NULL;
SELECT count(*) SELECT count(*)
INTO replace_violations INTO replace_violations
FROM schedule_overrides FROM schedule_overrides
WHERE action = 'REPLACE' WHERE action = 'REPLACE'
AND new_teacher_id IS NULL AND (new_time_slot_id IS NOT NULL
AND new_classroom_id IS NULL OR (new_teacher_id IS NULL
AND new_lesson_format IS NULL; AND new_classroom_id IS NULL
AND new_lesson_format IS NULL));
SELECT count(*) SELECT count(*)
INTO format_violations INTO format_violations
@@ -1319,18 +1328,28 @@ BEGIN
WHERE new_lesson_format IS NOT NULL WHERE new_lesson_format IS NOT NULL
AND new_lesson_format NOT IN ('Очно', 'Онлайн'); 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 IF cancel_violations > 0
OR move_violations > 0 OR move_violations > 0
OR replace_violations > 0 OR replace_violations > 0
OR format_violations > 0 THEN OR format_violations > 0
OR target_date_violations > 0 THEN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( 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, cancel_violations,
move_violations, move_violations,
replace_violations, replace_violations,
format_violations format_violations,
target_date_violations
); );
END IF; END IF;
END $$; END $$;
@@ -1340,7 +1359,8 @@ ALTER TABLE schedule_overrides
CHECK ( CHECK (
action <> 'CANCEL' action <> 'CANCEL'
OR ( 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_classroom_id IS NULL
AND new_teacher_id IS NULL AND new_teacher_id IS NULL
AND new_lesson_format IS NULL AND new_lesson_format IS NULL
@@ -1350,19 +1370,32 @@ ALTER TABLE schedule_overrides
CHECK ( CHECK (
action <> 'MOVE' action <> 'MOVE'
OR new_time_slot_id IS NOT NULL OR new_time_slot_id IS NOT NULL
OR new_classroom_id IS NOT NULL
) NOT VALID, ) NOT VALID,
ADD CONSTRAINT chk_schedule_overrides_replace_payload ADD CONSTRAINT chk_schedule_overrides_replace_payload
CHECK ( CHECK (
action <> 'REPLACE' action <> 'REPLACE'
OR new_teacher_id IS NOT NULL OR (
OR new_classroom_id IS NOT NULL new_time_slot_id IS NULL
OR new_lesson_format IS NOT NULL AND (
new_teacher_id IS NOT NULL
OR new_classroom_id IS NOT NULL
OR new_lesson_format IS NOT NULL
)
)
) NOT VALID, ) NOT VALID,
ADD CONSTRAINT chk_schedule_overrides_format ADD CONSTRAINT chk_schedule_overrides_format
CHECK ( CHECK (
new_lesson_format IS NULL new_lesson_format IS NULL
OR new_lesson_format IN ('Очно', 'Онлайн') 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; ) NOT VALID;
ALTER TABLE schedule_overrides ALTER TABLE schedule_overrides
@@ -1377,6 +1410,9 @@ ALTER TABLE schedule_overrides
ALTER TABLE schedule_overrides ALTER TABLE schedule_overrides
VALIDATE CONSTRAINT chk_schedule_overrides_format; 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; package com.magistr.app.controller;
import com.magistr.app.dto.ScheduleOverrideDto; import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.service.ScheduleConflictException; import com.magistr.app.service.ScheduleConflictException;
import com.magistr.app.service.ScheduleOverrideService; import com.magistr.app.service.ScheduleOverrideService;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -8,9 +9,14 @@ import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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 { 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 ScheduleOverrideService service;
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -51,4 +60,43 @@ class ScheduleOverrideControllerTest {
.andExpect(jsonPath("$.message") .andExpect(jsonPath("$.message")
.value("Конфликт расписания: преподаватель уже занят в указанное время")); .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); SeedIds seedIds = loadSeedIds(connection);
LocalDate validDate = LocalDate.of(2026, 3, 2); 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, "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(2), "REPLACE", null, seedIds.teacherId(), null);
insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, 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")) assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
.as("Все допустимые варианты точечного изменения должны сохраниться") .as("Все допустимые варианты точечного изменения должны сохраниться")
.isEqualTo(4L); .isEqualTo(5L);
LocalDate invalidDate = LocalDate.of(2026, 4, 6); LocalDate invalidDate = LocalDate.of(2026, 4, 6);
assertCheckViolation( assertCheckViolation(
@@ -94,6 +134,14 @@ class ScheduleOverrideMigrationIntegrationTest {
"Смешанный", "Смешанный",
"chk_schedule_overrides_format" "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( return new SeedIds(
queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"), 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 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 classroomId,
Long teacherId, Long teacherId,
String lessonFormat 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 { ) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_overrides ( INSERT INTO schedule_overrides (
base_rule_slot_id, base_rule_slot_id,
lesson_date, lesson_date,
target_lesson_date,
action, action,
new_time_slot_id,
new_classroom_id, new_classroom_id,
new_teacher_id, new_teacher_id,
new_lesson_format new_lesson_format
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""")) { """)) {
statement.setLong(1, seedIds.baseRuleSlotId()); statement.setLong(1, seedIds.baseRuleSlotId());
statement.setDate(2, Date.valueOf(lessonDate)); statement.setDate(2, Date.valueOf(lessonDate));
statement.setString(3, action); statement.setObject(3, targetLessonDate == null ? null : Date.valueOf(targetLessonDate));
setNullableLong(statement, 4, classroomId); statement.setString(4, action);
setNullableLong(statement, 5, teacherId); setNullableLong(statement, 5, timeSlotId);
statement.setString(6, lessonFormat); setNullableLong(statement, 6, classroomId);
setNullableLong(statement, 7, teacherId);
statement.setString(8, lessonFormat);
statement.executeUpdate(); 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( private void assertCheckViolation(
Connection connection, Connection connection,
SeedIds seedIds, 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); 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) { private void awaitAdvisoryLockWait(int backendPid) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
do { do {

View File

@@ -1,12 +1,17 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
import com.magistr.app.dto.ScheduleOverrideDto; import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.model.Classroom; import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride; import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleParity; import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot; 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.TimeSlot;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository; import com.magistr.app.repository.ClassroomRepository;
@@ -30,6 +35,7 @@ import java.time.LocalTime;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -45,6 +51,7 @@ import static org.mockito.Mockito.when;
class ScheduleOverrideServiceTest { class ScheduleOverrideServiceTest {
private static final LocalDate DATE = LocalDate.of(2026, 9, 1); 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 BASE_SLOT_ID = 101L;
private static final Long OTHER_SLOT_ID = 202L; private static final Long OTHER_SLOT_ID = 202L;
private static final Long SOURCE_TIME_SLOT_ID = 1L; private static final Long SOURCE_TIME_SLOT_ID = 1L;
@@ -76,6 +83,10 @@ class ScheduleOverrideServiceTest {
private ScheduleGeneratorService scheduleGeneratorService; private ScheduleGeneratorService scheduleGeneratorService;
@Mock @Mock
private PostgreSqlAdvisoryLock advisoryLock; private PostgreSqlAdvisoryLock advisoryLock;
@Mock
private AcademicDateService academicDateService;
@Mock
private EffectiveTimeSlotService effectiveTimeSlotService;
private ScheduleOverrideService service; private ScheduleOverrideService service;
private ScheduleRuleSlot baseSlot; private ScheduleRuleSlot baseSlot;
@@ -96,6 +107,10 @@ class ScheduleOverrideServiceTest {
newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202"); newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель"); sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель"); newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
baseSlot.setTimeSlot(sourceTimeSlot);
baseSlot.setTeacher(sourceTeacher);
baseSlot.setClassroom(sourceClassroom);
baseSlot.setLessonFormat("Очно");
sourceLesson = lesson( sourceLesson = lesson(
BASE_SLOT_ID, BASE_SLOT_ID,
SOURCE_TIME_SLOT_ID, SOURCE_TIME_SLOT_ID,
@@ -117,7 +132,9 @@ class ScheduleOverrideServiceTest {
subgroupRepository, subgroupRepository,
scheduleQueryService, scheduleQueryService,
scheduleGeneratorService, scheduleGeneratorService,
advisoryLock advisoryLock,
academicDateService,
effectiveTimeSlotService
); );
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot)); when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
@@ -133,6 +150,7 @@ class ScheduleOverrideServiceTest {
when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class))) when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson)); 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 -> { when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
List<Long> subgroupIds = invocation.getArgument(0); List<Long> subgroupIds = invocation.getArgument(0);
return subgroupIds.stream() return subgroupIds.stream()
@@ -148,6 +166,47 @@ class ScheduleOverrideServiceTest {
.hasMessage("Передайте данные изменения расписания"); .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 @Test
void rejectsMissingRequiredRequestReferences() { void rejectsMissingRequiredRequestReferences() {
assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null))) 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 BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
))) )))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Для переноса укажите новое время или аудиторию"); .hasMessage("Для переноса укажите новое время");
assertThatThrownBy(() -> service.create(request( assertThatThrownBy(() -> service.create(request(
BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
@@ -289,10 +348,10 @@ class ScheduleOverrideServiceTest {
stubEffectiveDay(sourceLesson); stubEffectiveDay(sourceLesson);
assertThatThrownBy(() -> service.create(request( 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) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Перенос должен фактически менять время пары или аудиторию"); .hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
} }
@Test @Test
@@ -315,7 +374,7 @@ class ScheduleOverrideServiceTest {
BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
))) )))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Перенос должен фактически менять время пары или аудиторию"); .hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
} }
@Test @Test
@@ -440,7 +499,6 @@ class ScheduleOverrideServiceTest {
@Test @Test
void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() { void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null); 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.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE)) when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
.thenReturn(Optional.of(stored)); .thenReturn(Optional.of(stored));
@@ -470,7 +528,6 @@ class ScheduleOverrideServiceTest {
@Test @Test
void idempotentUpdateExcludesStoredOverrideFromConflictCheck() { void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot); 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.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE)) when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
.thenReturn(Optional.of(stored)); .thenReturn(Optional.of(stored));
@@ -489,6 +546,262 @@ class ScheduleOverrideServiceTest {
verify(scheduleOverrideRepository).saveAndFlush(stored); 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) { private void stubEffectiveDay(RenderedLessonDto... lessons) {
when(scheduleQueryService.buildEffectiveDayForAllGroups( when(scheduleQueryService.buildEffectiveDayForAllGroups(
eq(List.of(sourceLesson)), 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.Role;
import com.magistr.app.model.ScheduleOverride; import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleParity; import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot; 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.TimeSlot;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository; import com.magistr.app.repository.ScheduleOverrideRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime; import java.time.LocalTime;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.when;
class ScheduleQueryServiceOverrideTest { class ScheduleQueryServiceOverrideTest {
private static final LocalDate DATE = LocalDate.of(2026, 9, 1); private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
@Test @Test
void appliesAllReplacementFieldsToProvidedDaySnapshot() { void appliesAllReplacementFieldsToProvidedDaySnapshot() {
@@ -95,6 +104,74 @@ class ScheduleQueryServiceOverrideTest {
assertThat(result).singleElement().extracting(RenderedLessonDto::scheduleRuleSlotId).isEqualTo(101L); 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() { private ScheduleQueryService service() {
return new ScheduleQueryService( return new ScheduleQueryService(
mock(ScheduleGeneratorService.class), 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) { private ScheduleOverride override(String action) {
ScheduleRuleSlot baseSlot = new ScheduleRuleSlot(); ScheduleRuleSlot baseSlot = new ScheduleRuleSlot();
baseSlot.setId(101L); 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.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -115,7 +116,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
} }
@Test @Test
@DisplayName("Совпавшая базовая и достроенная пара остаётся в результате один раз") @DisplayName("Уже загруженная базовая пара не достраивается повторно")
void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() { void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() {
RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1); RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1);
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE)) when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
@@ -129,8 +130,8 @@ class ScheduleQueryServiceTeacherOverrideTest {
assertThat(result).singleElement().satisfies(lesson -> assertThat(result).singleElement().satisfies(lesson ->
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L)); assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE); verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
verify(groupRepository).findAll(); verify(groupRepository, never()).findAll();
verifyBaseDayBatch(); verify(generatorService, never()).buildScheduleForGroups(any(), eq(DATE), eq(DATE));
} }
@Test @Test

View File

@@ -416,6 +416,25 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
] ]
``` ```
### `GET /api/schedule/semesters`
Доступный только для чтения список семестров для фильтров просмотра расписания. Доступен всем ролям,
которые могут просматривать расписание, включая `DEPARTMENT` и `SCHEDULE_VIEWER`.
Семестры возвращаются от новых к старым.
```json
[
{
"id": 2,
"academicYearId": 1,
"academicYearTitle": "2025/2026",
"semesterType": "spring",
"startDate": "2026-02-09",
"endDate": "2026-06-30"
}
]
```
### `GET /api/admin/time-slots` ### `GET /api/admin/time-slots`
Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной. Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.
@@ -737,45 +756,82 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
``` ```
Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`. Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`.
Для занятия, к которому применено разовое изменение, дополнительно заполнены:
- `scheduleOverrideId` — идентификатор изменения;
- `overrideAction``MOVE` или `REPLACE` (`CANCEL` в выдачу не попадает);
- `originalLessonDate` — исходная дата занятия из базового правила.
Перенос удаляет занятие из исходного дня и добавляет его в целевой. Поиск учитывает
изменение, если в запрошенный диапазон попала исходная **или** целевая дата, поэтому
входящий перенос находится даже запросом только по целевому диапазону. Для целевой даты
пересчитываются день недели, номер недели и чётность; учёт академических часов остаётся
привязан к исходному занятию.
### Точечные изменения расписания учебного отдела ### Точечные изменения расписания учебного отдела
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/edu-office/schedule/overrides` | Список точечных изменений | | `GET` | `/api/edu-office/schedule/overrides?startDate=&endDate=` | Список изменений; диапазон проверяется по исходной или целевой дате |
| `GET` | `/api/edu-office/schedule/overrides/availability?baseRuleSlotId=&lessonDate=` | Границы семестра и допустимые учебные даты для конкретного занятия |
| `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену | | `POST` | `/api/edu-office/schedule/overrides` | Создать перенос, отмену или замену |
| `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение | | `PUT` | `/api/edu-office/schedule/overrides/{id}` | Обновить изменение |
| `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение | | `DELETE` | `/api/edu-office/schedule/overrides/{id}` | Удалить изменение и вернуть актуальный вариант из правила |
`startDate` и `endDate` у списка передаются только парой, включительно; максимальный
диапазон — 120 дней. Ответ реестра содержит исходные дату, время, преподавателя,
аудиторию, формат, дисциплину, тип занятия, группы и границы семестра, поэтому отменённую
или перенесённую пару можно открыть без присутствия в текущей выдаче расписания.
```json ```json
{ {
"baseRuleSlotId": 31, "baseRuleSlotId": 31,
"lessonDate": "2026-05-21", "lessonDate": "2026-05-21",
"action": "REPLACE", "targetLessonDate": "2026-05-27",
"action": "MOVE",
"newTimeSlotId": 4,
"newClassroomId": 2, "newClassroomId": 2,
"newTeacherId": 5, "newTeacherId": 5,
"comment": "Замена аудитории и преподавателя" "newLessonFormat": "Онлайн",
"comment": "Перенос конкретного занятия"
} }
``` ```
`lessonDate` всегда обозначает исходное занятие из правила. `targetLessonDate` передаётся
только при переносе на другой день и не заменяет идентификатор исходной пары
`baseRuleSlotId + lessonDate`.
Правила payload: Правила payload:
- `CANCEL` отменяет конкретную пару; поля `newTimeSlotId`, `newClassroomId`, - `CANCEL` отменяет конкретную пару; `targetLessonDate` и все поля `new*` должны отсутствовать;
`newTeacherId` и `newLessonFormat` должны отсутствовать; - `MOVE` требует новый временной слот; при переносе даты слот выбирается из эффективной
- `MOVE` требует новый временной слот или аудиторию; преподавателя и формат можно изменить сетки целевого дня, а преподавателя, аудиторию и формат можно изменить тем же запросом;
в том же запросе; - `REPLACE` используется только без изменения даты и времени и требует нового
- `REPLACE` требует нового преподавателя, аудиторию или формат; временной слот можно преподавателя, аудиторию или формат;
изменить в том же запросе;
- формат принимает только `Очно` или `Онлайн`; - формат принимает только `Очно` или `Онлайн`;
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID - `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
временного слота с тем же интервалом не считается переносом. временного слота с тем же интервалом не считается переносом.
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов. расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
Если пара не формируется или нарушена матрица действия, API возвращает `400` с русским При переносе даты backend дополнительно проверяет тот же семестр, действие правила и
сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией, дисциплины, lifecycle итоговых ресурсов, учебный календарь всех затронутых групп и
группой или той же подгруппой, API возвращает `409 Conflict`; соседние интервалы и разные принадлежность времени эффективной сетке целевого дня. Если пара не формируется или
подгруппы одной группы не конфликтуют. нарушена матрица действия, API возвращает `400` с русским сообщением. Если результирующее
время пересекается с занятым преподавателем, аудиторией, группой или той же подгруппой,
API возвращает `409 Conflict`; соседние интервалы и разные подгруппы одной группы не
конфликтуют.
Пример ответа availability:
```json
{
"semesterId": 3,
"semesterStartDate": "2026-02-09",
"semesterEndDate": "2026-06-30",
"availableDates": ["2026-05-21", "2026-05-22", "2026-05-25"]
}
```
## Загруженность ## Загруженность

View File

@@ -263,30 +263,44 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Учебный отдел может создать изменение конкретной пары: Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару; - `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой фактический интервал или в другую аудиторию; - `MOVE` — перенести пару на другую учебную дату того же семестра или другой фактический интервал;
- `REPLACE` — заменить преподавателя, аудиторию или формат. - `REPLACE` без изменения даты и времени заменить преподавателя, аудиторию или формат.
`CANCEL` не принимает новые ресурсы. Для `MOVE` обязателен новый временной слот или `CANCEL` не принимает целевую дату и новые ресурсы. Для `MOVE` обязателен новый временной
аудитория, для `REPLACE` — преподаватель, аудитория или формат. Дополнительные изменения слот, для `REPLACE` — преподаватель, аудитория или формат. Перенос даты требует явно
можно объединять в одном payload, но основное действие должно фактически менять свою выбранного слота эффективной сетки целевого дня. Дополнительные изменения преподавателя,
часть пары. Формат ограничен значениями `Очно` и `Онлайн`. аудитории и формата можно объединить с `MOVE`, но основное действие должно фактически
менять свою часть пары. Формат ограничен значениями `Очно` и `Онлайн`; дисциплина и тип
занятия не изменяются.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
расписания на конкретную дату. Перед записью `ScheduleOverrideService`: расписания. Исходная пара всегда идентифицируется как `baseRuleSlotId + lessonDate`, а
`targetLessonDate` хранит только новую дату единственного занятия. Перед записью
`ScheduleOverrideService`:
1. захватывает transaction advisory lock PostgreSQL для tenant-БД и даты; 1. захватывает transaction advisory lock PostgreSQL для исходных и целевых дат в стабильном порядке;
2. строит базовый день для всех групп без интерактивного лимита широкого поиска; 2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
3. доказывает существование исходной пары с учётом семестра, календарного графика, 3. доказывает существование исходной пары с учётом семестра, календарного графика,
чётности, недели начала, лимита часов и lifecycle; чётности, недели начала, лимита часов и lifecycle;
4. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`; 4. для новой даты проверяет границы того же семестра, активность правила и дисциплины,
5. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы; учебный день всех затронутых групп и эффективную сетку времени;
6. сохраняет изменение только при отсутствии конфликта. 5. проверяет lifecycle итоговых преподавателя и аудитории на целевую дату;
6. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
7. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы целевого дня;
8. сохраняет изменение только при отсутствии конфликта.
В поиске `MOVE` удаляет исходное вхождение и добавляет занятие в целевой день, включая
случай, когда в запрос попала только целевая дата. День недели, номер недели и чётность
вычисляются заново по семестру, а расход академических часов остаётся у исходной пары.
`CANCEL` скрывает занятие. Удаление override возвращает текущий вариант, который снова
формируется базовым правилом.
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом. Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях. подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
для backend-pod, поэтому два конкурентных изменения одной даты проверяются последовательно. для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
дату и одинаковые ресурсы проверяются последовательно.
--- ---

View File

@@ -326,6 +326,7 @@ erDiagram
BIGSERIAL id PK BIGSERIAL id PK
BIGINT base_rule_slot_id FK BIGINT base_rule_slot_id FK
DATE lesson_date DATE lesson_date
DATE target_lesson_date
VARCHAR action VARCHAR action
BIGINT new_time_slot_id FK BIGINT new_time_slot_id FK
BIGINT new_classroom_id FK BIGINT new_classroom_id FK
@@ -873,7 +874,8 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|---------|-----|----------| |---------|-----|----------|
| `id` | BIGSERIAL PK | ID изменения | | `id` | BIGSERIAL PK | ID изменения |
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот правила | | `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот правила |
| `lesson_date` | DATE | Дата конкретной пары | | `lesson_date` | DATE | Исходная дата конкретной пары из базового правила |
| `target_lesson_date` | DATE NULL | Новая дата единственного занятия при `MOVE` |
| `action` | VARCHAR(20) | `MOVE`, `CANCEL`, `REPLACE` | | `action` | VARCHAR(20) | `MOVE`, `CANCEL`, `REPLACE` |
| `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот | | `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот |
| `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория | | `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория |
@@ -887,12 +889,19 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
одной и той же пары. Базовая схема V1 добавляет структурные инварианты: одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
- `CANCEL` не содержит новых ресурсов; - `CANCEL` не содержит новых ресурсов;
- `MOVE` содержит новый временной слот или аудиторию; - `MOVE` содержит новый временной слот;
- `REPLACE` содержит нового преподавателя, аудиторию или формат; - `REPLACE` не содержит нового времени и содержит нового преподавателя, аудиторию или формат;
- `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`. - `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`.
- `target_lesson_date` отличается от `lesson_date`, разрешена только для `MOVE` и требует
`new_time_slot_id`.
Фактическое существование пары, реальность изменения и ресурсные конфликты зависят от Частичный индекс `idx_schedule_overrides_target_date` ускоряет выборку входящих переносов,
календаря конкретной даты и проверяются транзакционным сервисом, а не SQL CHECK. а индекс исходной даты `idx_schedule_overrides_date` обслуживает исходящие изменения и
отмены. Диапазонный реестр и поиск используют обе даты.
Фактическое существование пары, тот же семестр, учебный календарь затронутых групп,
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
конфликты проверяются транзакционным сервисом, а не SQL CHECK.
--- ---
@@ -910,7 +919,7 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
### Этап разработки ### Этап разработки

View File

@@ -28,6 +28,8 @@ frontend/
├── tests/ ├── tests/
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile │ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
├── index.html # 🔐 Страница авторизации (общая) ├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации ├── script.js # Логика авторизации
@@ -59,7 +61,8 @@ frontend/
│ │ ├── subjects.js # Управление дисциплинами │ │ ├── subjects.js # Управление дисциплинами
│ │ ├── university-structure.js # Кафедры, специальности и профили обучения │ │ ├── university-structure.js # Кафедры, специальности и профили обучения
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели │ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── schedule.js # Конструктор правил расписания │ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── academic-calendar.js # Календарные учебные графики
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр │ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
@@ -152,7 +155,7 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` | | `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а на экране отображается одна активная совмещённая таблица чётной/нечётной недели | `/api/schedule/search` | | `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
@@ -169,7 +172,9 @@ frontend/
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; в дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
@@ -282,7 +287,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
- расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре; - расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре;
- конструктор расписания через `/api/admin/schedule-rules`; - конструктор расписания через `/api/admin/schedule-rules`;
- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API; - разовая правка конкретного занятия через боковую панель: перенос даты/времени, замена ресурсов, отмена и возврат по правилу;
- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`; - матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`;
- аудитории, оборудование, календарный график и загруженность. - аудитории, оборудование, календарный график и загруженность.
@@ -298,7 +303,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
### Просмотр расписаний (`/admin/#schedule-view`) ### Просмотр расписаний (`/admin/#schedule-view`)
Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам. Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам.
### Преподаватель (`/teacher/`) ### Преподаватель (`/teacher/`)

View File

@@ -630,7 +630,7 @@
} }
.schedule-view-extra-grid { .schedule-view-extra-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr)); grid-template-columns: repeat(4, minmax(180px, 1fr));
margin-top: 0.9rem; margin-top: 0.9rem;
} }
@@ -780,6 +780,438 @@
margin-top: 0.1rem; margin-top: 0.1rem;
} }
.schedule-view-lesson-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.schedule-override-badge {
display: inline-flex;
width: fit-content;
margin: 0.35rem 0;
padding: 0.18rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-size: 0.68rem;
font-weight: 700;
}
.schedule-override-edit {
width: 100%;
margin-top: 0.55rem;
padding: 0.42rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bg-card-border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-input));
color: var(--text-primary);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
.schedule-override-edit:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--bg-input));
}
.schedule-override-count {
display: inline-grid;
min-width: 1.5rem;
height: 1.5rem;
margin-left: 0.4rem;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
font-size: 0.72rem;
}
body.schedule-override-open {
overflow: hidden;
}
.schedule-override-drawer {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
grid-template-columns: 1fr minmax(520px, 680px);
}
.schedule-override-drawer[hidden] {
display: none;
}
.schedule-override-backdrop {
border: 0;
background: rgba(4, 10, 24, 0.68);
cursor: default;
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
}
.schedule-override-panel {
min-width: 0;
height: 100vh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
border-left: 1px solid var(--bg-card-border);
background: color-mix(in srgb, var(--bg-card) 88%, transparent);
box-shadow: -24px 0 64px rgba(0, 0, 0, 0.28);
-webkit-backdrop-filter: blur(24px) saturate(135%);
backdrop-filter: blur(24px) saturate(135%);
animation: schedule-override-enter 0.22s ease-out;
}
@keyframes schedule-override-enter {
from { transform: translateX(30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.schedule-override-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem 1.5rem 1rem;
}
.schedule-override-header h2 {
margin: 0.18rem 0 0;
}
.schedule-override-eyebrow {
color: var(--accent);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.schedule-override-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
margin: 0 1.5rem;
padding: 0.3rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-override-tab {
min-height: 38px;
border: 0;
border-radius: calc(var(--radius-sm) - 3px);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.schedule-override-tab.active {
background: color-mix(in srgb, var(--accent) 16%, var(--bg-card));
color: var(--text-primary);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
}
.schedule-override-body {
min-height: 0;
padding: 1.25rem 1.5rem 1.5rem;
overflow-y: auto;
}
.schedule-override-empty {
display: grid;
min-height: 180px;
padding: 1.25rem;
place-items: center;
border: 1px dashed var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-secondary);
text-align: center;
}
.schedule-override-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1.2rem;
}
.schedule-override-comparison article {
min-width: 0;
padding: 0.9rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-override-comparison .schedule-override-after {
border-color: color-mix(in srgb, var(--accent) 55%, var(--bg-card-border));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-input));
}
.schedule-override-comparison span,
.schedule-override-comparison strong,
.schedule-override-comparison p {
display: block;
}
.schedule-override-comparison span {
margin-bottom: 0.4rem;
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.schedule-override-comparison strong {
font-size: 0.88rem;
}
.schedule-override-comparison p {
margin: 0.42rem 0 0;
color: var(--text-secondary);
font-size: 0.76rem;
line-height: 1.45;
}
.schedule-override-fieldset {
min-width: 0;
margin: 0 0 1.2rem;
padding: 0;
border: 0;
}
.schedule-override-fieldset legend {
margin-bottom: 0.55rem;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
}
.schedule-override-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.35rem;
}
.schedule-override-date {
min-width: 0;
min-height: 58px;
padding: 0.38rem 0.2rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
.schedule-override-date span,
.schedule-override-date strong {
display: block;
}
.schedule-override-date span {
color: var(--text-secondary);
font-size: 0.68rem;
}
.schedule-override-date strong {
margin-top: 0.2rem;
font-size: 0.77rem;
}
.schedule-override-date.active,
.schedule-override-calendar-day.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--bg-input));
box-shadow: inset 0 0 0 1px var(--accent);
}
.schedule-override-date:disabled,
.schedule-override-calendar-day:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.schedule-override-calendar-toggle {
width: 100%;
margin-top: 0.6rem;
}
.schedule-override-calendar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
max-height: 390px;
margin-top: 0.65rem;
padding: 0.65rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
overflow-y: auto;
}
.schedule-override-calendar[hidden] {
display: none;
}
.schedule-override-month {
padding: 0.55rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.schedule-override-month h4 {
margin: 0 0 0.55rem;
font-size: 0.8rem;
text-align: center;
}
.schedule-override-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.18rem;
}
.schedule-override-calendar-grid > strong {
padding: 0.2rem 0;
color: var(--text-secondary);
font-size: 0.6rem;
text-align: center;
}
.schedule-override-calendar-day,
.schedule-override-calendar-blank {
min-width: 0;
aspect-ratio: 1;
}
.schedule-override-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.7rem;
cursor: pointer;
}
.schedule-override-calendar-day:not(:disabled):hover {
border-color: var(--accent);
}
.schedule-override-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.schedule-override-fields select,
#schedule-override-comment {
width: 100%;
}
#schedule-override-comment {
resize: vertical;
}
.schedule-override-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--bg-card-border);
}
.schedule-override-registry-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.9rem;
}
.schedule-override-registry-heading h3 {
margin: 0 0 0.25rem;
}
.schedule-override-registry {
display: grid;
gap: 0.7rem;
}
.schedule-override-registry-item {
padding: 1rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-override-registry-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.schedule-override-registry-topline time {
color: var(--text-secondary);
font-size: 0.76rem;
}
.schedule-override-action {
display: inline-flex;
padding: 0.2rem 0.48rem;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
font-size: 0.68rem;
font-weight: 800;
}
.schedule-override-action.action-cancel {
background: color-mix(in srgb, var(--danger, #ef4444) 13%, transparent);
color: var(--danger, #ef4444);
}
.schedule-override-registry-item h4 {
margin: 0.65rem 0 0.25rem;
}
.schedule-override-registry-item p,
.schedule-override-registry-item blockquote {
color: var(--text-secondary);
font-size: 0.78rem;
line-height: 1.5;
}
.schedule-override-registry-item p {
margin: 0;
}
.schedule-override-registry-item blockquote {
margin: 0.65rem 0;
padding-left: 0.7rem;
border-left: 2px solid var(--accent);
}
.schedule-override-registry-item .btn {
margin-top: 0.75rem;
}
.schedule-view-state { .schedule-view-state {
min-height: 100%; min-height: 100%;
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
@@ -944,4 +1376,46 @@
.schedule-view-day-tabs { .schedule-view-day-tabs {
justify-content: flex-start; justify-content: flex-start;
} }
.schedule-override-drawer {
display: block;
}
.schedule-override-drawer[hidden] {
display: none;
}
.schedule-override-backdrop {
display: none;
}
.schedule-override-panel {
width: 100%;
border-left: 0;
}
.schedule-override-header,
.schedule-override-body {
padding-right: 1rem;
padding-left: 1rem;
}
.schedule-override-tabs {
margin-right: 1rem;
margin-left: 1rem;
}
.schedule-override-comparison,
.schedule-override-fields,
.schedule-override-calendar {
grid-template-columns: 1fr;
}
.schedule-override-week {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.schedule-override-actions .btn {
width: 100%;
}
} }

View File

@@ -0,0 +1,611 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
export function canEditScheduleOverrides(role) {
return EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
if (!canEditScheduleOverrides(role)) {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
const drawer = document.getElementById('schedule-override-drawer');
const form = document.getElementById('schedule-override-form');
const empty = document.getElementById('schedule-override-empty');
const registryButton = document.getElementById('schedule-view-overrides-open');
const registryCount = document.getElementById('schedule-view-overrides-count');
const registry = document.getElementById('schedule-override-registry');
const registryPeriod = document.getElementById('schedule-override-registry-period');
const calendar = document.getElementById('schedule-override-calendar');
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
const week = document.getElementById('schedule-override-week');
const timeSelect = document.getElementById('schedule-override-time');
const teacherSelect = document.getElementById('schedule-override-teacher');
const classroomSelect = document.getElementById('schedule-override-classroom');
const formatSelect = document.getElementById('schedule-override-format');
const commentInput = document.getElementById('schedule-override-comment');
const restoreButton = document.getElementById('schedule-override-restore');
const cancelButton = document.getElementById('schedule-override-cancel');
const saveButton = document.getElementById('schedule-override-save');
const state = {
overrides: [],
selectedLesson: null,
selectedOverride: null,
source: null,
availability: null,
targetDate: null,
timeSlots: [],
busy: false
};
registryButton.hidden = false;
fillReferenceSelects();
bindEvents();
renderRegistry();
return { setRegistry, openLesson, openRegistry };
function bindEvents() {
drawer.querySelectorAll('[data-override-close]').forEach(button => {
button.addEventListener('click', close);
});
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
});
registryButton.addEventListener('click', openRegistry);
calendarToggle.addEventListener('click', () => {
calendar.hidden = !calendar.hidden;
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
});
week.addEventListener('click', selectDateFromButton);
calendar.addEventListener('click', selectDateFromButton);
registry.addEventListener('click', event => {
const button = event.target.closest('[data-override-registry-id]');
if (!button) return;
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
if (item) openOverride(item);
});
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
select.addEventListener('change', updateComparison);
});
form.addEventListener('submit', save);
cancelButton.addEventListener('click', cancelLesson);
restoreButton.addEventListener('click', restoreRule);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !drawer.hidden) close();
});
}
function setRegistry(overrides, range) {
state.overrides = Array.isArray(overrides) ? overrides : [];
registryCount.textContent = String(state.overrides.length);
const activeRange = range || getRange?.();
registryPeriod.textContent = activeRange
? `${formatDate(activeRange.startDate)}${formatDate(activeRange.endDate)}`
: 'Период не выбран';
renderRegistry();
}
async function openLesson(lesson) {
if (!lesson?.scheduleRuleSlotId) return;
const originalDate = lesson.originalLessonDate || lesson.date;
const existing = state.overrides.find(override =>
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
&& override.lessonDate === originalDate
) || null;
await prepareEditor(lesson, existing);
}
async function openOverride(override) {
const syntheticLesson = {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
await prepareEditor(syntheticLesson, override);
}
function openRegistry() {
open();
activateTab('registry');
renderRegistry();
}
async function prepareEditor(lesson, existing) {
open();
activateTab('edit');
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Загрузка доступных дат и времени...';
hideAlert('schedule-override-alert');
state.selectedLesson = lesson;
state.selectedOverride = existing;
state.source = sourceFrom(lesson, existing);
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
try {
state.availability = await api.get(
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
);
renderDateChoices();
await loadTimeSlots(initialTimePreference(existing));
setEditorValues(existing);
renderSource();
updateComparison();
restoreButton.hidden = !existing;
form.hidden = false;
empty.hidden = true;
} catch (error) {
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
}
}
function sourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
function initialTimePreference(existing) {
return {
id: existing?.newTimeSlotId || state.source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
endTime: existing?.newTimeSlotId ? null : state.source.endTime
};
}
function fillReferenceSelects() {
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
).join('');
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
).join('');
}
function setEditorValues(existing) {
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
ensureSelectOption(
teacherSelect,
effectiveTeacherId,
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
);
ensureSelectOption(
classroomSelect,
effectiveClassroomId,
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
);
teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId);
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || '';
}
async function selectDateFromButton(event) {
const button = event.target.closest('[data-override-date]');
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
state.targetDate = button.dataset.overrideDate;
renderDateChoices();
await loadTimeSlots({
id: state.source.timeSlotId,
startTime: state.source.startTime,
endTime: state.source.endTime
});
updateComparison();
}
function renderDateChoices() {
const available = new Set(state.availability?.availableDates || []);
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
calendar.innerHTML = renderSemesterCalendar(
state.availability.semesterStartDate,
state.availability.semesterEndDate,
available,
state.targetDate
);
}
function dateButton(date, label, available) {
const selected = date === state.targetDate ? ' active' : '';
const disabled = available.has(date) ? '' : ' disabled';
return `
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatDateShort(date))}</strong>
</button>
`;
}
async function loadTimeSlots(preference) {
timeSelect.disabled = true;
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
state.timeSlots = await api.get(
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
);
const selected = pickMatchingTimeSlot(
state.timeSlots,
preference?.id,
preference?.startTime,
preference?.endTime
);
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
).join('');
timeSelect.value = selected ? String(selected.id) : '';
timeSelect.disabled = false;
}
function renderSource() {
document.getElementById('schedule-override-before-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-before-meta').textContent = [
formatDate(state.source.lessonDate),
intervalLabel(state.source.startTime, state.source.endTime),
state.source.teacherName,
state.source.classroomName,
state.source.lessonFormat,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
function updateComparison() {
if (!state.source) return;
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
document.getElementById('schedule-override-after-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-after-meta').textContent = [
formatDate(state.targetDate),
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName(teacherSelect.value),
classroomName(classroomSelect.value),
formatSelect.value,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
async function save(event) {
event.preventDefault();
if (state.busy) return;
hideAlert('schedule-override-alert');
const payload = buildPayload();
if (!payload) return;
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
function buildPayload() {
if (!timeSelect.value) {
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
return null;
}
const dateChanged = state.targetDate !== state.source.lessonDate;
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
const formatChanged = formatSelect.value !== state.source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
showAlert(
'schedule-override-alert',
state.selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат',
'error'
);
return null;
}
return {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: dateChanged ? state.targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
newLessonFormat: formatChanged ? formatSelect.value : null,
comment: commentInput.value.trim() || null
};
}
async function cancelLesson() {
if (state.busy || !state.source) return;
const payload = {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: commentInput.value.trim() || null
};
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
async function restoreRule() {
if (state.busy || !state.selectedOverride) return;
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
}
async function mutate(operation) {
setBusy(true);
hideAlert('schedule-override-alert');
try {
await operation();
await onChanged?.();
state.selectedLesson = null;
state.selectedOverride = null;
state.source = null;
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
openRegistry();
} catch (error) {
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
} finally {
setBusy(false);
}
}
function setBusy(busy) {
state.busy = busy;
saveButton.disabled = busy;
cancelButton.disabled = busy;
restoreButton.disabled = busy;
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
}
function renderRegistry() {
if (!registry) return;
if (!state.overrides.length) {
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
return;
}
registry.innerHTML = state.overrides.map(override => {
const target = override.targetLessonDate
? `${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
teacherName(override.newTeacherId),
classroomName(override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return `
<article class="schedule-override-registry-item">
<div class="schedule-override-registry-topline">
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
</div>
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
<p>${escapeHtml([
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '))}</p>
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
</article>
`;
}).join('');
}
function activateTab(tab) {
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.overrideTab === tab);
});
drawer.querySelectorAll('[data-override-section]').forEach(section => {
section.hidden = section.dataset.overrideSection !== tab;
});
}
function open() {
drawer.hidden = false;
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('schedule-override-open');
}
function close() {
drawer.hidden = true;
drawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('schedule-override-open');
}
function teacherName(id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
function classroomName(id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
}
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return '';
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
}
return months.join('');
}
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
const active = date === selectedDate ? ' active' : '';
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
}).join('');
return `
<section class="schedule-override-month">
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
<div class="schedule-override-calendar-grid">
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
${blanks}${days}
</div>
</section>
`;
}
function timeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
}
function intervalLabel(start, end) {
return `${trimTime(start)}${trimTime(end)}`;
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
function ensureSelectOption(select, value, label) {
if (value == null || value === '') return;
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
if (exists) return;
const option = document.createElement('option');
option.value = String(value);
option.textContent = label;
select.append(option);
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function formatDateShort(value) {
const [, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

View File

@@ -1,5 +1,6 @@
import { api, getSession } from '../api.js'; import { api, getSession } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
const ROLE_DEPARTMENT = 'DEPARTMENT'; const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups'; const TARGET_GROUPS = 'groups';
@@ -59,6 +60,7 @@ export async function initScheduleView() {
const tables = document.getElementById('schedule-view-tables'); const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]')); const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department'); const departmentSelect = document.getElementById('schedule-view-department');
const semesterSelect = document.getElementById('schedule-view-semester');
const state = { const state = {
dictionaries: { dictionaries: {
@@ -71,8 +73,11 @@ export async function initScheduleView() {
activeSectionIndex: 0, activeSectionIndex: 0,
mobileDay: 1, mobileDay: 1,
range: null, range: null,
totalLessons: 0 totalLessons: 0,
lessons: [],
overrides: []
}; };
let overridePanel = createEmptyOverridePanel();
const mobileMedia = window.matchMedia(MOBILE_QUERY); const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput); setDefaultDate(dateInput);
@@ -97,8 +102,22 @@ export async function initScheduleView() {
updatePeriodLabel(); updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание'); resetResults('Нажмите «Показать», чтобы обновить расписание');
}); });
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1)); resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1)); resultNextButton?.addEventListener('click', () => moveResult(1));
tables?.addEventListener('click', event => {
const button = event.target.closest('[data-schedule-override-edit]');
if (!button) return;
const lesson = state.lessons.find(item =>
String(item.scheduleRuleSlotId) === button.dataset.baseRuleSlotId
&& (item.originalLessonDate || item.date) === button.dataset.lessonDate
);
if (lesson) overridePanel.openLesson(lesson);
});
if (typeof mobileMedia.addEventListener === 'function') { if (typeof mobileMedia.addEventListener === 'function') {
mobileMedia.addEventListener('change', renderActiveSection); mobileMedia.addEventListener('change', renderActiveSection);
} else if (typeof mobileMedia.addListener === 'function') { } else if (typeof mobileMedia.addListener === 'function') {
@@ -107,6 +126,13 @@ export async function initScheduleView() {
try { try {
state.dictionaries = await loadDictionaries(role, userDepartmentId); state.dictionaries = await loadDictionaries(role, userDepartmentId);
selectCurrentSemester();
overridePanel = createScheduleOverridePanel({
role,
dictionaries: state.dictionaries,
getRange: () => state.range,
onChanged: loadData
});
configureDepartmentAccess(); configureDepartmentAccess();
updateEntityFieldVisibility(); updateEntityFieldVisibility();
await loadData(); await loadData();
@@ -192,7 +218,7 @@ export async function initScheduleView() {
} }
const target = activeTarget(); const target = activeTarget();
const range = twoWeekRange(dateInput.value); const range = scheduleRangeForSemester(dateInput.value, selectedSemester());
const previousKey = state.sections[state.activeSectionIndex]?.key; const previousKey = state.sections[state.activeSectionIndex]?.key;
state.range = range; state.range = range;
updatePeriodLabel(range); updatePeriodLabel(range);
@@ -213,7 +239,14 @@ export async function initScheduleView() {
addOptional(params, 'parity', valueOf('schedule-view-parity')); addOptional(params, 'parity', valueOf('schedule-view-parity'));
try { try {
const lessons = await api.get(`/api/schedule/search?${params}`); const [lessons, overrides] = await Promise.all([
api.get(`/api/schedule/search?${params}`),
canEditScheduleOverrides(role)
? api.get(`/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`)
: Promise.resolve([])
]);
state.overrides = overrides;
overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey); renderLessons(lessons, range, target, previousKey);
} catch (error) { } catch (error) {
const message = error.message || 'Ошибка загрузки расписания'; const message = error.message || 'Ошибка загрузки расписания';
@@ -245,6 +278,7 @@ export async function initScheduleView() {
} }
function renderLessons(lessons, range, target, previousKey) { function renderLessons(lessons, range, target, previousKey) {
state.lessons = lessons;
state.totalLessons = lessons.length; state.totalLessons = lessons.length;
if (!lessons.length) { if (!lessons.length) {
state.sections = []; state.sections = [];
@@ -581,6 +615,9 @@ export async function initScheduleView() {
lesson.classroomId || lesson.classroomName || '', lesson.classroomId || lesson.classroomName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '', lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '', lesson.lessonFormat || '',
lesson.scheduleOverrideId || '',
lesson.overrideAction || '',
lesson.originalLessonDate || '',
valueListSignature(lesson.groupIds, lesson.groupNames), valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames) valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':'); ].join(':');
@@ -672,19 +709,36 @@ export async function initScheduleView() {
const weeksText = getLessonWeeksText(lesson); const weeksText = getLessonWeeksText(lesson);
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : ''; const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
const sourceDate = lesson.originalLessonDate || lesson.date;
const overrideBadge = lesson.scheduleOverrideId
? '<span class="schedule-override-badge">Разовая правка</span>'
: '';
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
? `
<button type="button"
class="schedule-override-edit"
data-schedule-override-edit
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
data-lesson-date="${escapeHtml(sourceDate)}">
Изменить
</button>
`
: '';
return ` return `
<div class="lesson-card schedule-view-lesson-card"> <div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;"> <div class="lesson-subject schedule-view-lesson-title" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span> <span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
${weeksBadge} ${weeksBadge}
</div> </div>
${overrideBadge}
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div> <div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''} ${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div> <div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div> <div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''} ${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''} ${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
${editButton}
</div> </div>
`; `;
} }
@@ -699,6 +753,7 @@ export async function initScheduleView() {
state.activeSectionIndex = 0; state.activeSectionIndex = 0;
state.mobileDay = 1; state.mobileDay = 1;
state.totalLessons = 0; state.totalLessons = 0;
state.lessons = [];
renderResultNavigation(); renderResultNavigation();
tableTitle.textContent = 'Расписание'; tableTitle.textContent = 'Расписание';
tableCaption.textContent = message; tableCaption.textContent = message;
@@ -714,7 +769,36 @@ export async function initScheduleView() {
periodLabel.textContent = 'Период не выбран'; periodLabel.textContent = 'Период не выбран';
return; return;
} }
periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value)); periodLabel.textContent = periodCaption(
range || scheduleRangeForSemester(dateInput.value, selectedSemester()),
selectedSemester()
);
}
function selectedSemester() {
const semesterId = semesterSelect?.value;
if (!semesterId) return null;
return (state.dictionaries.semesters || [])
.find(semester => String(semester.id) === String(semesterId)) || null;
}
function selectCurrentSemester() {
if (!semesterSelect) return;
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
synchronizeDateWithSemester();
updatePeriodLabel();
}
function synchronizeDateWithSemester() {
const semester = selectedSemester();
if (!semester) {
dateInput.value = toIsoDate(new Date());
return;
}
if (!dateWithinSemester(dateInput.value, semester)) {
dateInput.value = semester.startDate;
}
} }
function groupNameById(id) { function groupNameById(id) {
@@ -727,15 +811,23 @@ export async function initScheduleView() {
} }
} }
function createEmptyOverridePanel() {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
async function loadDictionaries(role, userDepartmentId) { async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([ const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/groups'), api.get('/api/groups'),
api.get('/api/users/teachers'), api.get('/api/users/teachers'),
api.get('/api/classrooms'), api.get('/api/classrooms'),
api.get('/api/departments'), api.get('/api/departments'),
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/lesson-types'), api.get('/api/lesson-types'),
api.get('/api/admin/calendar/years').catch(() => []) api.get('/api/schedule/semesters')
]); ]);
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
@@ -748,13 +840,14 @@ async function loadDictionaries(role, userDepartmentId) {
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры'); fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины'); fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы'); fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
return { return {
groups, groups,
teachers, teachers,
classrooms, classrooms,
departments, departments,
semesters: (academicYears || []).flatMap(year => year.semesters || []) semesters: semesters || []
}; };
} }
@@ -975,8 +1068,43 @@ function mobileDayCaption(datesByParity) {
return 'Нечётная / Чётная'; return 'Нечётная / Чётная';
} }
function periodCaption(range) { function periodCaption(range, semester = null) {
return 'Учебное расписание'; if (!range) return 'Учебное расписание';
const rangeLabel = `${formatDate(range.startDate)}${formatDate(range.endDate)}`;
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
}
export function semesterOptionLabel(semester) {
const type = String(semester?.semesterType || '').toLowerCase();
const typeLabel = type === 'autumn'
? 'Осенний семестр'
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
const academicYear = semester?.academicYearTitle || 'Учебный год';
const dates = semester?.startDate && semester?.endDate
? ` · ${formatDate(semester.startDate)}${formatDate(semester.endDate)}`
: '';
return `${academicYear} · ${typeLabel}${dates}`;
}
export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
? semester.startDate
: selectedDate;
return twoWeekRange(anchorDate);
}
function dateWithinSemester(date, semester) {
return Boolean(
date
&& semester?.startDate
&& semester?.endDate
&& date >= semester.startDate
&& date <= semester.endDate
);
} }
function lessonSlotKey(lesson) { function lessonSlotKey(lesson) {

View File

@@ -47,12 +47,18 @@
</select> </select>
</div> </div>
<input type="date" id="schedule-view-date" style="display: none;"> <input type="date" id="schedule-view-date" hidden>
</div> </div>
<details class="schedule-view-extra-filters"> <details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary> <summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid"> <div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-semester">Семестр</label>
<select id="schedule-view-semester">
<option value="">По текущей дате</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="schedule-view-subject">Дисциплина</label> <label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject"> <select id="schedule-view-subject">
@@ -87,6 +93,10 @@
<span class="muted" id="schedule-view-count">0 занятий</span> <span class="muted" id="schedule-view-count">0 занятий</span>
</div> </div>
<div class="schedule-view-result-actions"> <div class="schedule-view-result-actions">
<button type="button" class="btn btn-md btn-secondary" id="schedule-view-overrides-open" hidden>
Изменения за период
<span class="schedule-override-count" id="schedule-view-overrides-count">0</span>
</button>
<button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание"> <button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline> <polyline points="15 18 9 12 15 6"></polyline>
@@ -116,3 +126,102 @@
</div> </div>
</div> </div>
</section> </section>
<div class="schedule-override-drawer" id="schedule-override-drawer" hidden aria-hidden="true">
<button type="button" class="schedule-override-backdrop" data-override-close aria-label="Закрыть панель"></button>
<aside class="schedule-override-panel" role="dialog" aria-modal="true" aria-labelledby="schedule-override-title">
<header class="schedule-override-header">
<div>
<span class="schedule-override-eyebrow">Точечное изменение</span>
<h2 id="schedule-override-title">Конкретное занятие</h2>
</div>
<button type="button" class="btn btn-icon-md btn-secondary" data-override-close aria-label="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
</header>
<nav class="schedule-override-tabs" aria-label="Разовые изменения">
<button type="button" class="schedule-override-tab active" data-override-tab="edit">Редактирование</button>
<button type="button" class="schedule-override-tab" data-override-tab="registry">Изменения за период</button>
</nav>
<div class="schedule-override-body">
<section data-override-section="edit">
<div class="schedule-override-empty" id="schedule-override-empty">
Выберите занятие в расписании или откройте его из реестра изменений.
</div>
<form id="schedule-override-form" hidden>
<div class="schedule-override-comparison">
<article>
<span>Было по правилу</span>
<strong id="schedule-override-before-title"></strong>
<p id="schedule-override-before-meta"></p>
</article>
<article class="schedule-override-after">
<span>Станет</span>
<strong id="schedule-override-after-title"></strong>
<p id="schedule-override-after-meta"></p>
</article>
</div>
<fieldset class="schedule-override-fieldset">
<legend>Дата занятия</legend>
<div class="schedule-override-week" id="schedule-override-week"></div>
<button type="button" class="btn btn-md btn-secondary schedule-override-calendar-toggle" id="schedule-override-calendar-toggle">
Выбрать другую дату
</button>
<div class="schedule-override-calendar" id="schedule-override-calendar" hidden></div>
</fieldset>
<div class="schedule-override-fields">
<div class="form-group">
<label for="schedule-override-time">Время</label>
<select id="schedule-override-time" data-native-select="true" required></select>
</div>
<div class="form-group">
<label for="schedule-override-teacher">Преподаватель</label>
<select id="schedule-override-teacher" data-native-select="true"></select>
</div>
<div class="form-group">
<label for="schedule-override-classroom">Аудитория</label>
<select id="schedule-override-classroom" data-native-select="true"></select>
</div>
<div class="form-group">
<label for="schedule-override-format">Формат</label>
<select id="schedule-override-format" data-native-select="true">
<option value="Очно">Очно</option>
<option value="Онлайн">Онлайн</option>
</select>
</div>
</div>
<div class="form-group">
<label for="schedule-override-comment">Комментарий</label>
<textarea id="schedule-override-comment" rows="3" maxlength="1000" placeholder="Например, замена аудитории из-за мероприятия"></textarea>
</div>
<div class="form-alert" id="schedule-override-alert" role="alert"></div>
<footer class="schedule-override-actions">
<button type="submit" class="btn btn-md btn-primary" id="schedule-override-save">Сохранить</button>
<button type="button" class="btn btn-md btn-danger-subtle" id="schedule-override-cancel">Отменить занятие</button>
<button type="button" class="btn btn-md btn-secondary" id="schedule-override-restore" hidden>Вернуть по правилу</button>
</footer>
</form>
</section>
<section data-override-section="registry" hidden>
<div class="schedule-override-registry-heading">
<div>
<h3>Изменения за выбранный период</h3>
<span class="muted" id="schedule-override-registry-period"></span>
</div>
</div>
<div class="schedule-override-registry" id="schedule-override-registry"></div>
</section>
</div>
</aside>
</div>

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
canEditScheduleOverrides,
classroomOptionLabel,
overrideActionForChanges,
pickMatchingTimeSlot,
weekDates
} from '../admin/js/views/schedule-override-panel.js';
test('редактировать разовые изменения могут только администратор и учебный отдел', () => {
assert.equal(canEditScheduleOverrides('ADMIN'), true);
assert.equal(canEditScheduleOverrides('EDUCATION_OFFICE'), true);
assert.equal(canEditScheduleOverrides('DEPARTMENT'), false);
assert.equal(canEditScheduleOverrides('SCHEDULE_VIEWER'), false);
});
test('дата или время формируют MOVE, а замена ресурсов формирует REPLACE', () => {
assert.equal(overrideActionForChanges({ dateChanged: true }), 'MOVE');
assert.equal(overrideActionForChanges({ timeChanged: true, teacherChanged: true }), 'MOVE');
assert.equal(overrideActionForChanges({ classroomChanged: true }), 'REPLACE');
assert.equal(overrideActionForChanges({ teacherChanged: true, formatChanged: true }), 'REPLACE');
assert.equal(overrideActionForChanges({}), null);
});
test('недельный выбор всегда показывает понедельник-воскресенье исходной недели', () => {
assert.deepEqual(weekDates('2026-09-03'), [
'2026-08-31',
'2026-09-01',
'2026-09-02',
'2026-09-03',
'2026-09-04',
'2026-09-05',
'2026-09-06'
]);
});
test('время выбирается сначала по ID, затем по совпадающему интервалу', () => {
const slots = [
{ id: 10, startTime: '09:00:00', endTime: '10:30:00' },
{ id: 20, startTime: '10:40:00', endTime: '12:10:00' }
];
assert.equal(pickMatchingTimeSlot(slots, 20, '09:00', '10:30').id, 20);
assert.equal(pickMatchingTimeSlot(slots, 99, '09:00', '10:30').id, 10);
assert.equal(pickMatchingTimeSlot(slots, 99, '12:20', '13:50'), null);
});
test('в селекте боковой панели аудитория показывается только своим названием', () => {
assert.equal(classroomOptionLabel({
name: '101',
building: 'Главный корпус',
floor: 1
}), '101');
});
test('боковая панель и слой под ней применяют backdrop blur', async () => {
const css = await readFile(new URL('../admin/css/auditorium-workload.css', import.meta.url), 'utf8');
assert.match(css, /\.schedule-override-backdrop\s*\{[^}]*backdrop-filter:\s*blur\(7px\)/s);
assert.match(css, /\.schedule-override-panel\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--bg-card\) 88%, transparent\)[^}]*backdrop-filter:\s*blur\(24px\)/s);
});

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findSemesterForDate,
scheduleRangeForSemester,
semesterOptionLabel
} from '../admin/js/views/schedule-view.js';
const autumnSemester = {
id: 11,
academicYearTitle: '2025/2026',
semesterType: 'autumn',
startDate: '2025-09-01',
endDate: '2026-01-31'
};
const springSemester = {
id: 12,
academicYearTitle: '2025/2026',
semesterType: 'spring',
startDate: '2026-02-09',
endDate: '2026-06-30'
};
test('семестр определяется по выбранной дате включительно с границами', () => {
const semesters = [autumnSemester, springSemester];
assert.equal(findSemesterForDate(semesters, '2025-09-01')?.id, 11);
assert.equal(findSemesterForDate(semesters, '2026-06-30')?.id, 12);
assert.equal(findSemesterForDate(semesters, '2026-07-01'), null);
});
test('при выборе другого семестра диапазон начинается с его первой учебной недели', () => {
assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), {
startDate: '2025-09-01',
endDate: '2025-09-14'
});
});
test('внутри выбранного семестра сохраняется текущая двухнедельная точка просмотра', () => {
assert.deepEqual(scheduleRangeForSemester('2026-03-18', springSemester), {
startDate: '2026-03-16',
endDate: '2026-03-29'
});
});
test('название семестра содержит учебный год, тип и границы', () => {
assert.equal(
semesterOptionLabel(springSemester),
'2025/2026 · Весенний семестр · 09.02.2026 — 30.06.2026'
);
});

View File

@@ -1,5 +1,5 @@
{ {
"generated_at": "2026-07-12T21:52:36.954007+00:00", "generated_at": "2026-07-21T20:46:50.611104+00:00",
"nodes": { "nodes": {
"backend_src_main_java_com_magistr_app_config_auth_authorizationinterceptor_authorizationinterceptor": { "backend_src_main_java_com_magistr_app_config_auth_authorizationinterceptor_authorizationinterceptor": {
"code_fingerprint": "70b01b3fa22e058ee997250c52cc0affee54ab37aa5f8cc7eb74411487f1bbbf", "code_fingerprint": "70b01b3fa22e058ee997250c52cc0affee54ab37aa5f8cc7eb74411487f1bbbf",
@@ -22,7 +22,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 2.75167804, "score": 2.237417033,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -38,7 +38,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.977626859, "score": 0.794918212,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -54,11 +54,27 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 0.822552197, "score": 0.668825448,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_config_datainitializer_datainitializer": {
"code_fingerprint": "",
"label": "DataInitializer",
"last": "2026-07-12T22:20:21.382611+00:00",
"provenance": [
{
"date": "2026-07-12T22:20:21.382611+00:00",
"outcome": "useful",
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
}
],
"score": 0.813472031,
"source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_config_tenant_configmapupdater_configmapupdater": { "backend_src_main_java_com_magistr_app_config_tenant_configmapupdater_configmapupdater": {
"code_fingerprint": "", "code_fingerprint": "",
"label": "ConfigMapUpdater", "label": "ConfigMapUpdater",
@@ -70,45 +86,81 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 0.951498983, "score": 0.773673374,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_config_tenant_tenantconfigwatcher_tenantconfigwatcher": { "backend_src_main_java_com_magistr_app_config_tenant_tenantconfig_tenantconfig": {
"code_fingerprint": "83dcf36dd5d7c969fa4d454909cc245145949738b462f569eb1367663e33492d", "code_fingerprint": "22d2e92c23a81959e7c20ad48ff92fdea8fb2bc37b0dda0dfd33d86eb0267e85",
"label": "TenantConfigWatcher", "label": "TenantConfig",
"last": "2026-07-10T18:14:03.213067+00:00", "last": "2026-07-12T22:09:47.294749+00:00",
"provenance": [ "provenance": [
{
"date": "2026-07-12T22:09:47.294749+00:00",
"outcome": "useful",
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
}
],
"score": 0.813334106,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_config_tenant_tenantconfigwatcher_tenantconfigwatcher": {
"code_fingerprint": "875d6a84e8dc1b8ada56a152aa08c9bfb54792851692909044ffe1477c2e78c8",
"label": "TenantConfigWatcher",
"last": "2026-07-12T22:20:21.382611+00:00",
"provenance": [
{
"date": "2026-07-12T22:20:21.382611+00:00",
"outcome": "useful",
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
},
{
"date": "2026-07-12T22:09:47.294749+00:00",
"outcome": "useful",
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
},
{ {
"date": "2026-07-10T18:14:03.213067+00:00", "date": "2026-07-10T18:14:03.213067+00:00",
"outcome": "useful", "outcome": "useful",
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 0.951498983, "score": 2.400479511,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 3
}, },
"backend_src_main_java_com_magistr_app_config_tenant_tenantroutingdatasource_tenantroutingdatasource": { "backend_src_main_java_com_magistr_app_config_tenant_tenantroutingdatasource_tenantroutingdatasource": {
"code_fingerprint": "2efea217f4236134681ee86d870bd0d5ffcd653e312637e115b50768ada975f0", "code_fingerprint": "4dbf5864ca471e92d143acb834ed61b9eb23e2cf2ebc3ab9ab0757eb2439e727",
"label": "TenantRoutingDataSource", "label": "TenantRoutingDataSource",
"last": "2026-07-11T22:22:23.259230+00:00", "last": "2026-07-12T22:20:21.382611+00:00",
"provenance": [ "provenance": [
{
"date": "2026-07-12T22:20:21.382611+00:00",
"outcome": "useful",
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
},
{
"date": "2026-07-12T22:09:47.294749+00:00",
"outcome": "useful",
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
},
{ {
"date": "2026-07-11T22:22:23.259230+00:00", "date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful", "outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.977626859, "score": 2.421724349,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 3
}, },
"backend_src_main_java_com_magistr_app_controller_databasecontroller": { "backend_src_main_java_com_magistr_app_controller_databasecontroller": {
"code_fingerprint": "d4e6c182c5a40917feb56354fde6f6c9764d95fd79dc72d4bc760b1fd2d24cdc", "code_fingerprint": "e888a1e23f2d0ae6993fc2d767f6a4ef026c935ff2300d7d0c91bb805c22017f",
"label": "DatabaseController.java", "label": "DatabaseController.java",
"last": "2026-07-11T22:22:23.259230+00:00", "last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [ "provenance": [
@@ -118,13 +170,34 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.977626859, "score": 0.794918212,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_controller_databasecontroller_databasecontroller": {
"code_fingerprint": "e888a1e23f2d0ae6993fc2d767f6a4ef026c935ff2300d7d0c91bb805c22017f",
"label": "DatabaseController",
"last": "2026-07-12T22:20:21.382611+00:00",
"provenance": [
{
"date": "2026-07-12T22:20:21.382611+00:00",
"outcome": "useful",
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
},
{
"date": "2026-07-12T22:09:47.294749+00:00",
"outcome": "useful",
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
}
],
"score": 1.626806137,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "preferred",
"uses": 2
},
"backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": { "backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": {
"code_fingerprint": "3fc674c29337df18a9494ada4a6a150b522f6f5fc1f044d003c33eb4272e380d", "code_fingerprint": "a637ea110ec1c5fef0c0a1d88cccbc54d56b3b6ca66a6c1d3659871bd5ff7f4e",
"label": "GlobalExceptionHandler", "label": "GlobalExceptionHandler",
"last": "2026-07-10T18:14:03.213067+00:00", "last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [ "provenance": [
@@ -134,29 +207,34 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 0.951498983, "score": 0.773673374,
"source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java", "source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_controller_schedulecontroller": { "backend_src_main_java_com_magistr_app_controller_schedulecontroller": {
"code_fingerprint": "74f022a62ea5440fb0c4c66b86df005e5e77f7f1fda8a4b718d95bd55104ca13", "code_fingerprint": "40af5947a24d994d92566c135abe5080eec04f5f266b40e04abfa3c5fac18a84",
"label": "ScheduleController.java", "label": "ScheduleController.java",
"last": "2026-07-11T22:22:23.259230+00:00", "last": "2026-07-21T19:52:13.631700+00:00",
"provenance": [ "provenance": [
{
"date": "2026-07-21T19:52:13.631700+00:00",
"outcome": "useful",
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
},
{ {
"date": "2026-07-11T22:22:23.259230+00:00", "date": "2026-07-11T22:22:23.259230+00:00",
"outcome": "useful", "outcome": "useful",
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.977626859, "score": 1.794042273,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 2
}, },
"backend_src_main_java_com_magistr_app_controller_scheduleoverridecontroller_scheduleoverridecontroller": { "backend_src_main_java_com_magistr_app_controller_scheduleoverridecontroller_scheduleoverridecontroller": {
"code_fingerprint": "b9f25c272cab98280e15616873c10e65380ba04b5f3dc4cbfa50f2c16b5429f1", "code_fingerprint": "91be9aa1507e774d0168c858ad5cde838f8d6650e2169fae81cc1d551cbc5ff9",
"label": "ScheduleOverrideController", "label": "ScheduleOverrideController",
"last": "2026-07-12T14:52:07.844079+00:00", "last": "2026-07-12T14:52:07.844079+00:00",
"provenance": [ "provenance": [
@@ -166,7 +244,7 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 0.993275993, "score": 0.807642679,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -187,7 +265,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 1.951435101, "score": 1.586731467,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -208,7 +286,7 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 1.823406413, "score": 1.482630056,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -224,13 +302,29 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.999936118, "score": 0.813058094,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_model_semester_semester": {
"code_fingerprint": "52546b552fff16ab841a63ea80fb806f6cc32edc0615ec53b3c002543d692f24",
"label": "Semester",
"last": "2026-07-21T19:52:13.631700+00:00",
"provenance": [
{
"date": "2026-07-21T19:52:13.631700+00:00",
"outcome": "useful",
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
}
],
"score": 0.999124061,
"source_file": "backend/src/main/java/com/magistr/app/model/Semester.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": { "backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": {
"code_fingerprint": "8a39b28d2b7d0d2ca60b188a5c9b97c71187b5845dd8bdc85206f117b9d4984b", "code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10",
"label": "SemesterRepository", "label": "SemesterRepository",
"last": "2026-07-12T21:48:38.060218+00:00", "last": "2026-07-12T21:48:38.060218+00:00",
"provenance": [ "provenance": [
@@ -240,26 +334,31 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.999936118, "score": 0.813058094,
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java", "source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_service_schedulequeryservice_schedulequeryservice": { "backend_src_main_java_com_magistr_app_service_schedulequeryservice_schedulequeryservice": {
"code_fingerprint": "47d0183b1249d194c9825046e0942a1867ad5c781ed90f376b975095e8807f68", "code_fingerprint": "96b20e57f1e9d75f8f148a809e40d5ad98038423922f21c1f2c080bca6f76926",
"label": "ScheduleQueryService", "label": "ScheduleQueryService",
"last": "2026-07-12T14:52:07.844079+00:00", "last": "2026-07-21T19:52:13.631700+00:00",
"provenance": [ "provenance": [
{
"date": "2026-07-21T19:52:13.631700+00:00",
"outcome": "useful",
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
},
{ {
"date": "2026-07-12T14:52:07.844079+00:00", "date": "2026-07-12T14:52:07.844079+00:00",
"outcome": "useful", "outcome": "useful",
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 0.993275993, "score": 1.80676674,
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java", "source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 2
}, },
"backend_src_test_java_com_magistr_app_model_scheduleruletest_scheduleruletest": { "backend_src_test_java_com_magistr_app_model_scheduleruletest_scheduleruletest": {
"code_fingerprint": "73cb9594bd5dc29ba9d9bde11e860d2c182b205b36062df87a8a8783611ffe79", "code_fingerprint": "73cb9594bd5dc29ba9d9bde11e860d2c182b205b36062df87a8a8783611ffe79",
@@ -272,13 +371,13 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 0.823470295, "score": 0.669571962,
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java", "source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"compose_backend_service": { "compose_backend_service": {
"code_fingerprint": "a175ccc6d907386f120574edb6296a6af416da683b869c7da71f60835f2daaa6", "code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9",
"label": "Backend-сервис Docker Compose", "label": "Backend-сервис Docker Compose",
"last": "2026-07-11T22:22:23.259230+00:00", "last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [ "provenance": [
@@ -288,7 +387,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.977626859, "score": 0.794918212,
"source_file": "compose.yaml", "source_file": "compose.yaml",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-07-21T19:52:13.631700+00:00"
question: "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
contributor: "graphify"
outcome: "useful"
source_nodes: ["Semester", "ScheduleController.java", "ScheduleQueryService"]
---
# Q: в дополнительные фильры просмотра расписаний нужно добавить выбор семестра
## Answer
Expanded from original query via graph vocab: schedule, semester, search, view, frontend, period, academic, calendar, admin. Дополнительные фильтры находятся в schedule-view; список семестров предоставлен через доступный ролям просмотра endpoint ScheduleController, а frontend переводит выбранный семестр в двухнедельный диапазон без запроса всего семестра.
## Outcome
- Signal: useful
## Source Nodes
- Semester
- ScheduleController.java
- ScheduleQueryService

View File

@@ -0,0 +1,23 @@
---
type: "explain"
date: "2026-07-21T20:49:48.084227+00:00"
question: "Почему в выборе аудитории показаны корпус и этаж?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["Classroom", "ClassroomController.java"]
---
# Q: Почему в выборе аудитории показаны корпус и этаж?
## Answer
Expanded from graph vocab: classroom, room, building, floor, name. Source inspection confirmed that Classroom has nullable building and floor fields, V1 creates matching columns, and ClassroomResponse returns them. The side panel loads rooms from /api/classrooms; its label formatter was the component that appended building and floor. The formatter now displays only Classroom.name.
## Outcome
- Signal: useful
## Source Nodes
- Classroom
- ClassroomController.java

View File

@@ -1,34 +1,38 @@
# Lessons # Lessons
_Auto-generated by `graphify reflect` from 7 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._ _Auto-generated by `graphify reflect` from 10 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
## Summary ## Summary
- 6 useful · 1 dead ends · 0 corrected · 0 unmarked - 9 useful · 1 dead ends · 0 corrected · 0 unmarked
## Lessons ## Lessons
**Preferred sources** — corroborated by ≥2 useful results; start here. **Preferred sources** — corroborated by ≥2 useful results; start here.
- `TenantRoutingDataSource` (3× useful)
- `TenantConfigWatcher` (3× useful)
- `AuthorizationInterceptor` (3× useful) - `AuthorizationInterceptor` (3× useful)
- `ScheduleQueryService` (2× useful)
- `ScheduleController.java` (2× useful)
- `DatabaseController` (2× useful)
- `ScheduleGeneratorService` (2× useful) - `ScheduleGeneratorService` (2× useful)
- `ScheduleRuleAdminController` (2× useful) - `ScheduleRuleAdminController` (2× useful)
- `ScheduleRule` (2× useful) - `ScheduleRule` (2× useful)
**Tentative** — useful in fewer than 2 results; verify before relying. **Tentative** — useful in fewer than 2 results; verify before relying.
- `Semester` (1× useful)
- `DataInitializer` (1× useful)
- `TenantConfig` (1× useful)
- `ScheduleRuleSlot` (1× useful) - `ScheduleRuleSlot` (1× useful)
- `SemesterRepository` (1× useful) - `SemesterRepository` (1× useful)
- `ScheduleOverrideController` (1× useful) - `ScheduleOverrideController` (1× useful)
- `ScheduleQueryService` (1× useful)
- `Backend-сервис Docker Compose` (1× useful) - `Backend-сервис Docker Compose` (1× useful)
- `DatabaseController.java` (1× useful) - `DatabaseController.java` (1× useful)
- `JwtTokenService` (1× useful) - `JwtTokenService` (1× useful)
- `ScheduleController.java` (1× useful)
- `TenantRoutingDataSource` (1× useful)
- `ConfigMapUpdater` (1× useful) - `ConfigMapUpdater` (1× useful)
- `GlobalExceptionHandler` (1× useful) - `GlobalExceptionHandler` (1× useful)
- `TenantConfigWatcher` (1× useful)
- `ScheduleRuleTest` (1× useful) - `ScheduleRuleTest` (1× useful)
- `RequireRoles` (1× useful) - `RequireRoles` (1× useful)