This commit is contained in:
@@ -2,7 +2,10 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ScheduleOverrideAvailabilityDto(
|
||||||
|
Long semesterId,
|
||||||
|
LocalDate semesterStartDate,
|
||||||
|
LocalDate semesterEndDate,
|
||||||
|
List<LocalDate> availableDates
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package com.magistr.app.dto;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.TimeSlot;
|
||||||
|
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||||
|
import com.magistr.app.model.TimeSlotScope;
|
||||||
|
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
|
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EffectiveTimeSlotService {
|
||||||
|
|
||||||
|
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||||
|
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||||
|
|
||||||
|
private final TimeSlotRepository timeSlotRepository;
|
||||||
|
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||||
|
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||||
|
|
||||||
|
public EffectiveTimeSlotService(TimeSlotRepository timeSlotRepository,
|
||||||
|
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||||
|
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||||
|
this.timeSlotRepository = timeSlotRepository;
|
||||||
|
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||||
|
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<TimeSlot> findEffective(LocalDate date) {
|
||||||
|
if (date == null) {
|
||||||
|
throw new IllegalArgumentException("Дата временной сетки обязательна");
|
||||||
|
}
|
||||||
|
TimeSlotScope defaultScope = defaultScope();
|
||||||
|
TimeSlotScope effectiveScope = effectiveScope(date, defaultScope);
|
||||||
|
List<TimeSlot> defaultSlots = timeSlotRepository
|
||||||
|
.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||||
|
if (Objects.equals(effectiveScope.getId(), defaultScope.getId())) {
|
||||||
|
return defaultSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||||
|
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||||
|
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||||
|
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||||
|
return new ArrayList<>(slotsByOrder.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isEffectiveOnDate(Long timeSlotId, LocalDate date) {
|
||||||
|
return timeSlotId != null && findEffective(date).stream()
|
||||||
|
.anyMatch(slot -> Objects.equals(slot.getId(), timeSlotId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSlotScope effectiveScope(LocalDate date, TimeSlotScope defaultScope) {
|
||||||
|
return dateAssignmentRepository.findByDate(date)
|
||||||
|
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||||
|
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||||
|
APPLY_MODE_WEEKDAY,
|
||||||
|
date.getDayOfWeek().getValue()))
|
||||||
|
.orElse(defaultScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSlotScope defaultScope() {
|
||||||
|
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.magistr.app.service;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Инварианты правил расписания
|
-- Инварианты правил расписания
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.model.AcademicYear;
|
||||||
|
import com.magistr.app.model.Semester;
|
||||||
|
import com.magistr.app.model.SemesterType;
|
||||||
|
import com.magistr.app.repository.SemesterRepository;
|
||||||
|
import com.magistr.app.service.ScheduleQueryService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ScheduleControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsSemestersForReadOnlyScheduleFilterInNewestFirstOrder() {
|
||||||
|
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||||
|
SemesterRepository semesterRepository = mock(SemesterRepository.class);
|
||||||
|
ScheduleController controller = new ScheduleController(scheduleQueryService, semesterRepository);
|
||||||
|
|
||||||
|
Semester autumn = semester(1L, SemesterType.autumn,
|
||||||
|
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 1, 31));
|
||||||
|
Semester spring = semester(2L, SemesterType.spring,
|
||||||
|
LocalDate.of(2026, 2, 9), LocalDate.of(2026, 6, 30));
|
||||||
|
when(semesterRepository.findAll()).thenReturn(List.of(autumn, spring));
|
||||||
|
|
||||||
|
var result = controller.getSemesters();
|
||||||
|
|
||||||
|
assertThat(result).extracting(item -> item.id()).containsExactly(2L, 1L);
|
||||||
|
assertThat(result.get(0).academicYearTitle()).isEqualTo("2025/2026");
|
||||||
|
assertThat(result.get(0).semesterType()).isEqualTo(SemesterType.spring);
|
||||||
|
assertThat(result.get(0).startDate()).isEqualTo(LocalDate.of(2026, 2, 9));
|
||||||
|
assertThat(result.get(0).endDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Semester semester(Long id, SemesterType type, LocalDate startDate, LocalDate endDate) {
|
||||||
|
AcademicYear academicYear = new AcademicYear();
|
||||||
|
academicYear.setId(10L);
|
||||||
|
academicYear.setTitle("2025/2026");
|
||||||
|
academicYear.setStartDate(LocalDate.of(2025, 9, 1));
|
||||||
|
academicYear.setEndDate(LocalDate.of(2026, 8, 31));
|
||||||
|
|
||||||
|
Semester semester = new Semester();
|
||||||
|
semester.setId(id);
|
||||||
|
semester.setAcademicYear(academicYear);
|
||||||
|
semester.setSemesterType(type);
|
||||||
|
semester.setStartDate(startDate);
|
||||||
|
semester.setEndDate(endDate);
|
||||||
|
return semester;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.magistr.app.controller;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
docs/API.md
84
docs/API.md
@@ -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"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Загруженность
|
## Загруженность
|
||||||
|
|
||||||
|
|||||||
@@ -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, поэтому два конкурентных переноса с разных исходных дат на одну целевую
|
||||||
|
дату и одинаковые ресурсы проверяются последовательно.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
|
|||||||
@@ -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/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
611
frontend/admin/js/views/schedule-override-panel.js
Normal file
611
frontend/admin/js/views/schedule-override-panel.js
Normal 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('-');
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
64
frontend/tests/schedule-overrides.test.mjs
Normal file
64
frontend/tests/schedule-overrides.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
53
frontend/tests/schedule-view-semesters.test.mjs
Normal file
53
frontend/tests/schedule-view-semesters.test.mjs
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user