This commit is contained in:
@@ -2,7 +2,10 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.SemesterDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -11,6 +14,8 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -21,9 +26,20 @@ public class ScheduleController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ScheduleController.class);
|
||||
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final SemesterRepository semesterRepository;
|
||||
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService) {
|
||||
public ScheduleController(ScheduleQueryService scheduleQueryService,
|
||||
SemesterRepository semesterRepository) {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.semesterRepository = semesterRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/semesters")
|
||||
public List<SemesterDto> getSemesters() {
|
||||
return semesterRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
|
||||
.map(this::toSemesterDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -54,4 +70,15 @@ public class ScheduleController {
|
||||
endDate
|
||||
));
|
||||
}
|
||||
|
||||
private SemesterDto toSemesterDto(Semester semester) {
|
||||
return new SemesterDto(
|
||||
semester.getId(),
|
||||
semester.getAcademicYear().getId(),
|
||||
semester.getAcademicYear().getTitle(),
|
||||
semester.getSemesterType(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleOverrideService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/edu-office/schedule/overrides")
|
||||
@@ -22,8 +24,19 @@ public class ScheduleOverrideController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleOverrideDto> getAll() {
|
||||
return scheduleOverrideService.getAll();
|
||||
public List<ScheduleOverrideDto> getAll(
|
||||
@RequestParam(required = false) LocalDate startDate,
|
||||
@RequestParam(required = false) LocalDate endDate
|
||||
) {
|
||||
return scheduleOverrideService.getAll(startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/availability")
|
||||
public ScheduleOverrideAvailabilityDto getAvailability(
|
||||
@RequestParam Long baseRuleSlotId,
|
||||
@RequestParam LocalDate lessonDate
|
||||
) {
|
||||
return scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import com.magistr.app.service.TimeSlotService;
|
||||
import com.magistr.app.service.EffectiveTimeSlotService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -35,15 +36,18 @@ public class TimeSlotAdminController {
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
private final TimeSlotService timeSlotService;
|
||||
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
|
||||
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository,
|
||||
TimeSlotService timeSlotService) {
|
||||
TimeSlotService timeSlotService,
|
||||
EffectiveTimeSlotService effectiveTimeSlotService) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
this.timeSlotService = timeSlotService;
|
||||
this.effectiveTimeSlotService = effectiveTimeSlotService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -55,7 +59,7 @@ public class TimeSlotAdminController {
|
||||
|
||||
@GetMapping("/effective")
|
||||
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
|
||||
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
|
||||
return ResponseEntity.ok(effectiveTimeSlotService.findEffective(date).stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
@@ -167,35 +171,6 @@ public class TimeSlotAdminController {
|
||||
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
|
||||
}
|
||||
|
||||
private List<TimeSlot> effectiveSlots(LocalDate date) {
|
||||
TimeSlotScope defaultScope = defaultScope();
|
||||
TimeSlotScope effectiveScope = effectiveScope(date);
|
||||
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||
if (effectiveScope.getId().equals(defaultScope.getId())) {
|
||||
return defaultSlots;
|
||||
}
|
||||
|
||||
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
return new ArrayList<>(slotsByOrder.values());
|
||||
}
|
||||
|
||||
private TimeSlotScope effectiveScope(LocalDate date) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()))
|
||||
.orElseGet(this::defaultScope);
|
||||
}
|
||||
|
||||
private TimeSlotScope defaultScope() {
|
||||
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||
}
|
||||
|
||||
private int nextManualDisplayOrder() {
|
||||
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
|
||||
.map(TimeSlotScope::getDisplayOrder)
|
||||
|
||||
@@ -37,6 +37,79 @@ public record RenderedLessonDto(
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
com.magistr.app.model.ScheduleParity ruleParity,
|
||||
Long scheduleOverrideId,
|
||||
String overrideAction,
|
||||
LocalDate originalLessonDate
|
||||
) {
|
||||
public RenderedLessonDto(
|
||||
Long scheduleRuleId,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate date,
|
||||
Integer dayOfWeek,
|
||||
String dayName,
|
||||
Integer weekNumber,
|
||||
ScheduleParity parity,
|
||||
Long timeSlotId,
|
||||
Integer timeSlotOrder,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
String lessonFormat,
|
||||
Long subgroupId,
|
||||
String subgroupName,
|
||||
List<Long> subgroupIds,
|
||||
List<String> subgroupNames,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson,
|
||||
com.magistr.app.model.ScheduleParity ruleParity
|
||||
) {
|
||||
this(
|
||||
scheduleRuleId,
|
||||
scheduleRuleSlotId,
|
||||
date,
|
||||
dayOfWeek,
|
||||
dayName,
|
||||
weekNumber,
|
||||
parity,
|
||||
timeSlotId,
|
||||
timeSlotOrder,
|
||||
startTime,
|
||||
endTime,
|
||||
subjectId,
|
||||
subjectName,
|
||||
teacherId,
|
||||
teacherName,
|
||||
classroomId,
|
||||
classroomName,
|
||||
lessonTypeId,
|
||||
lessonTypeName,
|
||||
lessonFormat,
|
||||
subgroupId,
|
||||
subgroupName,
|
||||
subgroupIds,
|
||||
subgroupNames,
|
||||
groupIds,
|
||||
groupNames,
|
||||
activityType,
|
||||
lessonTypeAcademicHours,
|
||||
consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
remainingLessonTypeAcademicHoursAfterLesson,
|
||||
ruleParity,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleOverrideAvailabilityDto(
|
||||
Long semesterId,
|
||||
LocalDate semesterStartDate,
|
||||
LocalDate semesterEndDate,
|
||||
List<LocalDate> availableDates
|
||||
) {
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
@@ -14,6 +17,109 @@ public record ScheduleOverrideDto(
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
Instant createdAt
|
||||
Instant createdAt,
|
||||
Long semesterId,
|
||||
LocalDate semesterStartDate,
|
||||
LocalDate semesterEndDate,
|
||||
Long sourceTimeSlotId,
|
||||
Integer sourceTimeSlotOrder,
|
||||
LocalTime sourceStartTime,
|
||||
LocalTime sourceEndTime,
|
||||
Long sourceTeacherId,
|
||||
String sourceTeacherName,
|
||||
Long sourceClassroomId,
|
||||
String sourceClassroomName,
|
||||
String sourceLessonFormat,
|
||||
String subjectName,
|
||||
String lessonTypeName,
|
||||
List<String> groupNames
|
||||
) {
|
||||
public ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
Instant createdAt
|
||||
) {
|
||||
this(
|
||||
id,
|
||||
baseRuleSlotId,
|
||||
lessonDate,
|
||||
targetLessonDate,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
newLessonFormat,
|
||||
comment,
|
||||
createdBy,
|
||||
createdAt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
public ScheduleOverrideDto(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String newLessonFormat,
|
||||
String comment,
|
||||
Long createdBy,
|
||||
Instant createdAt
|
||||
) {
|
||||
this(
|
||||
id,
|
||||
baseRuleSlotId,
|
||||
lessonDate,
|
||||
null,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
newLessonFormat,
|
||||
comment,
|
||||
createdBy,
|
||||
createdAt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public class ScheduleOverride {
|
||||
@Column(name = "lesson_date", nullable = false)
|
||||
private LocalDate lessonDate;
|
||||
|
||||
@Column(name = "target_lesson_date")
|
||||
private LocalDate targetLessonDate;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String action;
|
||||
|
||||
@@ -67,6 +70,14 @@ public class ScheduleOverride {
|
||||
this.lessonDate = lessonDate;
|
||||
}
|
||||
|
||||
public LocalDate getTargetLessonDate() {
|
||||
return targetLessonDate;
|
||||
}
|
||||
|
||||
public void setTargetLessonDate(LocalDate targetLessonDate) {
|
||||
this.targetLessonDate = targetLessonDate;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -17,10 +18,13 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
select o
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
""")
|
||||
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@@ -29,8 +33,62 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
|
||||
Optional<ScheduleOverride> findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate);
|
||||
|
||||
@Query("select o.lessonDate from ScheduleOverride o where o.id = :id")
|
||||
Optional<LocalDate> findLessonDateById(@Param("id") Long id);
|
||||
@Query("""
|
||||
select distinct o
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
join fetch baseSlot.timeSlot
|
||||
join fetch baseSlot.teacher
|
||||
join fetch baseSlot.classroom
|
||||
join fetch baseSlot.lessonType
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findAllWithRegistryDetails();
|
||||
|
||||
@Query("""
|
||||
select distinct o
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
join fetch baseSlot.timeSlot
|
||||
join fetch baseSlot.teacher
|
||||
join fetch baseSlot.classroom
|
||||
join fetch baseSlot.lessonType
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findForRegistry(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select o
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate in :dates
|
||||
or o.targetLessonDate in :dates
|
||||
""")
|
||||
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select o from ScheduleOverride o where o.id = :id")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.TimeSlotDateAssignment;
|
||||
import com.magistr.app.model.TimeSlotScope;
|
||||
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.TimeSlotScopeRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class EffectiveTimeSlotService {
|
||||
|
||||
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
|
||||
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
|
||||
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final TimeSlotScopeRepository timeSlotScopeRepository;
|
||||
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
|
||||
|
||||
public EffectiveTimeSlotService(TimeSlotRepository timeSlotRepository,
|
||||
TimeSlotScopeRepository timeSlotScopeRepository,
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.timeSlotScopeRepository = timeSlotScopeRepository;
|
||||
this.dateAssignmentRepository = dateAssignmentRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TimeSlot> findEffective(LocalDate date) {
|
||||
if (date == null) {
|
||||
throw new IllegalArgumentException("Дата временной сетки обязательна");
|
||||
}
|
||||
TimeSlotScope defaultScope = defaultScope();
|
||||
TimeSlotScope effectiveScope = effectiveScope(date, defaultScope);
|
||||
List<TimeSlot> defaultSlots = timeSlotRepository
|
||||
.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
|
||||
if (Objects.equals(effectiveScope.getId(), defaultScope.getId())) {
|
||||
return defaultSlots;
|
||||
}
|
||||
|
||||
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
|
||||
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
|
||||
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
|
||||
return new ArrayList<>(slotsByOrder.values());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isEffectiveOnDate(Long timeSlotId, LocalDate date) {
|
||||
return timeSlotId != null && findEffective(date).stream()
|
||||
.anyMatch(slot -> Objects.equals(slot.getId(), timeSlotId));
|
||||
}
|
||||
|
||||
private TimeSlotScope effectiveScope(LocalDate date, TimeSlotScope defaultScope) {
|
||||
return dateAssignmentRepository.findByDate(date)
|
||||
.map(TimeSlotDateAssignment::getTimeSlotScope)
|
||||
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
|
||||
APPLY_MODE_WEEKDAY,
|
||||
date.getDayOfWeek().getValue()))
|
||||
.orElse(defaultScope);
|
||||
}
|
||||
|
||||
private TimeSlotScope defaultScope() {
|
||||
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
|
||||
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Role;
|
||||
@@ -17,9 +18,11 @@ import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
@@ -31,7 +34,9 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -49,7 +54,10 @@ public class ScheduleOverrideService {
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final PostgreSqlAdvisoryLock advisoryLock;
|
||||
private final AcademicDateService academicDateService;
|
||||
private final EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
|
||||
@Autowired
|
||||
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
@@ -58,7 +66,9 @@ public class ScheduleOverrideService {
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
PostgreSqlAdvisoryLock advisoryLock) {
|
||||
PostgreSqlAdvisoryLock advisoryLock,
|
||||
AcademicDateService academicDateService,
|
||||
EffectiveTimeSlotService effectiveTimeSlotService) {
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
@@ -68,42 +78,124 @@ public class ScheduleOverrideService {
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.advisoryLock = advisoryLock;
|
||||
this.academicDateService = academicDateService;
|
||||
this.effectiveTimeSlotService = effectiveTimeSlotService;
|
||||
}
|
||||
|
||||
ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
UserRepository userRepository,
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
PostgreSqlAdvisoryLock advisoryLock) {
|
||||
this(
|
||||
scheduleOverrideRepository,
|
||||
scheduleRuleSlotRepository,
|
||||
timeSlotRepository,
|
||||
classroomRepository,
|
||||
userRepository,
|
||||
subgroupRepository,
|
||||
scheduleQueryService,
|
||||
scheduleGeneratorService,
|
||||
advisoryLock,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
|
||||
if ((startDate == null) != (endDate == null)) {
|
||||
throw new IllegalArgumentException("Дата начала и дата окончания должны быть указаны вместе");
|
||||
}
|
||||
if (startDate != null) {
|
||||
validateRange(startDate, endDate);
|
||||
}
|
||||
List<ScheduleOverride> overrides = startDate == null
|
||||
? scheduleOverrideRepository.findAllWithRegistryDetails()
|
||||
: scheduleOverrideRepository.findForRegistry(startDate, endDate);
|
||||
return overrides.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleOverrideDto> getAll() {
|
||||
return scheduleOverrideRepository.findAll().stream()
|
||||
.map(this::toDto)
|
||||
return getAll(null, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ScheduleOverrideAvailabilityDto getAvailability(Long baseRuleSlotId, LocalDate lessonDate) {
|
||||
if (baseRuleSlotId == null || lessonDate == null) {
|
||||
throw new IllegalArgumentException("Базовый слот и исходная дата пары обязательны");
|
||||
}
|
||||
ScheduleRuleSlot slot = scheduleRuleSlotRepository.findById(baseRuleSlotId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
|
||||
ensureSourceOccurrenceExists(baseRuleSlotId, lessonDate);
|
||||
var rule = slot.getScheduleRule();
|
||||
if (rule == null || rule.getSemester() == null) {
|
||||
throw new IllegalArgumentException("Для базового слота не найден семестр");
|
||||
}
|
||||
var semester = rule.getSemester();
|
||||
if (lessonDate.isBefore(semester.getStartDate()) || lessonDate.isAfter(semester.getEndDate())) {
|
||||
throw new IllegalArgumentException("Исходная дата пары находится вне семестра правила");
|
||||
}
|
||||
if (academicDateService == null) {
|
||||
throw new IllegalStateException("Сервис учебных дат не настроен");
|
||||
}
|
||||
List<com.magistr.app.model.StudentGroup> groups = rule.getGroups().stream()
|
||||
.sorted(Comparator.comparing(com.magistr.app.model.StudentGroup::getName))
|
||||
.toList();
|
||||
var snapshot = academicDateService.createScheduleSnapshot(
|
||||
groups,
|
||||
List.of(semester),
|
||||
semester.getEndDate()
|
||||
);
|
||||
List<LocalDate> availableDates = semester.getStartDate()
|
||||
.datesUntil(semester.getEndDate().plusDays(1))
|
||||
.filter(date -> rule.isActiveOn(date))
|
||||
.filter(date -> rule.getSubject().isActiveOn(date))
|
||||
.filter(date -> groups.stream().allMatch(group -> group.isActiveOn(date)
|
||||
&& snapshot.isTheoryDay(group, semester, date)))
|
||||
.toList();
|
||||
return new ScheduleOverrideAvailabilityDto(
|
||||
semester.getId(),
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate(),
|
||||
availableDates
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleOverrideDto create(ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
advisoryLock.lockScheduleOverrideDate(command.lessonDate());
|
||||
lockDatesInStableOrder(command.lessonDate(), command.effectiveDate());
|
||||
|
||||
ScheduleOverride current = scheduleOverrideRepository
|
||||
scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
|
||||
.orElse(null);
|
||||
return validateAndPersist(current, command);
|
||||
.ifPresent(existing -> {
|
||||
throw new ScheduleConflictException(
|
||||
"Для выбранной базовой пары и даты уже существует точечное изменение"
|
||||
);
|
||||
});
|
||||
return validateAndPersist(null, command);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
advisoryLock.lockScheduleOverrideId(id);
|
||||
|
||||
LocalDate observedOldDate = scheduleOverrideRepository.findLessonDateById(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
lockDatesInStableOrder(observedOldDate, command.lessonDate());
|
||||
|
||||
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
if (!Objects.equals(observedOldDate, current.getLessonDate())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
|
||||
);
|
||||
}
|
||||
lockDatesInStableOrder(
|
||||
current.getLessonDate(),
|
||||
effectiveDate(current),
|
||||
command.lessonDate(),
|
||||
command.effectiveDate()
|
||||
);
|
||||
|
||||
scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDateForUpdate(command.baseRuleSlotId(), command.lessonDate())
|
||||
@@ -119,24 +211,15 @@ public class ScheduleOverrideService {
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
advisoryLock.lockScheduleOverrideId(id);
|
||||
LocalDate observedDate = scheduleOverrideRepository.findLessonDateById(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
advisoryLock.lockScheduleOverrideDate(observedDate);
|
||||
|
||||
ScheduleOverride current = scheduleOverrideRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
if (!Objects.equals(observedDate, current.getLessonDate())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Точечное изменение расписания было изменено параллельно. Повторите запрос"
|
||||
);
|
||||
}
|
||||
lockDatesInStableOrder(current.getLessonDate(), effectiveDate(current));
|
||||
scheduleOverrideRepository.delete(current);
|
||||
scheduleOverrideRepository.flush();
|
||||
scheduleGeneratorService.clearCache();
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto validateAndPersist(ScheduleOverride current, OverrideCommand command) {
|
||||
ResolvedReferences references = resolveReferences(command);
|
||||
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
|
||||
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
|
||||
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
|
||||
@@ -147,6 +230,8 @@ public class ScheduleOverrideService {
|
||||
"На выбранную дату базовая пара не формируется по правилам расписания"
|
||||
);
|
||||
}
|
||||
ResolvedReferences references = resolveReferences(command, sourceOccurrences);
|
||||
validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);
|
||||
|
||||
ScheduleOverride candidate = buildCandidate(command, references);
|
||||
if (!"CANCEL".equals(command.action())) {
|
||||
@@ -169,22 +254,34 @@ public class ScheduleOverrideService {
|
||||
List<RenderedLessonDto> baseDay,
|
||||
List<RenderedLessonDto> sourceOccurrences) {
|
||||
Long currentId = current == null ? null : current.getId();
|
||||
List<ScheduleOverride> effectiveOverrides = scheduleOverrideRepository
|
||||
.findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate())
|
||||
Set<LocalDate> affectedDates = new TreeSet<>(List.of(command.lessonDate(), command.effectiveDate()));
|
||||
List<ScheduleOverride> storedOverrides = affectedDates.size() == 1
|
||||
? scheduleOverrideRepository.findByLessonDateBetweenWithDetails(
|
||||
command.lessonDate(),
|
||||
command.lessonDate()
|
||||
)
|
||||
: scheduleOverrideRepository.findAffectingDatesWithDetails(affectedDates);
|
||||
List<ScheduleOverride> effectiveOverrides = Optional.ofNullable(storedOverrides)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(override -> currentId == null || !Objects.equals(override.getId(), currentId))
|
||||
.filter(override -> !sameKey(override, command.baseRuleSlotId(), command.lessonDate()))
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
effectiveOverrides.add(candidate);
|
||||
|
||||
List<RenderedLessonDto> effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups(
|
||||
baseDay,
|
||||
command.lessonDate(),
|
||||
effectiveOverrides
|
||||
);
|
||||
List<RenderedLessonDto> candidateOccurrences = effectiveDay.stream()
|
||||
List<RenderedLessonDto> effectiveSchedule = Objects.equals(command.lessonDate(), command.effectiveDate())
|
||||
? scheduleQueryService.buildEffectiveDayForAllGroups(
|
||||
baseDay,
|
||||
command.lessonDate(),
|
||||
effectiveOverrides
|
||||
)
|
||||
: scheduleQueryService.buildEffectiveDaysForAllGroups(affectedDates, effectiveOverrides);
|
||||
List<RenderedLessonDto> candidateOccurrences = effectiveSchedule.stream()
|
||||
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
|
||||
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
|
||||
.filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
|
||||
.filter(lesson -> Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|
||||
|| (Objects.equals(command.lessonDate(), command.effectiveDate())
|
||||
&& lesson.originalLessonDate() == null))
|
||||
.toList();
|
||||
if (candidateOccurrences.isEmpty()) {
|
||||
throw new IllegalArgumentException("Точечное изменение не формирует результирующую пару");
|
||||
@@ -203,13 +300,22 @@ public class ScheduleOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
List<RenderedLessonDto> otherOccurrences = effectiveDay.stream()
|
||||
.filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
|
||||
List<RenderedLessonDto> otherOccurrences = effectiveSchedule.stream()
|
||||
.filter(lesson -> !isCandidateOccurrence(lesson, command))
|
||||
.filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate()))
|
||||
.toList();
|
||||
Map<Long, Long> subgroupToGroup = loadSubgroupGroupMap(candidateOccurrences, otherOccurrences);
|
||||
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
|
||||
}
|
||||
|
||||
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
|
||||
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
|
||||
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
|
||||
|| (Objects.equals(command.lessonDate(), command.effectiveDate())
|
||||
&& lesson.originalLessonDate() == null))
|
||||
&& Objects.equals(lesson.date(), command.effectiveDate());
|
||||
}
|
||||
|
||||
private void validateActionSpecificChange(String action,
|
||||
List<RenderedLessonDto> sourceOccurrences,
|
||||
List<RenderedLessonDto> candidateOccurrences) {
|
||||
@@ -222,7 +328,7 @@ public class ScheduleOverrideService {
|
||||
.collect(Collectors.toSet());
|
||||
if (source.equals(candidate)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Перенос должен фактически менять время пары или аудиторию"
|
||||
"Перенос должен фактически менять дату, время пары или аудиторию"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -331,7 +437,8 @@ public class ScheduleOverrideService {
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private ResolvedReferences resolveReferences(OverrideCommand command) {
|
||||
private ResolvedReferences resolveReferences(OverrideCommand command,
|
||||
List<RenderedLessonDto> sourceOccurrences) {
|
||||
ScheduleRuleSlot baseRuleSlot = scheduleRuleSlotRepository.findById(command.baseRuleSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
|
||||
TimeSlot newTimeSlot = command.newTimeSlotId() == null
|
||||
@@ -347,22 +454,73 @@ public class ScheduleOverrideService {
|
||||
: userRepository.findById(command.newTeacherId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
|
||||
if (newTeacher != null
|
||||
&& (newTeacher.getRole() != Role.TEACHER || !newTeacher.isActiveOn(command.lessonDate()))) {
|
||||
if ("CANCEL".equals(command.action())) {
|
||||
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
|
||||
}
|
||||
|
||||
LocalDate effectiveDate = command.effectiveDate();
|
||||
User effectiveTeacher = newTeacher == null ? baseRuleSlot.getTeacher() : newTeacher;
|
||||
Classroom effectiveClassroom = newClassroom == null ? baseRuleSlot.getClassroom() : newClassroom;
|
||||
if (effectiveTeacher == null
|
||||
|| effectiveTeacher.getRole() != Role.TEACHER
|
||||
|| !effectiveTeacher.isActiveOn(effectiveDate)) {
|
||||
throw new IllegalArgumentException("Активный преподаватель с ролью TEACHER не найден");
|
||||
}
|
||||
if (newClassroom != null
|
||||
&& (!newClassroom.isActiveOn(command.lessonDate())
|
||||
|| !Boolean.TRUE.equals(newClassroom.getIsAvailable()))) {
|
||||
if (effectiveClassroom == null
|
||||
|| !effectiveClassroom.isActiveOn(effectiveDate)
|
||||
|| !Boolean.TRUE.equals(effectiveClassroom.getIsAvailable())) {
|
||||
throw new IllegalArgumentException("Активная и доступная аудитория не найдена");
|
||||
}
|
||||
if (newTimeSlot != null
|
||||
&& effectiveTimeSlotService != null
|
||||
&& !effectiveTimeSlotService.isEffectiveOnDate(newTimeSlot.getId(), effectiveDate)) {
|
||||
throw new IllegalArgumentException("Выбранный временной слот не действует на целевую дату");
|
||||
}
|
||||
return new ResolvedReferences(baseRuleSlot, newTimeSlot, newClassroom, newTeacher);
|
||||
}
|
||||
|
||||
private void validateTargetDate(OverrideCommand command,
|
||||
ScheduleRuleSlot baseRuleSlot,
|
||||
List<RenderedLessonDto> sourceOccurrences) {
|
||||
if (command.targetLessonDate() == null) {
|
||||
return;
|
||||
}
|
||||
if (academicDateService == null) {
|
||||
throw new IllegalStateException("Сервис учебных дат не настроен");
|
||||
}
|
||||
if (baseRuleSlot.getScheduleRule() == null
|
||||
|| baseRuleSlot.getScheduleRule().getSemester() == null) {
|
||||
throw new IllegalArgumentException("Для базового слота не найден семестр");
|
||||
}
|
||||
var rule = baseRuleSlot.getScheduleRule();
|
||||
var semester = rule.getSemester();
|
||||
LocalDate targetDate = command.targetLessonDate();
|
||||
if (targetDate.isBefore(semester.getStartDate()) || targetDate.isAfter(semester.getEndDate())) {
|
||||
throw new IllegalArgumentException("Перенести занятие можно только в пределах того же семестра");
|
||||
}
|
||||
if (!rule.isActiveOn(targetDate) || !rule.getSubject().isActiveOn(targetDate)) {
|
||||
throw new IllegalArgumentException("Правило или дисциплина не действуют на целевую дату");
|
||||
}
|
||||
Set<Long> affectedGroupIds = sourceOccurrences.stream()
|
||||
.flatMap(lesson -> safeIds(lesson.groupIds()).stream())
|
||||
.collect(Collectors.toSet());
|
||||
List<com.magistr.app.model.StudentGroup> affectedGroups = rule.getGroups().stream()
|
||||
.filter(group -> affectedGroupIds.contains(group.getId()))
|
||||
.toList();
|
||||
if (affectedGroups.size() != affectedGroupIds.size()
|
||||
|| affectedGroups.stream().anyMatch(group -> !group.isActiveOn(targetDate)
|
||||
|| !academicDateService.isTheoryDay(group, semester, targetDate))) {
|
||||
throw new IllegalArgumentException(
|
||||
"Целевая дата должна быть учебной для всех групп исходного занятия"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduleOverride buildCandidate(OverrideCommand command, ResolvedReferences references) {
|
||||
ScheduleOverride candidate = new ScheduleOverride();
|
||||
candidate.setBaseRuleSlot(references.baseRuleSlot());
|
||||
candidate.setLessonDate(command.lessonDate());
|
||||
candidate.setTargetLessonDate(command.targetLessonDate());
|
||||
candidate.setAction(command.action());
|
||||
candidate.setNewTimeSlot(references.newTimeSlot());
|
||||
candidate.setNewClassroom(references.newClassroom());
|
||||
@@ -374,6 +532,7 @@ public class ScheduleOverrideService {
|
||||
private void copyCandidate(ScheduleOverride source, ScheduleOverride target) {
|
||||
target.setBaseRuleSlot(source.getBaseRuleSlot());
|
||||
target.setLessonDate(source.getLessonDate());
|
||||
target.setTargetLessonDate(source.getTargetLessonDate());
|
||||
target.setAction(source.getAction());
|
||||
target.setNewTimeSlot(source.getNewTimeSlot());
|
||||
target.setNewClassroom(source.getNewClassroom());
|
||||
@@ -397,21 +556,34 @@ public class ScheduleOverrideService {
|
||||
throw new IllegalArgumentException("Недопустимое действие изменения расписания");
|
||||
}
|
||||
String lessonFormat = clean(request.newLessonFormat());
|
||||
LocalDate targetLessonDate = request.targetLessonDate();
|
||||
if (lessonFormat != null && !LESSON_FORMATS.contains(lessonFormat)) {
|
||||
throw new IllegalArgumentException("Формат пары должен быть «Очно» или «Онлайн»");
|
||||
}
|
||||
|
||||
if ("CANCEL".equals(action)
|
||||
&& (request.newTimeSlotId() != null
|
||||
&& (targetLessonDate != null
|
||||
|| request.newTimeSlotId() != null
|
||||
|| request.newClassroomId() != null
|
||||
|| request.newTeacherId() != null
|
||||
|| lessonFormat != null)) {
|
||||
throw new IllegalArgumentException("Для отмены пары новые параметры должны быть пустыми");
|
||||
}
|
||||
if (targetLessonDate != null && Objects.equals(targetLessonDate, request.lessonDate())) {
|
||||
throw new IllegalArgumentException("Целевая дата должна отличаться от исходной даты пары");
|
||||
}
|
||||
if (targetLessonDate != null && request.newTimeSlotId() == null) {
|
||||
throw new IllegalArgumentException("Для переноса даты выберите временной слот целевого дня");
|
||||
}
|
||||
if (targetLessonDate != null && !"MOVE".equals(action)) {
|
||||
throw new IllegalArgumentException("Перенос даты должен использовать действие MOVE");
|
||||
}
|
||||
if ("MOVE".equals(action)
|
||||
&& request.newTimeSlotId() == null
|
||||
&& request.newClassroomId() == null) {
|
||||
throw new IllegalArgumentException("Для переноса укажите новое время или аудиторию");
|
||||
&& request.newTimeSlotId() == null) {
|
||||
throw new IllegalArgumentException("Для переноса укажите новое время");
|
||||
}
|
||||
if ("REPLACE".equals(action) && request.newTimeSlotId() != null) {
|
||||
throw new IllegalArgumentException("Изменение времени должно использовать действие MOVE");
|
||||
}
|
||||
if ("REPLACE".equals(action)
|
||||
&& request.newTeacherId() == null
|
||||
@@ -424,6 +596,7 @@ public class ScheduleOverrideService {
|
||||
return new OverrideCommand(
|
||||
request.baseRuleSlotId(),
|
||||
request.lessonDate(),
|
||||
targetLessonDate,
|
||||
action,
|
||||
request.newTimeSlotId(),
|
||||
request.newClassroomId(),
|
||||
@@ -433,8 +606,9 @@ public class ScheduleOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
private void lockDatesInStableOrder(LocalDate first, LocalDate second) {
|
||||
List.of(first, second).stream()
|
||||
private void lockDatesInStableOrder(LocalDate... dates) {
|
||||
java.util.Arrays.stream(dates)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.forEach(advisoryLock::lockScheduleOverrideDate);
|
||||
@@ -446,15 +620,55 @@ public class ScheduleOverrideService {
|
||||
&& Objects.equals(override.getLessonDate(), lessonDate);
|
||||
}
|
||||
|
||||
private LocalDate effectiveDate(ScheduleOverride override) {
|
||||
return override.getTargetLessonDate() == null
|
||||
? override.getLessonDate()
|
||||
: override.getTargetLessonDate();
|
||||
}
|
||||
|
||||
private void ensureSourceOccurrenceExists(Long baseRuleSlotId, LocalDate lessonDate) {
|
||||
boolean exists = scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream()
|
||||
.anyMatch(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId)
|
||||
&& Objects.equals(lesson.date(), lessonDate));
|
||||
if (!exists) {
|
||||
throw new IllegalArgumentException(
|
||||
"На выбранную дату базовая пара не формируется по правилам расписания"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
|
||||
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
|
||||
}
|
||||
}
|
||||
|
||||
private NoSuchElementException notFound(Long id) {
|
||||
return new NoSuchElementException("Точечное изменение расписания с идентификатором " + id + " не найдено");
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto toDto(ScheduleOverride override) {
|
||||
ScheduleRuleSlot baseSlot = override.getBaseRuleSlot();
|
||||
var rule = baseSlot == null ? null : baseSlot.getScheduleRule();
|
||||
var semester = rule == null ? null : rule.getSemester();
|
||||
TimeSlot sourceTimeSlot = baseSlot == null ? null : baseSlot.getTimeSlot();
|
||||
User sourceTeacher = baseSlot == null ? null : baseSlot.getTeacher();
|
||||
Classroom sourceClassroom = baseSlot == null ? null : baseSlot.getClassroom();
|
||||
List<String> groupNames = rule == null || rule.getGroups() == null
|
||||
? List.of()
|
||||
: rule.getGroups().stream()
|
||||
.map(com.magistr.app.model.StudentGroup::getName)
|
||||
.filter(Objects::nonNull)
|
||||
.sorted()
|
||||
.toList();
|
||||
return new ScheduleOverrideDto(
|
||||
override.getId(),
|
||||
override.getBaseRuleSlot().getId(),
|
||||
baseSlot == null ? null : baseSlot.getId(),
|
||||
override.getLessonDate(),
|
||||
override.getTargetLessonDate(),
|
||||
override.getAction(),
|
||||
override.getNewTimeSlot() == null ? null : override.getNewTimeSlot().getId(),
|
||||
override.getNewClassroom() == null ? null : override.getNewClassroom().getId(),
|
||||
@@ -462,7 +676,24 @@ public class ScheduleOverrideService {
|
||||
override.getNewLessonFormat(),
|
||||
override.getComment(),
|
||||
override.getCreatedBy(),
|
||||
override.getCreatedAt()
|
||||
override.getCreatedAt(),
|
||||
semester == null ? null : semester.getId(),
|
||||
semester == null ? null : semester.getStartDate(),
|
||||
semester == null ? null : semester.getEndDate(),
|
||||
sourceTimeSlot == null ? null : sourceTimeSlot.getId(),
|
||||
sourceTimeSlot == null ? null : sourceTimeSlot.getOrderNumber(),
|
||||
sourceTimeSlot == null ? null : sourceTimeSlot.getStartTime(),
|
||||
sourceTimeSlot == null ? null : sourceTimeSlot.getEndTime(),
|
||||
sourceTeacher == null ? null : sourceTeacher.getId(),
|
||||
sourceTeacher == null ? null : ScheduleGeneratorService.displayUserName(sourceTeacher),
|
||||
sourceClassroom == null ? null : sourceClassroom.getId(),
|
||||
sourceClassroom == null ? null : sourceClassroom.getName(),
|
||||
baseSlot == null ? null : baseSlot.getLessonFormat(),
|
||||
rule == null || rule.getSubject() == null ? null : rule.getSubject().getName(),
|
||||
baseSlot == null || baseSlot.getLessonType() == null
|
||||
? null
|
||||
: baseSlot.getLessonType().getLessonType(),
|
||||
groupNames
|
||||
);
|
||||
}
|
||||
|
||||
@@ -473,6 +704,7 @@ public class ScheduleOverrideService {
|
||||
private record OverrideCommand(
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
@@ -480,6 +712,9 @@ public class ScheduleOverrideService {
|
||||
String newLessonFormat,
|
||||
String comment
|
||||
) {
|
||||
private LocalDate effectiveDate() {
|
||||
return targetLessonDate == null ? lessonDate : targetLessonDate;
|
||||
}
|
||||
}
|
||||
|
||||
private record ResolvedReferences(
|
||||
@@ -491,6 +726,7 @@ public class ScheduleOverrideService {
|
||||
}
|
||||
|
||||
private record EffectiveSourceTuple(
|
||||
LocalDate date,
|
||||
Long timeSlotId,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
@@ -500,6 +736,7 @@ public class ScheduleOverrideService {
|
||||
) {
|
||||
private static EffectiveSourceTuple from(RenderedLessonDto lesson) {
|
||||
return new EffectiveSourceTuple(
|
||||
lesson.date(),
|
||||
lesson.timeSlotId(),
|
||||
lesson.startTime(),
|
||||
lesson.endTime(),
|
||||
@@ -511,12 +748,13 @@ public class ScheduleOverrideService {
|
||||
}
|
||||
|
||||
private record MoveTuple(
|
||||
LocalDate date,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long classroomId
|
||||
) {
|
||||
private static MoveTuple from(RenderedLessonDto lesson) {
|
||||
return new MoveTuple(lesson.startTime(), lesson.endTime(), lesson.classroomId());
|
||||
return new MoveTuple(lesson.date(), lesson.startTime(), lesson.endTime(), lesson.classroomId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -22,15 +23,26 @@ public class ScheduleQueryService {
|
||||
private final GroupRepository groupRepository;
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
private final AcademicDateService academicDateService;
|
||||
|
||||
@Autowired
|
||||
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
StudentGroupLifecycleService groupLifecycleService,
|
||||
AcademicDateService academicDateService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
this.academicDateService = academicDateService;
|
||||
}
|
||||
|
||||
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +87,31 @@ public class ScheduleQueryService {
|
||||
return deduplicate(applyOverrides(safeBaseDay, date, overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит несколько эффективных дней с учётом входящих и исходящих переносов.
|
||||
* Исходная пара достраивается даже тогда, когда её дата не входит в результирующий набор.
|
||||
*/
|
||||
public List<RenderedLessonDto> buildEffectiveDaysForAllGroups(
|
||||
Collection<LocalDate> dates,
|
||||
Collection<ScheduleOverride> overrides
|
||||
) {
|
||||
Set<LocalDate> requestedDates = Optional.ofNullable(dates)
|
||||
.orElseGet(List::of)
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
if (requestedDates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<RenderedLessonDto> baseLessons = requestedDates.stream()
|
||||
.flatMap(date -> buildBaseDayForAllGroups(date).stream())
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
addOverrideSourceOccurrences(baseLessons, overrides, null);
|
||||
return deduplicate(applyOverrides(baseLessons, null, overrides).stream()
|
||||
.filter(lesson -> requestedDates.contains(lesson.date()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> search(Long groupId,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
@@ -137,29 +174,26 @@ public class ScheduleQueryService {
|
||||
boolean enforceInteractiveGroupLimit) {
|
||||
validateRange(startDate, endDate);
|
||||
List<RenderedLessonDto> generatedLessons;
|
||||
List<StudentGroup> scopedGroups = null;
|
||||
boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null;
|
||||
if (teacherOnlySearch) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(
|
||||
scopedGroups = resolveGroups(
|
||||
groupId,
|
||||
departmentId,
|
||||
startDate,
|
||||
endDate,
|
||||
enforceInteractiveGroupLimit
|
||||
);
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate);
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForGroups(scopedGroups, startDate, endDate);
|
||||
}
|
||||
List<ScheduleOverride> overrideSnapshot = scheduleOverrideRepository
|
||||
.findByLessonDateBetweenWithDetails(startDate, endDate);
|
||||
if (teacherOnlySearch) {
|
||||
generatedLessons = addOverrideSourceOccurrencesForTeacher(
|
||||
generatedLessons,
|
||||
teacherId,
|
||||
overrideSnapshot
|
||||
);
|
||||
}
|
||||
generatedLessons = new ArrayList<>(generatedLessons);
|
||||
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
|
||||
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
|
||||
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
|
||||
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
|
||||
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
|
||||
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
|
||||
@@ -170,10 +204,10 @@ public class ScheduleQueryService {
|
||||
return deduplicate(lessons);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> addOverrideSourceOccurrencesForTeacher(
|
||||
private void addOverrideSourceOccurrences(
|
||||
List<RenderedLessonDto> generatedLessons,
|
||||
Long teacherId,
|
||||
Collection<ScheduleOverride> overrideSnapshot
|
||||
Collection<ScheduleOverride> overrideSnapshot,
|
||||
List<StudentGroup> scopedGroups
|
||||
) {
|
||||
Map<LocalDate, Set<Long>> relevantSlotsByDate = Optional.ofNullable(overrideSnapshot)
|
||||
.orElseGet(List::of)
|
||||
@@ -182,8 +216,7 @@ public class ScheduleQueryService {
|
||||
.filter(override -> override.getLessonDate() != null)
|
||||
.filter(override -> override.getBaseRuleSlot() != null)
|
||||
.filter(override -> override.getBaseRuleSlot().getId() != null)
|
||||
.filter(override -> override.getNewTeacher() != null)
|
||||
.filter(override -> Objects.equals(override.getNewTeacher().getId(), teacherId))
|
||||
.filter(override -> !"CANCEL".equals(override.getAction()))
|
||||
.collect(Collectors.groupingBy(
|
||||
ScheduleOverride::getLessonDate,
|
||||
TreeMap::new,
|
||||
@@ -193,15 +226,27 @@ public class ScheduleQueryService {
|
||||
)
|
||||
));
|
||||
if (relevantSlotsByDate.isEmpty()) {
|
||||
return generatedLessons;
|
||||
return;
|
||||
}
|
||||
|
||||
List<RenderedLessonDto> supplementedLessons = new ArrayList<>(generatedLessons);
|
||||
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> buildBaseDayForAllGroups(date).stream()
|
||||
.filter(lesson -> date.equals(lesson.date()))
|
||||
.filter(lesson -> baseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
|
||||
.forEach(supplementedLessons::add));
|
||||
return supplementedLessons;
|
||||
relevantSlotsByDate.forEach((date, baseRuleSlotIds) -> {
|
||||
Set<Long> missingBaseRuleSlotIds = baseRuleSlotIds.stream()
|
||||
.filter(baseRuleSlotId -> generatedLessons.stream().noneMatch(lesson ->
|
||||
date.equals(lesson.date())
|
||||
&& Objects.equals(baseRuleSlotId, lesson.scheduleRuleSlotId())
|
||||
))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (missingBaseRuleSlotIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<RenderedLessonDto> sourceDay = scopedGroups == null
|
||||
? buildBaseDayForAllGroups(date)
|
||||
: deduplicate(scheduleGeneratorService.buildScheduleForGroups(scopedGroups, date, date));
|
||||
sourceDay.stream()
|
||||
.filter(lesson -> date.equals(lesson.date()))
|
||||
.filter(lesson -> missingBaseRuleSlotIds.contains(lesson.scheduleRuleSlotId()))
|
||||
.forEach(generatedLessons::add);
|
||||
});
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> applyOverrides(List<RenderedLessonDto> lessons,
|
||||
@@ -235,6 +280,26 @@ public class ScheduleQueryService {
|
||||
if ("CANCEL".equals(override.getAction())) {
|
||||
return null;
|
||||
}
|
||||
LocalDate effectiveDate = override.getTargetLessonDate() == null
|
||||
? lesson.date()
|
||||
: override.getTargetLessonDate();
|
||||
Integer dayOfWeek = lesson.dayOfWeek();
|
||||
String dayName = lesson.dayName();
|
||||
Integer weekNumber = lesson.weekNumber();
|
||||
com.magistr.app.model.ScheduleParity parity = lesson.parity();
|
||||
if (!Objects.equals(effectiveDate, lesson.date())) {
|
||||
if (academicDateService == null
|
||||
|| override.getBaseRuleSlot() == null
|
||||
|| override.getBaseRuleSlot().getScheduleRule() == null
|
||||
|| override.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
|
||||
throw new IllegalStateException("Невозможно пересчитать календарные параметры перенесённой пары");
|
||||
}
|
||||
var semester = override.getBaseRuleSlot().getScheduleRule().getSemester();
|
||||
dayOfWeek = effectiveDate.getDayOfWeek().getValue();
|
||||
dayName = dayName(effectiveDate.getDayOfWeek());
|
||||
weekNumber = academicDateService.getWeekNumber(semester, effectiveDate);
|
||||
parity = academicDateService.getParity(semester, effectiveDate);
|
||||
}
|
||||
Long timeSlotId = lesson.timeSlotId();
|
||||
Integer timeSlotOrder = lesson.timeSlotOrder();
|
||||
java.time.LocalTime startTime = lesson.startTime();
|
||||
@@ -255,11 +320,11 @@ public class ScheduleQueryService {
|
||||
return new RenderedLessonDto(
|
||||
lesson.scheduleRuleId(),
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
lesson.dayOfWeek(),
|
||||
lesson.dayName(),
|
||||
lesson.weekNumber(),
|
||||
lesson.parity(),
|
||||
effectiveDate,
|
||||
dayOfWeek,
|
||||
dayName,
|
||||
weekNumber,
|
||||
parity,
|
||||
timeSlotId,
|
||||
timeSlotOrder,
|
||||
startTime,
|
||||
@@ -283,10 +348,25 @@ public class ScheduleQueryService {
|
||||
lesson.lessonTypeAcademicHours(),
|
||||
lesson.consumedLessonTypeAcademicHoursBeforeLesson(),
|
||||
lesson.remainingLessonTypeAcademicHoursAfterLesson(),
|
||||
lesson.ruleParity()
|
||||
lesson.ruleParity(),
|
||||
override.getId(),
|
||||
override.getAction(),
|
||||
lesson.date()
|
||||
);
|
||||
}
|
||||
|
||||
private String dayName(java.time.DayOfWeek dayOfWeek) {
|
||||
return switch (dayOfWeek) {
|
||||
case MONDAY -> "Понедельник";
|
||||
case TUESDAY -> "Вторник";
|
||||
case WEDNESDAY -> "Среда";
|
||||
case THURSDAY -> "Четверг";
|
||||
case FRIDAY -> "Пятница";
|
||||
case SATURDAY -> "Суббота";
|
||||
case SUNDAY -> "Воскресенье";
|
||||
};
|
||||
}
|
||||
|
||||
private String overrideKey(Long scheduleRuleSlotId, LocalDate date) {
|
||||
return scheduleRuleSlotId + ":" + date;
|
||||
}
|
||||
@@ -356,7 +436,9 @@ public class ScheduleQueryService {
|
||||
String.valueOf(lesson.timeSlotOrder()),
|
||||
String.valueOf(lesson.teacherId()),
|
||||
String.valueOf(lesson.subjectId()),
|
||||
sortedSignature(lesson.subgroupIds())
|
||||
sortedSignature(lesson.subgroupIds()),
|
||||
String.valueOf(lesson.scheduleOverrideId()),
|
||||
String.valueOf(lesson.originalLessonDate())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,7 +510,10 @@ public class ScheduleQueryService {
|
||||
source.lessonTypeAcademicHours(),
|
||||
source.consumedLessonTypeAcademicHoursBeforeLesson(),
|
||||
source.remainingLessonTypeAcademicHoursAfterLesson(),
|
||||
source.ruleParity()
|
||||
source.ruleParity(),
|
||||
source.scheduleOverrideId(),
|
||||
source.overrideAction(),
|
||||
source.originalLessonDate()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +873,7 @@ CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
target_lesson_date DATE,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
new_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
new_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
@@ -888,6 +889,10 @@ CREATE TABLE IF NOT EXISTS schedule_overrides (
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_date
|
||||
ON schedule_overrides(lesson_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_target_date
|
||||
ON schedule_overrides(target_lesson_date)
|
||||
WHERE target_lesson_date IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schedule_overrides_new_teacher
|
||||
ON schedule_overrides(new_teacher_id);
|
||||
|
||||
@@ -1121,6 +1126,8 @@ COMMENT ON TABLE teacher_department_assignments IS 'История принад
|
||||
COMMENT ON TABLE teacher_creation_requests IS 'Заявки кафедр на создание преподавателей';
|
||||
COMMENT ON TABLE subject_comments IS 'Комментарии кафедр и администраторов к дисциплинам';
|
||||
COMMENT ON TABLE schedule_overrides IS 'Точечные изменения конкретных сгенерированных пар';
|
||||
COMMENT ON COLUMN schedule_overrides.lesson_date IS 'Исходная дата занятия, сформированного базовым правилом';
|
||||
COMMENT ON COLUMN schedule_overrides.target_lesson_date IS 'Новая дата единственного занятия при действии MOVE';
|
||||
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
|
||||
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
|
||||
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';
|
||||
@@ -1288,12 +1295,14 @@ DECLARE
|
||||
move_violations BIGINT;
|
||||
replace_violations BIGINT;
|
||||
format_violations BIGINT;
|
||||
target_date_violations BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
INTO cancel_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'CANCEL'
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
AND (target_lesson_date IS NOT NULL
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL);
|
||||
@@ -1302,16 +1311,16 @@ BEGIN
|
||||
INTO move_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'MOVE'
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL;
|
||||
AND new_time_slot_id IS NULL;
|
||||
|
||||
SELECT count(*)
|
||||
INTO replace_violations
|
||||
FROM schedule_overrides
|
||||
WHERE action = 'REPLACE'
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL;
|
||||
AND (new_time_slot_id IS NOT NULL
|
||||
OR (new_teacher_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_lesson_format IS NULL));
|
||||
|
||||
SELECT count(*)
|
||||
INTO format_violations
|
||||
@@ -1319,18 +1328,28 @@ BEGIN
|
||||
WHERE new_lesson_format IS NOT NULL
|
||||
AND new_lesson_format NOT IN ('Очно', 'Онлайн');
|
||||
|
||||
SELECT count(*)
|
||||
INTO target_date_violations
|
||||
FROM schedule_overrides
|
||||
WHERE target_lesson_date IS NOT NULL
|
||||
AND (target_lesson_date = lesson_date
|
||||
OR action <> 'MOVE'
|
||||
OR new_time_slot_id IS NULL);
|
||||
|
||||
IF cancel_violations > 0
|
||||
OR move_violations > 0
|
||||
OR replace_violations > 0
|
||||
OR format_violations > 0 THEN
|
||||
OR format_violations > 0
|
||||
OR target_date_violations > 0 THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '23514',
|
||||
MESSAGE = format(
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
||||
cancel_violations,
|
||||
move_violations,
|
||||
replace_violations,
|
||||
format_violations
|
||||
format_violations,
|
||||
target_date_violations
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1340,7 +1359,8 @@ ALTER TABLE schedule_overrides
|
||||
CHECK (
|
||||
action <> 'CANCEL'
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
target_lesson_date IS NULL
|
||||
AND new_time_slot_id IS NULL
|
||||
AND new_classroom_id IS NULL
|
||||
AND new_teacher_id IS NULL
|
||||
AND new_lesson_format IS NULL
|
||||
@@ -1350,19 +1370,32 @@ ALTER TABLE schedule_overrides
|
||||
CHECK (
|
||||
action <> 'MOVE'
|
||||
OR new_time_slot_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_replace_payload
|
||||
CHECK (
|
||||
action <> 'REPLACE'
|
||||
OR new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
OR (
|
||||
new_time_slot_id IS NULL
|
||||
AND (
|
||||
new_teacher_id IS NOT NULL
|
||||
OR new_classroom_id IS NOT NULL
|
||||
OR new_lesson_format IS NOT NULL
|
||||
)
|
||||
)
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_format
|
||||
CHECK (
|
||||
new_lesson_format IS NULL
|
||||
OR new_lesson_format IN ('Очно', 'Онлайн')
|
||||
) NOT VALID,
|
||||
ADD CONSTRAINT chk_schedule_overrides_target_date
|
||||
CHECK (
|
||||
target_lesson_date IS NULL
|
||||
OR (
|
||||
target_lesson_date <> lesson_date
|
||||
AND action = 'MOVE'
|
||||
AND new_time_slot_id IS NOT NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
@@ -1377,6 +1410,9 @@ ALTER TABLE schedule_overrides
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_format;
|
||||
|
||||
ALTER TABLE schedule_overrides
|
||||
VALIDATE CONSTRAINT chk_schedule_overrides_target_date;
|
||||
|
||||
-- ==========================================
|
||||
-- Инварианты правил расписания
|
||||
-- ==========================================
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.service.ScheduleQueryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsSemestersForReadOnlyScheduleFilterInNewestFirstOrder() {
|
||||
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
SemesterRepository semesterRepository = mock(SemesterRepository.class);
|
||||
ScheduleController controller = new ScheduleController(scheduleQueryService, semesterRepository);
|
||||
|
||||
Semester autumn = semester(1L, SemesterType.autumn,
|
||||
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 1, 31));
|
||||
Semester spring = semester(2L, SemesterType.spring,
|
||||
LocalDate.of(2026, 2, 9), LocalDate.of(2026, 6, 30));
|
||||
when(semesterRepository.findAll()).thenReturn(List.of(autumn, spring));
|
||||
|
||||
var result = controller.getSemesters();
|
||||
|
||||
assertThat(result).extracting(item -> item.id()).containsExactly(2L, 1L);
|
||||
assertThat(result.get(0).academicYearTitle()).isEqualTo("2025/2026");
|
||||
assertThat(result.get(0).semesterType()).isEqualTo(SemesterType.spring);
|
||||
assertThat(result.get(0).startDate()).isEqualTo(LocalDate.of(2026, 2, 9));
|
||||
assertThat(result.get(0).endDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||
}
|
||||
|
||||
private Semester semester(Long id, SemesterType type, LocalDate startDate, LocalDate endDate) {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(10L);
|
||||
academicYear.setTitle("2025/2026");
|
||||
academicYear.setStartDate(LocalDate.of(2025, 9, 1));
|
||||
academicYear.setEndDate(LocalDate.of(2026, 8, 31));
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setId(id);
|
||||
semester.setAcademicYear(academicYear);
|
||||
semester.setSemesterType(type);
|
||||
semester.setStartDate(startDate);
|
||||
semester.setEndDate(endDate);
|
||||
return semester;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.service.ScheduleConflictException;
|
||||
import com.magistr.app.service.ScheduleOverrideService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -8,9 +9,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -18,6 +24,9 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
|
||||
|
||||
class ScheduleOverrideControllerTest {
|
||||
|
||||
private static final LocalDate SOURCE_DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -51,4 +60,43 @@ class ScheduleOverrideControllerTest {
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Конфликт расписания: преподаватель уже занят в указанное время"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsRegistryBySourceOrTargetRangeWithTargetDate() throws Exception {
|
||||
ScheduleOverrideDto override = new ScheduleOverrideDto(
|
||||
70L, 101L, SOURCE_DATE, TARGET_DATE, "MOVE", 2L,
|
||||
null, null, null, "Перенос", 5L, null
|
||||
);
|
||||
when(service.getAll(SOURCE_DATE, TARGET_DATE)).thenReturn(List.of(override));
|
||||
|
||||
mockMvc.perform(get("/api/edu-office/schedule/overrides")
|
||||
.param("startDate", SOURCE_DATE.toString())
|
||||
.param("endDate", TARGET_DATE.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].lessonDate[0]").value(2026))
|
||||
.andExpect(jsonPath("$[0].lessonDate[1]").value(9))
|
||||
.andExpect(jsonPath("$[0].lessonDate[2]").value(1))
|
||||
.andExpect(jsonPath("$[0].targetLessonDate[2]").value(10));
|
||||
|
||||
verify(service).getAll(SOURCE_DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSemesterAvailabilityForSourceOccurrence() throws Exception {
|
||||
when(service.getAvailability(101L, SOURCE_DATE)).thenReturn(new ScheduleOverrideAvailabilityDto(
|
||||
8L,
|
||||
LocalDate.of(2026, 9, 1),
|
||||
LocalDate.of(2027, 1, 31),
|
||||
List.of(SOURCE_DATE, TARGET_DATE)
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/edu-office/schedule/overrides/availability")
|
||||
.param("baseRuleSlotId", "101")
|
||||
.param("lessonDate", SOURCE_DATE.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.semesterId").value(8))
|
||||
.andExpect(jsonPath("$.availableDates[1][0]").value(2026))
|
||||
.andExpect(jsonPath("$.availableDates[1][1]").value(9))
|
||||
.andExpect(jsonPath("$.availableDates[1][2]").value(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +44,54 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
LocalDate validDate = LocalDate.of(2026, 3, 2);
|
||||
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'schedule_overrides'
|
||||
AND column_name = 'target_lesson_date'
|
||||
"""))
|
||||
.as("V1 должна создавать целевую дату переноса")
|
||||
.isEqualTo(1L);
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'schedule_overrides'
|
||||
AND indexname = 'idx_schedule_overrides_target_date'
|
||||
"""))
|
||||
.as("V1 должна создавать индекс целевой даты")
|
||||
.isEqualTo(1L);
|
||||
|
||||
insertOverride(connection, seedIds, validDate, "CANCEL", null, null, null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(1), "MOVE", seedIds.classroomId(), null, null);
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
validDate.plusDays(1),
|
||||
null,
|
||||
"MOVE",
|
||||
seedIds.timeSlotId(),
|
||||
seedIds.classroomId(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(2), "REPLACE", null, seedIds.teacherId(), null);
|
||||
insertOverride(connection, seedIds, validDate.plusDays(3), "REPLACE", null, null, "Онлайн");
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
validDate.plusDays(4),
|
||||
validDate.plusDays(9),
|
||||
"MOVE",
|
||||
seedIds.timeSlotId(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_overrides"))
|
||||
.as("Все допустимые варианты точечного изменения должны сохраниться")
|
||||
.isEqualTo(4L);
|
||||
.isEqualTo(5L);
|
||||
|
||||
LocalDate invalidDate = LocalDate.of(2026, 4, 6);
|
||||
assertCheckViolation(
|
||||
@@ -94,6 +134,14 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
"Смешанный",
|
||||
"chk_schedule_overrides_format"
|
||||
);
|
||||
assertTargetDateCheckViolation(
|
||||
connection,
|
||||
seedIds,
|
||||
invalidDate.plusDays(4),
|
||||
invalidDate.plusDays(4),
|
||||
"MOVE",
|
||||
seedIds.timeSlotId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +158,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
return new SeedIds(
|
||||
queryLong(connection, "SELECT id FROM schedule_rule_slots ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM classrooms ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1")
|
||||
queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM time_slots ORDER BY id LIMIT 1")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,27 +171,79 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat
|
||||
) throws SQLException {
|
||||
insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
lessonDate,
|
||||
null,
|
||||
action,
|
||||
null,
|
||||
classroomId,
|
||||
teacherId,
|
||||
lessonFormat
|
||||
);
|
||||
}
|
||||
|
||||
private void insertOverride(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long timeSlotId,
|
||||
Long classroomId,
|
||||
Long teacherId,
|
||||
String lessonFormat
|
||||
) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_overrides (
|
||||
base_rule_slot_id,
|
||||
lesson_date,
|
||||
target_lesson_date,
|
||||
action,
|
||||
new_time_slot_id,
|
||||
new_classroom_id,
|
||||
new_teacher_id,
|
||||
new_lesson_format
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
statement.setLong(1, seedIds.baseRuleSlotId());
|
||||
statement.setDate(2, Date.valueOf(lessonDate));
|
||||
statement.setString(3, action);
|
||||
setNullableLong(statement, 4, classroomId);
|
||||
setNullableLong(statement, 5, teacherId);
|
||||
statement.setString(6, lessonFormat);
|
||||
statement.setObject(3, targetLessonDate == null ? null : Date.valueOf(targetLessonDate));
|
||||
statement.setString(4, action);
|
||||
setNullableLong(statement, 5, timeSlotId);
|
||||
setNullableLong(statement, 6, classroomId);
|
||||
setNullableLong(statement, 7, teacherId);
|
||||
statement.setString(8, lessonFormat);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertTargetDateCheckViolation(Connection connection,
|
||||
SeedIds seedIds,
|
||||
LocalDate lessonDate,
|
||||
LocalDate targetLessonDate,
|
||||
String action,
|
||||
Long timeSlotId) {
|
||||
Throwable failure = catchThrowable(() -> insertOverride(
|
||||
connection,
|
||||
seedIds,
|
||||
lessonDate,
|
||||
targetLessonDate,
|
||||
action,
|
||||
timeSlotId,
|
||||
action.equals("MOVE") && timeSlotId == null ? seedIds.classroomId() : null,
|
||||
action.equals("REPLACE") ? seedIds.teacherId() : null,
|
||||
null
|
||||
));
|
||||
|
||||
assertThat(failure).isInstanceOf(SQLException.class);
|
||||
SQLException sqlFailure = (SQLException) failure;
|
||||
assertThat(sqlFailure.getSQLState()).isEqualTo("23514");
|
||||
assertThat(sqlFailure.getMessage()).contains("chk_schedule_overrides_target_date");
|
||||
}
|
||||
|
||||
private void assertCheckViolation(
|
||||
Connection connection,
|
||||
SeedIds seedIds,
|
||||
@@ -197,6 +298,6 @@ class ScheduleOverrideMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId) {
|
||||
private record SeedIds(long baseRuleSlotId, long classroomId, long teacherId, long timeSlotId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,70 @@ class PostgreSqlAdvisoryLockIntegrationTest {
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesConcurrentMovesToSameResourcesAndTargetDate() throws Exception {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schedule_move_concurrency_probe (
|
||||
source_date DATE NOT NULL,
|
||||
target_date DATE NOT NULL,
|
||||
teacher_id BIGINT NOT NULL,
|
||||
classroom_id BIGINT NOT NULL
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.update("DELETE FROM schedule_move_concurrency_probe");
|
||||
|
||||
executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Future<Boolean> first = executor.submit(() -> tryMove(
|
||||
TEST_DATE.minusDays(7), TEST_DATE, 11L, 21L, ready, start
|
||||
));
|
||||
Future<Boolean> second = executor.submit(() -> tryMove(
|
||||
TEST_DATE.minusDays(14), TEST_DATE, 11L, 21L, ready, start
|
||||
));
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
|
||||
assertThat(java.util.List.of(
|
||||
first.get(5, TimeUnit.SECONDS),
|
||||
second.get(5, TimeUnit.SECONDS)
|
||||
)).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM schedule_move_concurrency_probe",
|
||||
Long.class
|
||||
)).isEqualTo(1L);
|
||||
}
|
||||
|
||||
private boolean tryMove(LocalDate sourceDate,
|
||||
LocalDate targetDate,
|
||||
long teacherId,
|
||||
long classroomId,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start) {
|
||||
ready.countDown();
|
||||
await(start, "Конкурентные переносы не получили общий сигнал запуска");
|
||||
Boolean saved = transactionTemplate.execute(status -> {
|
||||
advisoryLock.lockScheduleOverrideDate(targetDate);
|
||||
Long conflicts = jdbcTemplate.queryForObject("""
|
||||
SELECT count(*)
|
||||
FROM schedule_move_concurrency_probe
|
||||
WHERE target_date = ?
|
||||
AND (teacher_id = ? OR classroom_id = ?)
|
||||
""", Long.class, targetDate, teacherId, classroomId);
|
||||
if (conflicts != null && conflicts > 0) {
|
||||
return false;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO schedule_move_concurrency_probe (
|
||||
source_date, target_date, teacher_id, classroom_id
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""", sourceDate, targetDate, teacherId, classroomId);
|
||||
return true;
|
||||
});
|
||||
return Boolean.TRUE.equals(saved);
|
||||
}
|
||||
|
||||
private void awaitAdvisoryLockWait(int backendPid) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
do {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideAvailabilityDto;
|
||||
import com.magistr.app.dto.ScheduleOverrideDto;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
@@ -30,6 +35,7 @@ import java.time.LocalTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -45,6 +51,7 @@ import static org.mockito.Mockito.when;
|
||||
class ScheduleOverrideServiceTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
private static final Long BASE_SLOT_ID = 101L;
|
||||
private static final Long OTHER_SLOT_ID = 202L;
|
||||
private static final Long SOURCE_TIME_SLOT_ID = 1L;
|
||||
@@ -76,6 +83,10 @@ class ScheduleOverrideServiceTest {
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
@Mock
|
||||
private PostgreSqlAdvisoryLock advisoryLock;
|
||||
@Mock
|
||||
private AcademicDateService academicDateService;
|
||||
@Mock
|
||||
private EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
|
||||
private ScheduleOverrideService service;
|
||||
private ScheduleRuleSlot baseSlot;
|
||||
@@ -96,6 +107,10 @@ class ScheduleOverrideServiceTest {
|
||||
newClassroom = classroom(NEW_CLASSROOM_ID, "Б-202");
|
||||
sourceTeacher = teacher(SOURCE_TEACHER_ID, "Исходный преподаватель");
|
||||
newTeacher = teacher(NEW_TEACHER_ID, "Новый преподаватель");
|
||||
baseSlot.setTimeSlot(sourceTimeSlot);
|
||||
baseSlot.setTeacher(sourceTeacher);
|
||||
baseSlot.setClassroom(sourceClassroom);
|
||||
baseSlot.setLessonFormat("Очно");
|
||||
sourceLesson = lesson(
|
||||
BASE_SLOT_ID,
|
||||
SOURCE_TIME_SLOT_ID,
|
||||
@@ -117,7 +132,9 @@ class ScheduleOverrideServiceTest {
|
||||
subgroupRepository,
|
||||
scheduleQueryService,
|
||||
scheduleGeneratorService,
|
||||
advisoryLock
|
||||
advisoryLock,
|
||||
academicDateService,
|
||||
effectiveTimeSlotService
|
||||
);
|
||||
|
||||
when(scheduleRuleSlotRepository.findById(BASE_SLOT_ID)).thenReturn(Optional.of(baseSlot));
|
||||
@@ -133,6 +150,7 @@ class ScheduleOverrideServiceTest {
|
||||
when(scheduleOverrideRepository.saveAndFlush(any(ScheduleOverride.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(scheduleQueryService.buildBaseDayForAllGroups(DATE)).thenReturn(List.of(sourceLesson));
|
||||
when(effectiveTimeSlotService.isEffectiveOnDate(any(Long.class), any(LocalDate.class))).thenReturn(true);
|
||||
when(subgroupRepository.findGroupReferencesByIdIn(anyList())).thenAnswer(invocation -> {
|
||||
List<Long> subgroupIds = invocation.getArgument(0);
|
||||
return subgroupIds.stream()
|
||||
@@ -148,6 +166,47 @@ class ScheduleOverrideServiceTest {
|
||||
.hasMessage("Передайте данные изменения расписания");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registryRangeReturnsOverrideWhoseTargetDateIsInsidePeriod() {
|
||||
ScheduleOverride moved = override(720L, baseSlot, "MOVE", newTimeSlot);
|
||||
moved.setTargetLessonDate(TARGET_DATE);
|
||||
when(scheduleOverrideRepository.findForRegistry(TARGET_DATE, TARGET_DATE)).thenReturn(List.of(moved));
|
||||
|
||||
List<ScheduleOverrideDto> result = service.getAll(TARGET_DATE, TARGET_DATE);
|
||||
|
||||
assertThat(result).singleElement().satisfies(item -> {
|
||||
assertThat(item.lessonDate()).isEqualTo(DATE);
|
||||
assertThat(item.targetLessonDate()).isEqualTo(TARGET_DATE);
|
||||
});
|
||||
verify(scheduleOverrideRepository).findForRegistry(TARGET_DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void availabilityContainsOnlyStudyDatesOfSourceSemester() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
StudentGroup group = baseSlot.getScheduleRule().getGroups().iterator().next();
|
||||
AcademicDateService.ScheduleSnapshot snapshot = org.mockito.Mockito.mock(
|
||||
AcademicDateService.ScheduleSnapshot.class
|
||||
);
|
||||
when(academicDateService.createScheduleSnapshot(
|
||||
any(Collection.class),
|
||||
any(Collection.class),
|
||||
eq(semester.getEndDate())
|
||||
)).thenReturn(snapshot);
|
||||
when(snapshot.isTheoryDay(eq(group), eq(semester), any(LocalDate.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
LocalDate date = invocation.getArgument(2);
|
||||
return date.equals(DATE) || date.equals(TARGET_DATE);
|
||||
});
|
||||
|
||||
ScheduleOverrideAvailabilityDto result = service.getAvailability(BASE_SLOT_ID, DATE);
|
||||
|
||||
assertThat(result.semesterId()).isEqualTo(semester.getId());
|
||||
assertThat(result.semesterStartDate()).isEqualTo(semester.getStartDate());
|
||||
assertThat(result.semesterEndDate()).isEqualTo(semester.getEndDate());
|
||||
assertThat(result.availableDates()).containsExactly(DATE, TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredRequestReferences() {
|
||||
assertThatThrownBy(() -> service.create(request(null, DATE, "CANCEL", null, null, null, null)))
|
||||
@@ -203,7 +262,7 @@ class ScheduleOverrideServiceTest {
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, null, NEW_TEACHER_ID, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Для переноса укажите новое время или аудиторию");
|
||||
.hasMessage("Для переноса укажите новое время");
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "REPLACE", null, null, null, null
|
||||
@@ -289,10 +348,10 @@ class ScheduleOverrideServiceTest {
|
||||
stubEffectiveDay(sourceLesson);
|
||||
|
||||
assertThatThrownBy(() -> service.create(request(
|
||||
BASE_SLOT_ID, DATE, "MOVE", null, SOURCE_CLASSROOM_ID, null, null
|
||||
BASE_SLOT_ID, DATE, "MOVE", SOURCE_TIME_SLOT_ID, SOURCE_CLASSROOM_ID, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
.hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -315,7 +374,7 @@ class ScheduleOverrideServiceTest {
|
||||
BASE_SLOT_ID, DATE, "MOVE", 3L, null, null, null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос должен фактически менять время пары или аудиторию");
|
||||
.hasMessage("Перенос должен фактически менять дату, время пары или аудиторию");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -440,7 +499,6 @@ class ScheduleOverrideServiceTest {
|
||||
@Test
|
||||
void updateExcludesItselfAndDoesNotMutateStoredEntityOnConflict() {
|
||||
ScheduleOverride stored = override(500L, baseSlot, "CANCEL", null);
|
||||
when(scheduleOverrideRepository.findLessonDateById(500L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(500L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
@@ -470,7 +528,6 @@ class ScheduleOverrideServiceTest {
|
||||
@Test
|
||||
void idempotentUpdateExcludesStoredOverrideFromConflictCheck() {
|
||||
ScheduleOverride stored = override(501L, baseSlot, "MOVE", newTimeSlot);
|
||||
when(scheduleOverrideRepository.findLessonDateById(501L)).thenReturn(Optional.of(DATE));
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(501L)).thenReturn(Optional.of(stored));
|
||||
when(scheduleOverrideRepository.findByBaseRuleSlotIdAndLessonDateForUpdate(BASE_SLOT_ID, DATE))
|
||||
.thenReturn(Optional.of(stored));
|
||||
@@ -489,6 +546,262 @@ class ScheduleOverrideServiceTest {
|
||||
verify(scheduleOverrideRepository).saveAndFlush(stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCombinedMoveToStudyDateInSameSemester() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(true);
|
||||
when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
|
||||
when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
|
||||
.thenReturn(List.of(relocatedLesson(
|
||||
BASE_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_TEACHER_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
"Онлайн"
|
||||
)));
|
||||
|
||||
ScheduleOverrideDto saved = service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
NEW_TEACHER_ID,
|
||||
"Онлайн"
|
||||
));
|
||||
|
||||
assertThat(saved.targetLessonDate()).isEqualTo(TARGET_DATE);
|
||||
assertThat(saved.action()).isEqualTo("MOVE");
|
||||
assertThat(saved.newTimeSlotId()).isEqualTo(NEW_TIME_SLOT_ID);
|
||||
assertThat(saved.newTeacherId()).isEqualTo(NEW_TEACHER_ID);
|
||||
assertThat(saved.newClassroomId()).isEqualTo(NEW_CLASSROOM_ID);
|
||||
assertThat(saved.newLessonFormat()).isEqualTo("Онлайн");
|
||||
verify(advisoryLock).lockScheduleOverrideDate(DATE);
|
||||
verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateOutsideSourceSemester() {
|
||||
configureRuleForTarget();
|
||||
LocalDate outsideSemester = LocalDate.of(2027, 2, 1);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
outsideSemester,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенести занятие можно только в пределах того же семестра");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateThatIsNotStudyDayForEveryGroup() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Целевая дата должна быть учебной для всех групп исходного занятия");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTimeSlotThatIsNotEffectiveOnTargetDate() {
|
||||
configureRuleForTarget();
|
||||
when(effectiveTimeSlotService.isEffectiveOnDate(NEW_TIME_SLOT_ID, TARGET_DATE)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Выбранный временной слот не действует на целевую дату");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResourceThatIsInactiveOnTargetDate() {
|
||||
configureRuleForTarget();
|
||||
sourceTeacher.setActiveTo(DATE);
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Активный преподаватель с ролью TEACHER не найден");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResourceConflictInEffectiveTargetDay() {
|
||||
Semester semester = configureRuleForTarget();
|
||||
when(academicDateService.isTheoryDay(any(StudentGroup.class), eq(semester), eq(TARGET_DATE)))
|
||||
.thenReturn(true);
|
||||
when(scheduleOverrideRepository.findAffectingDatesWithDetails(any(Collection.class))).thenReturn(List.of());
|
||||
when(scheduleQueryService.buildEffectiveDaysForAllGroups(any(Collection.class), any(Collection.class)))
|
||||
.thenReturn(List.of(
|
||||
relocatedLesson(
|
||||
BASE_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
SOURCE_TEACHER_ID,
|
||||
SOURCE_CLASSROOM_ID,
|
||||
"Очно"
|
||||
),
|
||||
relocatedLesson(
|
||||
OTHER_SLOT_ID,
|
||||
NEW_TIME_SLOT_ID,
|
||||
SOURCE_TEACHER_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
"Очно"
|
||||
)
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"MOVE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessage("Конфликт расписания: преподаватель уже занят в указанное время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetDateWithReplaceAction() {
|
||||
assertThatThrownBy(() -> service.create(targetRequest(
|
||||
TARGET_DATE,
|
||||
"REPLACE",
|
||||
NEW_TIME_SLOT_ID,
|
||||
NEW_CLASSROOM_ID,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Перенос даты должен использовать действие MOVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMovedOverrideLocksBothDatesAndReturnsRuleAfterCacheRefresh() {
|
||||
ScheduleOverride stored = override(710L, baseSlot, "MOVE", newTimeSlot);
|
||||
stored.setTargetLessonDate(TARGET_DATE);
|
||||
when(scheduleOverrideRepository.findByIdForUpdate(710L)).thenReturn(Optional.of(stored));
|
||||
|
||||
service.delete(710L);
|
||||
|
||||
verify(advisoryLock).lockScheduleOverrideDate(DATE);
|
||||
verify(advisoryLock).lockScheduleOverrideDate(TARGET_DATE);
|
||||
verify(scheduleOverrideRepository).delete(stored);
|
||||
verify(scheduleOverrideRepository).flush();
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
private Semester configureRuleForTarget() {
|
||||
Semester semester = new Semester();
|
||||
semester.setId(81L);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2027, 1, 31));
|
||||
|
||||
Subject subject = new Subject();
|
||||
subject.setId(501L);
|
||||
subject.setName("Тестовая дисциплина");
|
||||
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(GROUP_ID);
|
||||
group.setName("Группа 31");
|
||||
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setId(900L);
|
||||
rule.setSemester(semester);
|
||||
rule.setSubject(subject);
|
||||
rule.setGroups(Set.of(group));
|
||||
baseSlot.setScheduleRule(rule);
|
||||
return semester;
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto targetRequest(LocalDate targetDate,
|
||||
String action,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String format) {
|
||||
return new ScheduleOverrideDto(
|
||||
null,
|
||||
BASE_SLOT_ID,
|
||||
DATE,
|
||||
targetDate,
|
||||
action,
|
||||
newTimeSlotId,
|
||||
newClassroomId,
|
||||
newTeacherId,
|
||||
format,
|
||||
"Перенос конкретного занятия",
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private RenderedLessonDto relocatedLesson(Long ruleSlotId,
|
||||
Long timeSlotId,
|
||||
Long teacherId,
|
||||
Long classroomId,
|
||||
String format) {
|
||||
return new RenderedLessonDto(
|
||||
900L,
|
||||
ruleSlotId,
|
||||
TARGET_DATE,
|
||||
4,
|
||||
"Четверг",
|
||||
2,
|
||||
ScheduleParity.EVEN,
|
||||
timeSlotId,
|
||||
2,
|
||||
LocalTime.of(10, 40),
|
||||
LocalTime.of(12, 10),
|
||||
501L,
|
||||
"Тестовая дисциплина",
|
||||
teacherId,
|
||||
"Тестовый преподаватель",
|
||||
classroomId,
|
||||
"Тестовая аудитория",
|
||||
601L,
|
||||
"Лекция",
|
||||
format,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(GROUP_ID),
|
||||
List.of("Группа 31"),
|
||||
"Лекция",
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
ScheduleParity.BOTH,
|
||||
null,
|
||||
"MOVE",
|
||||
DATE
|
||||
);
|
||||
}
|
||||
|
||||
private void stubEffectiveDay(RenderedLessonDto... lessons) {
|
||||
when(scheduleQueryService.buildEffectiveDayForAllGroups(
|
||||
eq(List.of(sourceLesson)),
|
||||
|
||||
@@ -5,23 +5,32 @@ import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleQueryServiceOverrideTest {
|
||||
|
||||
private static final LocalDate DATE = LocalDate.of(2026, 9, 1);
|
||||
private static final LocalDate TARGET_DATE = LocalDate.of(2026, 9, 10);
|
||||
|
||||
@Test
|
||||
void appliesAllReplacementFieldsToProvidedDaySnapshot() {
|
||||
@@ -95,6 +104,74 @@ class ScheduleQueryServiceOverrideTest {
|
||||
assertThat(result).singleElement().extracting(RenderedLessonDto::scheduleRuleSlotId).isEqualTo(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesOccurrenceToTargetDateAndRecalculatesCalendarFields() {
|
||||
AcademicDateService academicDateService = mock(AcademicDateService.class);
|
||||
ScheduleQueryService service = service(academicDateService);
|
||||
ScheduleOverride override = relocationOverride();
|
||||
when(academicDateService.getWeekNumber(any(Semester.class), eq(TARGET_DATE))).thenReturn(2);
|
||||
when(academicDateService.getParity(any(Semester.class), eq(TARGET_DATE))).thenReturn(ScheduleParity.EVEN);
|
||||
|
||||
List<RenderedLessonDto> result = service.buildEffectiveDayForAllGroups(
|
||||
List.of(lesson()),
|
||||
DATE,
|
||||
List.of(override)
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(moved -> {
|
||||
assertThat(moved.date()).isEqualTo(TARGET_DATE);
|
||||
assertThat(moved.dayOfWeek()).isEqualTo(4);
|
||||
assertThat(moved.dayName()).isEqualTo("Четверг");
|
||||
assertThat(moved.weekNumber()).isEqualTo(2);
|
||||
assertThat(moved.parity()).isEqualTo(ScheduleParity.EVEN);
|
||||
assertThat(moved.timeSlotId()).isEqualTo(2L);
|
||||
assertThat(moved.scheduleOverrideId()).isEqualTo(700L);
|
||||
assertThat(moved.overrideAction()).isEqualTo("MOVE");
|
||||
assertThat(moved.originalLessonDate()).isEqualTo(DATE);
|
||||
assertThat(moved.lessonTypeAcademicHours()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIncludesIncomingMoveWhenOnlyTargetDateIsRequested() {
|
||||
ScheduleGeneratorService generator = mock(ScheduleGeneratorService.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
StudentGroupLifecycleService lifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
AcademicDateService academicDateService = mock(AcademicDateService.class);
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
generator,
|
||||
groupRepository,
|
||||
overrideRepository,
|
||||
lifecycleService,
|
||||
academicDateService
|
||||
);
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(31L);
|
||||
group.setName("Группа 31");
|
||||
ScheduleOverride override = relocationOverride();
|
||||
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group));
|
||||
when(lifecycleService.mayHaveScheduleInRange(eq(group), any(LocalDate.class), any(LocalDate.class)))
|
||||
.thenReturn(true);
|
||||
when(generator.buildScheduleForGroups(anyList(), eq(TARGET_DATE), eq(TARGET_DATE))).thenReturn(List.of());
|
||||
when(generator.buildScheduleForGroups(anyList(), eq(DATE), eq(DATE))).thenReturn(List.of(lesson()));
|
||||
when(overrideRepository.findByLessonDateBetweenWithDetails(TARGET_DATE, TARGET_DATE))
|
||||
.thenReturn(List.of(override));
|
||||
when(academicDateService.getWeekNumber(any(Semester.class), eq(TARGET_DATE))).thenReturn(2);
|
||||
when(academicDateService.getParity(any(Semester.class), eq(TARGET_DATE))).thenReturn(ScheduleParity.EVEN);
|
||||
|
||||
List<RenderedLessonDto> result = service.search(
|
||||
null, null, null, null, null, null, null, null, TARGET_DATE, TARGET_DATE
|
||||
);
|
||||
|
||||
assertThat(result).singleElement().satisfies(moved -> {
|
||||
assertThat(moved.date()).isEqualTo(TARGET_DATE);
|
||||
assertThat(moved.originalLessonDate()).isEqualTo(DATE);
|
||||
assertThat(moved.scheduleRuleSlotId()).isEqualTo(101L);
|
||||
});
|
||||
}
|
||||
|
||||
private ScheduleQueryService service() {
|
||||
return new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
@@ -104,6 +181,37 @@ class ScheduleQueryServiceOverrideTest {
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleQueryService service(AcademicDateService academicDateService) {
|
||||
return new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(GroupRepository.class),
|
||||
mock(ScheduleOverrideRepository.class),
|
||||
mock(StudentGroupLifecycleService.class),
|
||||
academicDateService
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleOverride relocationOverride() {
|
||||
ScheduleOverride override = override("MOVE");
|
||||
ReflectionTestUtils.setField(override, "id", 700L);
|
||||
override.setTargetLessonDate(TARGET_DATE);
|
||||
TimeSlot targetTime = new TimeSlot();
|
||||
targetTime.setId(2L);
|
||||
targetTime.setOrderNumber(2);
|
||||
targetTime.setStartTime(LocalTime.of(10, 40));
|
||||
targetTime.setEndTime(LocalTime.of(12, 10));
|
||||
override.setNewTimeSlot(targetTime);
|
||||
|
||||
Semester semester = new Semester();
|
||||
semester.setId(80L);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2027, 1, 31));
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setSemester(semester);
|
||||
override.getBaseRuleSlot().setScheduleRule(rule);
|
||||
return override;
|
||||
}
|
||||
|
||||
private ScheduleOverride override(String action) {
|
||||
ScheduleRuleSlot baseSlot = new ScheduleRuleSlot();
|
||||
baseSlot.setId(101L);
|
||||
|
||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -115,7 +116,7 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Совпавшая базовая и достроенная пара остаётся в результате один раз")
|
||||
@DisplayName("Уже загруженная базовая пара не достраивается повторно")
|
||||
void duplicateSourceOccurrenceIsRemovedAfterApplyingSnapshot() {
|
||||
RenderedLessonDto existingLesson = lesson(101L, REPLACEMENT_TEACHER_ID, 1);
|
||||
when(generatorService.buildScheduleForTeacher(REPLACEMENT_TEACHER_ID, DATE, DATE))
|
||||
@@ -129,8 +130,8 @@ class ScheduleQueryServiceTeacherOverrideTest {
|
||||
assertThat(result).singleElement().satisfies(lesson ->
|
||||
assertThat(lesson.scheduleRuleSlotId()).isEqualTo(101L));
|
||||
verify(overrideRepository).findByLessonDateBetweenWithDetails(DATE, DATE);
|
||||
verify(groupRepository).findAll();
|
||||
verifyBaseDayBatch();
|
||||
verify(groupRepository, never()).findAll();
|
||||
verify(generatorService, never()).buildScheduleForGroups(any(), eq(DATE), eq(DATE));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
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`
|
||||
|
||||
Список временных слотов занятий. Слот принадлежит конкретной сетке времени: базовой, автоматической субботней или ручной.
|
||||
@@ -737,45 +756,82 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
|
||||
```
|
||||
|
||||
Ответ совпадает со структурой `RenderedLessonDto` из `GET /api/schedule`.
|
||||
Для занятия, к которому применено разовое изменение, дополнительно заполнены:
|
||||
|
||||
- `scheduleOverrideId` — идентификатор изменения;
|
||||
- `overrideAction` — `MOVE` или `REPLACE` (`CANCEL` в выдачу не попадает);
|
||||
- `originalLessonDate` — исходная дата занятия из базового правила.
|
||||
|
||||
Перенос удаляет занятие из исходного дня и добавляет его в целевой. Поиск учитывает
|
||||
изменение, если в запрошенный диапазон попала исходная **или** целевая дата, поэтому
|
||||
входящий перенос находится даже запросом только по целевому диапазону. Для целевой даты
|
||||
пересчитываются день недели, номер недели и чётность; учёт академических часов остаётся
|
||||
привязан к исходному занятию.
|
||||
|
||||
### Точечные изменения расписания учебного отдела
|
||||
|
||||
| Метод | 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` | Создать перенос, отмену или замену |
|
||||
| `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
|
||||
{
|
||||
"baseRuleSlotId": 31,
|
||||
"lessonDate": "2026-05-21",
|
||||
"action": "REPLACE",
|
||||
"targetLessonDate": "2026-05-27",
|
||||
"action": "MOVE",
|
||||
"newTimeSlotId": 4,
|
||||
"newClassroomId": 2,
|
||||
"newTeacherId": 5,
|
||||
"comment": "Замена аудитории и преподавателя"
|
||||
"newLessonFormat": "Онлайн",
|
||||
"comment": "Перенос конкретного занятия"
|
||||
}
|
||||
```
|
||||
|
||||
`lessonDate` всегда обозначает исходное занятие из правила. `targetLessonDate` передаётся
|
||||
только при переносе на другой день и не заменяет идентификатор исходной пары
|
||||
`baseRuleSlotId + lessonDate`.
|
||||
|
||||
Правила payload:
|
||||
|
||||
- `CANCEL` отменяет конкретную пару; поля `newTimeSlotId`, `newClassroomId`,
|
||||
`newTeacherId` и `newLessonFormat` должны отсутствовать;
|
||||
- `MOVE` требует новый временной слот или аудиторию; преподавателя и формат можно изменить
|
||||
в том же запросе;
|
||||
- `REPLACE` требует нового преподавателя, аудиторию или формат; временной слот можно
|
||||
изменить в том же запросе;
|
||||
- `CANCEL` отменяет конкретную пару; `targetLessonDate` и все поля `new*` должны отсутствовать;
|
||||
- `MOVE` требует новый временной слот; при переносе даты слот выбирается из эффективной
|
||||
сетки целевого дня, а преподавателя, аудиторию и формат можно изменить тем же запросом;
|
||||
- `REPLACE` используется только без изменения даты и времени и требует нового
|
||||
преподавателя, аудиторию или формат;
|
||||
- формат принимает только `Очно` или `Онлайн`;
|
||||
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
|
||||
временного слота с тем же интервалом не считается переносом.
|
||||
|
||||
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
|
||||
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
|
||||
Если пара не формируется или нарушена матрица действия, API возвращает `400` с русским
|
||||
сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией,
|
||||
группой или той же подгруппой, API возвращает `409 Conflict`; соседние интервалы и разные
|
||||
подгруппы одной группы не конфликтуют.
|
||||
При переносе даты backend дополнительно проверяет тот же семестр, действие правила и
|
||||
дисциплины, lifecycle итоговых ресурсов, учебный календарь всех затронутых групп и
|
||||
принадлежность времени эффективной сетке целевого дня. Если пара не формируется или
|
||||
нарушена матрица действия, 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` — отменить пару;
|
||||
- `MOVE` — перенести пару на другой фактический интервал или в другую аудиторию;
|
||||
- `REPLACE` — заменить преподавателя, аудиторию или формат.
|
||||
- `MOVE` — перенести пару на другую учебную дату того же семестра или другой фактический интервал;
|
||||
- `REPLACE` — без изменения даты и времени заменить преподавателя, аудиторию или формат.
|
||||
|
||||
`CANCEL` не принимает новые ресурсы. Для `MOVE` обязателен новый временной слот или
|
||||
аудитория, для `REPLACE` — преподаватель, аудитория или формат. Дополнительные изменения
|
||||
можно объединять в одном payload, но основное действие должно фактически менять свою
|
||||
часть пары. Формат ограничен значениями `Очно` и `Онлайн`.
|
||||
`CANCEL` не принимает целевую дату и новые ресурсы. Для `MOVE` обязателен новый временной
|
||||
слот, для `REPLACE` — преподаватель, аудитория или формат. Перенос даты требует явно
|
||||
выбранного слота эффективной сетки целевого дня. Дополнительные изменения преподавателя,
|
||||
аудитории и формата можно объединить с `MOVE`, но основное действие должно фактически
|
||||
менять свою часть пары. Формат ограничен значениями `Очно` и `Онлайн`; дисциплина и тип
|
||||
занятия не изменяются.
|
||||
|
||||
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
|
||||
расписания на конкретную дату. Перед записью `ScheduleOverrideService`:
|
||||
расписания. Исходная пара всегда идентифицируется как `baseRuleSlotId + lessonDate`, а
|
||||
`targetLessonDate` хранит только новую дату единственного занятия. Перед записью
|
||||
`ScheduleOverrideService`:
|
||||
|
||||
1. захватывает transaction advisory lock PostgreSQL для tenant-БД и даты;
|
||||
1. захватывает transaction advisory lock PostgreSQL для исходных и целевых дат в стабильном порядке;
|
||||
2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
|
||||
3. доказывает существование исходной пары с учётом семестра, календарного графика,
|
||||
чётности, недели начала, лимита часов и lifecycle;
|
||||
4. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
|
||||
5. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы;
|
||||
6. сохраняет изменение только при отсутствии конфликта.
|
||||
4. для новой даты проверяет границы того же семестра, активность правила и дисциплины,
|
||||
учебный день всех затронутых групп и эффективную сетку времени;
|
||||
5. проверяет lifecycle итоговых преподавателя и аудитории на целевую дату;
|
||||
6. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
|
||||
7. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы целевого дня;
|
||||
8. сохраняет изменение только при отсутствии конфликта.
|
||||
|
||||
В поиске `MOVE` удаляет исходное вхождение и добавляет занятие в целевой день, включая
|
||||
случай, когда в запрос попала только целевая дата. День недели, номер недели и чётность
|
||||
вычисляются заново по семестру, а расход академических часов остаётся у исходной пары.
|
||||
`CANCEL` скрывает занятие. Удаление override возвращает текущий вариант, который снова
|
||||
формируется базовым правилом.
|
||||
|
||||
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
|
||||
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
|
||||
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
|
||||
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
|
||||
для backend-pod, поэтому два конкурентных изменения одной даты проверяются последовательно.
|
||||
для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
|
||||
дату и одинаковые ресурсы проверяются последовательно.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@ erDiagram
|
||||
BIGSERIAL id PK
|
||||
BIGINT base_rule_slot_id FK
|
||||
DATE lesson_date
|
||||
DATE target_lesson_date
|
||||
VARCHAR action
|
||||
BIGINT new_time_slot_id FK
|
||||
BIGINT new_classroom_id FK
|
||||
@@ -873,7 +874,8 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID изменения |
|
||||
| `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` |
|
||||
| `new_time_slot_id` | BIGINT FK → time_slots | Новый временной слот |
|
||||
| `new_classroom_id` | BIGINT FK → classrooms | Новая аудитория |
|
||||
@@ -887,12 +889,19 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
||||
одной и той же пары. Базовая схема V1 добавляет структурные инварианты:
|
||||
|
||||
- `CANCEL` не содержит новых ресурсов;
|
||||
- `MOVE` содержит новый временной слот или аудиторию;
|
||||
- `REPLACE` содержит нового преподавателя, аудиторию или формат;
|
||||
- `MOVE` содержит новый временной слот;
|
||||
- `REPLACE` не содержит нового времени и содержит нового преподавателя, аудиторию или формат;
|
||||
- `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`.
|
||||
- `target_lesson_date` отличается от `lesson_date`, разрешена только для `MOVE` и требует
|
||||
`new_time_slot_id`.
|
||||
|
||||
Фактическое существование пары, реальность изменения и ресурсные конфликты зависят от
|
||||
календаря конкретной даты и проверяются транзакционным сервисом, а не SQL CHECK.
|
||||
Частичный индекс `idx_schedule_overrides_target_date` ускоряет выборку входящих переносов,
|
||||
а индекс исходной даты `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/
|
||||
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
||||
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
||||
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
||||
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
|
||||
├── index.html # 🔐 Страница авторизации (общая)
|
||||
├── script.js # Логика авторизации
|
||||
@@ -59,7 +61,8 @@ frontend/
|
||||
│ │ ├── subjects.js # Управление дисциплинами
|
||||
│ │ ├── university-structure.js # Кафедры, специальности и профили обучения
|
||||
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
|
||||
│ │ ├── schedule-view.js # Read-only просмотр расписаний в матрицах
|
||||
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
||||
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
||||
│ │ ├── schedule.js # Конструктор правил расписания
|
||||
│ │ ├── academic-calendar.js # Календарные учебные графики
|
||||
│ │ └── auditorium-workload.js # Загруженность аудиторий, преподавателей и кафедр
|
||||
@@ -152,7 +155,7 @@ frontend/
|
||||
| `subjects` | Дисциплины | `/api/subjects` |
|
||||
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
@@ -169,7 +172,9 @@ frontend/
|
||||
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
|
||||
- Компоновка `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`.
|
||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
||||
- Вкладка `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/admin/schedule-rules`;
|
||||
- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API;
|
||||
- разовая правка конкретного занятия через боковую панель: перенос даты/времени, замена ресурсов, отмена и возврат по правилу;
|
||||
- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`;
|
||||
- аудитории, оборудование, календарный график и загруженность.
|
||||
|
||||
@@ -298,7 +303,7 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
|
||||
|
||||
### Просмотр расписаний (`/admin/#schedule-view`)
|
||||
|
||||
Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам.
|
||||
Роль `SCHEDULE_VIEWER` видит только доступную для чтения вкладку просмотра расписаний. Доступны фильтры по семестру, дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам.
|
||||
|
||||
### Преподаватель (`/teacher/`)
|
||||
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
}
|
||||
|
||||
.schedule-view-extra-grid {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -780,6 +780,438 @@
|
||||
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 {
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--bg-card-border);
|
||||
@@ -944,4 +1376,46 @@
|
||||
.schedule-view-day-tabs {
|
||||
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 { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
|
||||
|
||||
const ROLE_DEPARTMENT = 'DEPARTMENT';
|
||||
const TARGET_GROUPS = 'groups';
|
||||
@@ -59,6 +60,7 @@ export async function initScheduleView() {
|
||||
const tables = document.getElementById('schedule-view-tables');
|
||||
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
|
||||
const departmentSelect = document.getElementById('schedule-view-department');
|
||||
const semesterSelect = document.getElementById('schedule-view-semester');
|
||||
|
||||
const state = {
|
||||
dictionaries: {
|
||||
@@ -71,8 +73,11 @@ export async function initScheduleView() {
|
||||
activeSectionIndex: 0,
|
||||
mobileDay: 1,
|
||||
range: null,
|
||||
totalLessons: 0
|
||||
totalLessons: 0,
|
||||
lessons: [],
|
||||
overrides: []
|
||||
};
|
||||
let overridePanel = createEmptyOverridePanel();
|
||||
const mobileMedia = window.matchMedia(MOBILE_QUERY);
|
||||
|
||||
setDefaultDate(dateInput);
|
||||
@@ -97,8 +102,22 @@ export async function initScheduleView() {
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
});
|
||||
semesterSelect?.addEventListener('change', () => {
|
||||
synchronizeDateWithSemester();
|
||||
updatePeriodLabel();
|
||||
resetResults('Нажмите «Показать», чтобы обновить расписание');
|
||||
});
|
||||
resultPreviousButton?.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') {
|
||||
mobileMedia.addEventListener('change', renderActiveSection);
|
||||
} else if (typeof mobileMedia.addListener === 'function') {
|
||||
@@ -107,6 +126,13 @@ export async function initScheduleView() {
|
||||
|
||||
try {
|
||||
state.dictionaries = await loadDictionaries(role, userDepartmentId);
|
||||
selectCurrentSemester();
|
||||
overridePanel = createScheduleOverridePanel({
|
||||
role,
|
||||
dictionaries: state.dictionaries,
|
||||
getRange: () => state.range,
|
||||
onChanged: loadData
|
||||
});
|
||||
configureDepartmentAccess();
|
||||
updateEntityFieldVisibility();
|
||||
await loadData();
|
||||
@@ -192,7 +218,7 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
const target = activeTarget();
|
||||
const range = twoWeekRange(dateInput.value);
|
||||
const range = scheduleRangeForSemester(dateInput.value, selectedSemester());
|
||||
const previousKey = state.sections[state.activeSectionIndex]?.key;
|
||||
state.range = range;
|
||||
updatePeriodLabel(range);
|
||||
@@ -213,7 +239,14 @@ export async function initScheduleView() {
|
||||
addOptional(params, 'parity', valueOf('schedule-view-parity'));
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
const message = error.message || 'Ошибка загрузки расписания';
|
||||
@@ -245,6 +278,7 @@ export async function initScheduleView() {
|
||||
}
|
||||
|
||||
function renderLessons(lessons, range, target, previousKey) {
|
||||
state.lessons = lessons;
|
||||
state.totalLessons = lessons.length;
|
||||
if (!lessons.length) {
|
||||
state.sections = [];
|
||||
@@ -581,6 +615,9 @@ export async function initScheduleView() {
|
||||
lesson.classroomId || lesson.classroomName || '',
|
||||
lesson.lessonTypeId || lesson.lessonTypeName || '',
|
||||
lesson.lessonFormat || '',
|
||||
lesson.scheduleOverrideId || '',
|
||||
lesson.overrideAction || '',
|
||||
lesson.originalLessonDate || '',
|
||||
valueListSignature(lesson.groupIds, lesson.groupNames),
|
||||
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
|
||||
].join(':');
|
||||
@@ -672,19 +709,36 @@ export async function initScheduleView() {
|
||||
|
||||
const weeksText = getLessonWeeksText(lesson);
|
||||
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 `
|
||||
<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>
|
||||
${weeksBadge}
|
||||
</div>
|
||||
${overrideBadge}
|
||||
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
|
||||
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
|
||||
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
|
||||
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
|
||||
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
|
||||
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
|
||||
${editButton}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -699,6 +753,7 @@ export async function initScheduleView() {
|
||||
state.activeSectionIndex = 0;
|
||||
state.mobileDay = 1;
|
||||
state.totalLessons = 0;
|
||||
state.lessons = [];
|
||||
renderResultNavigation();
|
||||
tableTitle.textContent = 'Расписание';
|
||||
tableCaption.textContent = message;
|
||||
@@ -714,7 +769,36 @@ export async function initScheduleView() {
|
||||
periodLabel.textContent = 'Период не выбран';
|
||||
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) {
|
||||
@@ -727,15 +811,23 @@ export async function initScheduleView() {
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyOverridePanel() {
|
||||
return {
|
||||
setRegistry() {},
|
||||
openLesson() {},
|
||||
openRegistry() {}
|
||||
};
|
||||
}
|
||||
|
||||
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/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/departments'),
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/admin/calendar/years').catch(() => [])
|
||||
api.get('/api/schedule/semesters')
|
||||
]);
|
||||
|
||||
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-subject', subjects, 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 {
|
||||
groups,
|
||||
teachers,
|
||||
classrooms,
|
||||
departments,
|
||||
semesters: (academicYears || []).flatMap(year => year.semesters || [])
|
||||
semesters: semesters || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,8 +1068,43 @@ function mobileDayCaption(datesByParity) {
|
||||
return 'Нечётная / Чётная';
|
||||
}
|
||||
|
||||
function periodCaption(range) {
|
||||
return 'Учебное расписание';
|
||||
function periodCaption(range, semester = null) {
|
||||
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) {
|
||||
|
||||
@@ -47,12 +47,18 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="date" id="schedule-view-date" style="display: none;">
|
||||
<input type="date" id="schedule-view-date" hidden>
|
||||
</div>
|
||||
|
||||
<details class="schedule-view-extra-filters">
|
||||
<summary>Дополнительные фильтры</summary>
|
||||
<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">
|
||||
<label for="schedule-view-subject">Дисциплина</label>
|
||||
<select id="schedule-view-subject">
|
||||
@@ -87,6 +93,10 @@
|
||||
<span class="muted" id="schedule-view-count">0 занятий</span>
|
||||
</div>
|
||||
<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="Предыдущее расписание">
|
||||
<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>
|
||||
@@ -116,3 +126,102 @@
|
||||
</div>
|
||||
</div>
|
||||
</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": {
|
||||
"backend_src_main_java_com_magistr_app_config_auth_authorizationinterceptor_authorizationinterceptor": {
|
||||
"code_fingerprint": "70b01b3fa22e058ee997250c52cc0affee54ab37aa5f8cc7eb74411487f1bbbf",
|
||||
@@ -22,7 +22,7 @@
|
||||
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
|
||||
}
|
||||
],
|
||||
"score": 2.75167804,
|
||||
"score": 2.237417033,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
@@ -38,7 +38,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.977626859,
|
||||
"score": 0.794918212,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -54,11 +54,27 @@
|
||||
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
|
||||
}
|
||||
],
|
||||
"score": 0.822552197,
|
||||
"score": 0.668825448,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
|
||||
"status": "tentative",
|
||||
"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": {
|
||||
"code_fingerprint": "",
|
||||
"label": "ConfigMapUpdater",
|
||||
@@ -70,45 +86,81 @@
|
||||
"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",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_config_tenant_tenantconfigwatcher_tenantconfigwatcher": {
|
||||
"code_fingerprint": "83dcf36dd5d7c969fa4d454909cc245145949738b462f569eb1367663e33492d",
|
||||
"label": "TenantConfigWatcher",
|
||||
"last": "2026-07-10T18:14:03.213067+00:00",
|
||||
"backend_src_main_java_com_magistr_app_config_tenant_tenantconfig_tenantconfig": {
|
||||
"code_fingerprint": "22d2e92c23a81959e7c20ad48ff92fdea8fb2bc37b0dda0dfd33d86eb0267e85",
|
||||
"label": "TenantConfig",
|
||||
"last": "2026-07-12T22:09:47.294749+00:00",
|
||||
"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",
|
||||
"outcome": "useful",
|
||||
"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",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_config_tenant_tenantroutingdatasource_tenantroutingdatasource": {
|
||||
"code_fingerprint": "2efea217f4236134681ee86d870bd0d5ffcd653e312637e115b50768ada975f0",
|
||||
"code_fingerprint": "4dbf5864ca471e92d143acb834ed61b9eb23e2cf2ebc3ab9ab0757eb2439e727",
|
||||
"label": "TenantRoutingDataSource",
|
||||
"last": "2026-07-11T22:22:23.259230+00:00",
|
||||
"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-11T22:22:23.259230+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.977626859,
|
||||
"score": 2.421724349,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_controller_databasecontroller": {
|
||||
"code_fingerprint": "d4e6c182c5a40917feb56354fde6f6c9764d95fd79dc72d4bc760b1fd2d24cdc",
|
||||
"code_fingerprint": "e888a1e23f2d0ae6993fc2d767f6a4ef026c935ff2300d7d0c91bb805c22017f",
|
||||
"label": "DatabaseController.java",
|
||||
"last": "2026-07-11T22:22:23.259230+00:00",
|
||||
"provenance": [
|
||||
@@ -118,13 +170,34 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.977626859,
|
||||
"score": 0.794918212,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
|
||||
"status": "tentative",
|
||||
"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": {
|
||||
"code_fingerprint": "3fc674c29337df18a9494ada4a6a150b522f6f5fc1f044d003c33eb4272e380d",
|
||||
"code_fingerprint": "a637ea110ec1c5fef0c0a1d88cccbc54d56b3b6ca66a6c1d3659871bd5ff7f4e",
|
||||
"label": "GlobalExceptionHandler",
|
||||
"last": "2026-07-10T18:14:03.213067+00:00",
|
||||
"provenance": [
|
||||
@@ -134,29 +207,34 @@
|
||||
"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",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_controller_schedulecontroller": {
|
||||
"code_fingerprint": "74f022a62ea5440fb0c4c66b86df005e5e77f7f1fda8a4b718d95bd55104ca13",
|
||||
"code_fingerprint": "40af5947a24d994d92566c135abe5080eec04f5f266b40e04abfa3c5fac18a84",
|
||||
"label": "ScheduleController.java",
|
||||
"last": "2026-07-11T22:22:23.259230+00:00",
|
||||
"last": "2026-07-21T19:52:13.631700+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-21T19:52:13.631700+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11T22:22:23.259230+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.977626859,
|
||||
"score": 1.794042273,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_controller_scheduleoverridecontroller_scheduleoverridecontroller": {
|
||||
"code_fingerprint": "b9f25c272cab98280e15616873c10e65380ba04b5f3dc4cbfa50f2c16b5429f1",
|
||||
"code_fingerprint": "91be9aa1507e774d0168c858ad5cde838f8d6650e2169fae81cc1d551cbc5ff9",
|
||||
"label": "ScheduleOverrideController",
|
||||
"last": "2026-07-12T14:52:07.844079+00:00",
|
||||
"provenance": [
|
||||
@@ -166,7 +244,7 @@
|
||||
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
|
||||
}
|
||||
],
|
||||
"score": 0.993275993,
|
||||
"score": 0.807642679,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -187,7 +265,7 @@
|
||||
"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",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -208,7 +286,7 @@
|
||||
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
|
||||
}
|
||||
],
|
||||
"score": 1.823406413,
|
||||
"score": 1.482630056,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -224,13 +302,29 @@
|
||||
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
|
||||
}
|
||||
],
|
||||
"score": 0.999936118,
|
||||
"score": 0.813058094,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
|
||||
"status": "tentative",
|
||||
"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": {
|
||||
"code_fingerprint": "8a39b28d2b7d0d2ca60b188a5c9b97c71187b5845dd8bdc85206f117b9d4984b",
|
||||
"code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10",
|
||||
"label": "SemesterRepository",
|
||||
"last": "2026-07-12T21:48:38.060218+00:00",
|
||||
"provenance": [
|
||||
@@ -240,26 +334,31 @@
|
||||
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
|
||||
}
|
||||
],
|
||||
"score": 0.999936118,
|
||||
"score": 0.813058094,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_service_schedulequeryservice_schedulequeryservice": {
|
||||
"code_fingerprint": "47d0183b1249d194c9825046e0942a1867ad5c781ed90f376b975095e8807f68",
|
||||
"code_fingerprint": "96b20e57f1e9d75f8f148a809e40d5ad98038423922f21c1f2c080bca6f76926",
|
||||
"label": "ScheduleQueryService",
|
||||
"last": "2026-07-12T14:52:07.844079+00:00",
|
||||
"last": "2026-07-21T19:52:13.631700+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-21T19:52:13.631700+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12T14:52:07.844079+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
|
||||
}
|
||||
],
|
||||
"score": 0.993275993,
|
||||
"score": 1.80676674,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
},
|
||||
"backend_src_test_java_com_magistr_app_model_scheduleruletest_scheduleruletest": {
|
||||
"code_fingerprint": "73cb9594bd5dc29ba9d9bde11e860d2c182b205b36062df87a8a8783611ffe79",
|
||||
@@ -272,13 +371,13 @@
|
||||
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
|
||||
}
|
||||
],
|
||||
"score": 0.823470295,
|
||||
"score": 0.669571962,
|
||||
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"compose_backend_service": {
|
||||
"code_fingerprint": "a175ccc6d907386f120574edb6296a6af416da683b869c7da71f60835f2daaa6",
|
||||
"code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9",
|
||||
"label": "Backend-сервис Docker Compose",
|
||||
"last": "2026-07-11T22:22:23.259230+00:00",
|
||||
"provenance": [
|
||||
@@ -288,7 +387,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.977626859,
|
||||
"score": 0.794918212,
|
||||
"source_file": "compose.yaml",
|
||||
"status": "tentative",
|
||||
"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
|
||||
|
||||
_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
|
||||
|
||||
- 6 useful · 1 dead ends · 0 corrected · 0 unmarked
|
||||
- 9 useful · 1 dead ends · 0 corrected · 0 unmarked
|
||||
|
||||
## Lessons
|
||||
|
||||
**Preferred sources** — corroborated by ≥2 useful results; start here.
|
||||
|
||||
- `TenantRoutingDataSource` (3× useful)
|
||||
- `TenantConfigWatcher` (3× useful)
|
||||
- `AuthorizationInterceptor` (3× useful)
|
||||
- `ScheduleQueryService` (2× useful)
|
||||
- `ScheduleController.java` (2× useful)
|
||||
- `DatabaseController` (2× useful)
|
||||
- `ScheduleGeneratorService` (2× useful)
|
||||
- `ScheduleRuleAdminController` (2× useful)
|
||||
- `ScheduleRule` (2× useful)
|
||||
|
||||
**Tentative** — useful in fewer than 2 results; verify before relying.
|
||||
|
||||
- `Semester` (1× useful)
|
||||
- `DataInitializer` (1× useful)
|
||||
- `TenantConfig` (1× useful)
|
||||
- `ScheduleRuleSlot` (1× useful)
|
||||
- `SemesterRepository` (1× useful)
|
||||
- `ScheduleOverrideController` (1× useful)
|
||||
- `ScheduleQueryService` (1× useful)
|
||||
- `Backend-сервис Docker Compose` (1× useful)
|
||||
- `DatabaseController.java` (1× useful)
|
||||
- `JwtTokenService` (1× useful)
|
||||
- `ScheduleController.java` (1× useful)
|
||||
- `TenantRoutingDataSource` (1× useful)
|
||||
- `ConfigMapUpdater` (1× useful)
|
||||
- `GlobalExceptionHandler` (1× useful)
|
||||
- `TenantConfigWatcher` (1× useful)
|
||||
- `ScheduleRuleTest` (1× useful)
|
||||
- `RequireRoles` (1× useful)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user