From 92ff87a917c845c60afec0c92485e94f26b1d85e Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 22 Jul 2026 00:15:35 +0300 Subject: [PATCH] =?UTF-8?q?#2=20-=20=D1=82=D0=B5=D1=81=D1=82,=20#5=20-=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BE.=20=D0=A0=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/ScheduleController.java | 29 +- .../ScheduleOverrideController.java | 17 +- .../controller/TimeSlotAdminController.java | 37 +- .../magistr/app/dto/RenderedLessonDto.java | 75 ++- .../dto/ScheduleOverrideAvailabilityDto.java | 12 + .../magistr/app/dto/ScheduleOverrideDto.java | 108 +++- .../magistr/app/model/ScheduleOverride.java | 11 + .../ScheduleOverrideRepository.java | 62 +- .../app/service/EffectiveTimeSlotService.java | 76 +++ .../app/service/ScheduleOverrideService.java | 346 ++++++++-- .../app/service/ScheduleQueryService.java | 145 ++++- .../main/resources/db/migration/V1__init.sql | 64 +- .../controller/ScheduleControllerTest.java | 55 ++ .../ScheduleOverrideControllerTest.java | 48 ++ ...eduleOverrideMigrationIntegrationTest.java | 119 +++- ...PostgreSqlAdvisoryLockIntegrationTest.java | 64 ++ .../service/ScheduleOverrideServiceTest.java | 327 +++++++++- .../ScheduleQueryServiceOverrideTest.java | 108 ++++ ...heduleQueryServiceTeacherOverrideTest.java | 7 +- docs/API.md | 84 ++- docs/BUSINESS_LOGIC.md | 38 +- docs/DATABASE.md | 21 +- docs/FRONTEND.md | 15 +- frontend/admin/css/auditorium-workload.css | 476 +++++++++++++- .../admin/js/views/schedule-override-panel.js | 611 ++++++++++++++++++ frontend/admin/js/views/schedule-view.js | 148 ++++- frontend/admin/views/schedule-view.html | 111 +++- frontend/tests/schedule-overrides.test.mjs | 64 ++ .../tests/schedule-view-semesters.test.mjs | 53 ++ graphify-out/.graphify_learning.json | 181 ++++-- ...лнительные_фильры_просмотра_расписаний_нужно.md | 24 + ...у_в_выборе_аудитории_показаны_корпус_и_этаж.md | 23 + graphify-out/reflections/LESSONS.md | 16 +- 33 files changed, 3324 insertions(+), 251 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/dto/ScheduleOverrideAvailabilityDto.java create mode 100644 backend/src/main/java/com/magistr/app/service/EffectiveTimeSlotService.java create mode 100644 backend/src/test/java/com/magistr/app/controller/ScheduleControllerTest.java create mode 100644 frontend/admin/js/views/schedule-override-panel.js create mode 100644 frontend/tests/schedule-overrides.test.mjs create mode 100644 frontend/tests/schedule-view-semesters.test.mjs create mode 100644 graphify-out/memory/query_20260721_195213_в_дополнительные_фильры_просмотра_расписаний_нужно.md create mode 100644 graphify-out/memory/query_20260721_204948_почему_в_выборе_аудитории_показаны_корпус_и_этаж.md diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java index eb326af..0119113 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleController.java @@ -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 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() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java index 0500e88..f9d40be 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java @@ -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 getAll() { - return scheduleOverrideService.getAll(); + public List 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 diff --git a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java index d176e4b..f311b9e 100644 --- a/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/TimeSlotAdminController.java @@ -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 effectiveSlots(LocalDate date) { - TimeSlotScope defaultScope = defaultScope(); - TimeSlotScope effectiveScope = effectiveScope(date); - List defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId()); - if (effectiveScope.getId().equals(defaultScope.getId())) { - return defaultSlots; - } - - Map 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) diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 3fee5f1..367bce3 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -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 subgroupIds, + List subgroupNames, + List groupIds, + List 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 + ); + } } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideAvailabilityDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideAvailabilityDto.java new file mode 100644 index 0000000..95825ea --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideAvailabilityDto.java @@ -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 availableDates +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java index 73c0c05..aafe4e1 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleOverrideDto.java @@ -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 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() + ); + } } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java index b914817..c965c4c 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleOverride.java @@ -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; } diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java index 4028ca2..0547467 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java @@ -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 findByLessonDateBetweenWithDetails( @Param("startDate") LocalDate startDate, @@ -29,8 +33,62 @@ public interface ScheduleOverrideRepository extends JpaRepository findByBaseRuleSlotIdAndLessonDate(Long baseRuleSlotId, LocalDate lessonDate); - @Query("select o.lessonDate from ScheduleOverride o where o.id = :id") - Optional 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 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 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 findAffectingDatesWithDetails(@Param("dates") Collection dates); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select o from ScheduleOverride o where o.id = :id") diff --git a/backend/src/main/java/com/magistr/app/service/EffectiveTimeSlotService.java b/backend/src/main/java/com/magistr/app/service/EffectiveTimeSlotService.java new file mode 100644 index 0000000..297215b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/EffectiveTimeSlotService.java @@ -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 findEffective(LocalDate date) { + if (date == null) { + throw new IllegalArgumentException("Дата временной сетки обязательна"); + } + TimeSlotScope defaultScope = defaultScope(); + TimeSlotScope effectiveScope = effectiveScope(date, defaultScope); + List defaultSlots = timeSlotRepository + .findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId()); + if (Objects.equals(effectiveScope.getId(), defaultScope.getId())) { + return defaultSlots; + } + + Map 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("Базовая сетка времени не настроена")); + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java index e9a2f21..320f396 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleOverrideService.java @@ -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 getAll(LocalDate startDate, LocalDate endDate) { + if ((startDate == null) != (endDate == null)) { + throw new IllegalArgumentException("Дата начала и дата окончания должны быть указаны вместе"); + } + if (startDate != null) { + validateRange(startDate, endDate); + } + List overrides = startDate == null + ? scheduleOverrideRepository.findAllWithRegistryDetails() + : scheduleOverrideRepository.findForRegistry(startDate, endDate); + return overrides.stream() + .map(this::toDto) + .toList(); } @Transactional(readOnly = true) public List 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 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 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 baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate()); List 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 baseDay, List sourceOccurrences) { Long currentId = current == null ? null : current.getId(); - List effectiveOverrides = scheduleOverrideRepository - .findByLessonDateBetweenWithDetails(command.lessonDate(), command.lessonDate()) + Set affectedDates = new TreeSet<>(List.of(command.lessonDate(), command.effectiveDate())); + List storedOverrides = affectedDates.size() == 1 + ? scheduleOverrideRepository.findByLessonDateBetweenWithDetails( + command.lessonDate(), + command.lessonDate() + ) + : scheduleOverrideRepository.findAffectingDatesWithDetails(affectedDates); + List 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 effectiveDay = scheduleQueryService.buildEffectiveDayForAllGroups( - baseDay, - command.lessonDate(), - effectiveOverrides - ); - List candidateOccurrences = effectiveDay.stream() + List effectiveSchedule = Objects.equals(command.lessonDate(), command.effectiveDate()) + ? scheduleQueryService.buildEffectiveDayForAllGroups( + baseDay, + command.lessonDate(), + effectiveOverrides + ) + : scheduleQueryService.buildEffectiveDaysForAllGroups(affectedDates, effectiveOverrides); + List 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 otherOccurrences = effectiveDay.stream() - .filter(lesson -> !Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())) + List otherOccurrences = effectiveSchedule.stream() + .filter(lesson -> !isCandidateOccurrence(lesson, command)) + .filter(lesson -> Objects.equals(lesson.date(), command.effectiveDate())) .toList(); Map 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 sourceOccurrences, List 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 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 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 affectedGroupIds = sourceOccurrences.stream() + .flatMap(lesson -> safeIds(lesson.groupIds()).stream()) + .collect(Collectors.toSet()); + List 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 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()); } } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index c70689e..d23b63a 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -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 buildEffectiveDaysForAllGroups( + Collection dates, + Collection overrides + ) { + Set requestedDates = Optional.ofNullable(dates) + .orElseGet(List::of) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toCollection(TreeSet::new)); + if (requestedDates.isEmpty()) { + return List.of(); + } + List 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 search(Long groupId, Long teacherId, Long classroomId, @@ -137,29 +174,26 @@ public class ScheduleQueryService { boolean enforceInteractiveGroupLimit) { validateRange(startDate, endDate); List generatedLessons; + List scopedGroups = null; boolean teacherOnlySearch = groupId == null && departmentId == null && teacherId != null; if (teacherOnlySearch) { generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); } else { - List groups = resolveGroups( + scopedGroups = resolveGroups( groupId, departmentId, startDate, endDate, enforceInteractiveGroupLimit ); - generatedLessons = scheduleGeneratorService.buildScheduleForGroups(groups, startDate, endDate); + generatedLessons = scheduleGeneratorService.buildScheduleForGroups(scopedGroups, startDate, endDate); } List overrideSnapshot = scheduleOverrideRepository .findByLessonDateBetweenWithDetails(startDate, endDate); - if (teacherOnlySearch) { - generatedLessons = addOverrideSourceOccurrencesForTeacher( - generatedLessons, - teacherId, - overrideSnapshot - ); - } + generatedLessons = new ArrayList<>(generatedLessons); + addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups); List 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 addOverrideSourceOccurrencesForTeacher( + private void addOverrideSourceOccurrences( List generatedLessons, - Long teacherId, - Collection overrideSnapshot + Collection overrideSnapshot, + List scopedGroups ) { Map> 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 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 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 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 applyOverrides(List 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() ); } } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 175b09a..d040527 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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; + -- ========================================== -- Инварианты правил расписания -- ========================================== diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleControllerTest.java new file mode 100644 index 0000000..cba897e --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleControllerTest.java @@ -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; + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java index 13c169c..4e4be18 100644 --- a/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleOverrideControllerTest.java @@ -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)); + } } diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java index a6b305a..af3067e 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java @@ -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) { } } diff --git a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java index 6040310..3ad6711 100644 --- a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java @@ -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 first = executor.submit(() -> tryMove( + TEST_DATE.minusDays(7), TEST_DATE, 11L, 21L, ready, start + )); + Future 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 { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java index cdd8440..ca67b86 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleOverrideServiceTest.java @@ -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 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 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)), diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java index b414e49..6e99f6f 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceOverrideTest.java @@ -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 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 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); diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java index 6eff294..e608e42 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTeacherOverrideTest.java @@ -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 diff --git a/docs/API.md b/docs/API.md index edbb1f1..66ff3b3 100644 --- a/docs/API.md +++ b/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"] +} +``` ## Загруженность diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index c9d9232..57765b5 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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, поэтому два конкурентных переноса с разных исходных дат на одну целевую +дату и одинаковые ресурсы проверяются последовательно. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 6815389..a898570 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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-ограничения, конкурентно безопасные триггеры и комментарии | ### Этап разработки diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a56fc3c..6f12fed 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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/`) diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 677c985..6378149 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -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%; + } } diff --git a/frontend/admin/js/views/schedule-override-panel.js b/frontend/admin/js/views/schedule-override-panel.js new file mode 100644 index 0000000..a54c34c --- /dev/null +++ b/frontend/admin/js/views/schedule-override-panel.js @@ -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 => + `` + ).join(''); + classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom => + `` + ).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 ` + + `; + } + + async function loadTimeSlots(preference) { + timeSelect.disabled = true; + timeSelect.innerHTML = ''; + 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 = '' + state.timeSlots.map(slot => + `` + ).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 = '
За выбранный период разовых изменений нет.
'; + 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 ` +
+
+ ${escapeHtml(ACTION_LABELS[override.action] || override.action)} + +
+

${escapeHtml(override.subjectName || 'Занятие')}

+

${escapeHtml([ + override.lessonTypeName, + (override.groupNames || []).join(', '), + result || 'Параметры изменены' + ].filter(Boolean).join(' · '))}

+ ${override.comment ? `
${escapeHtml(override.comment)}
` : ''} + +
+ `; + }).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 }, () => '').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 ``; + }).join(''); + return ` +
+

${MONTH_LABELS[monthIndex]} ${year}

+
+ ${DAY_LABELS.map(label => `${label}`).join('')} + ${blanks}${days} +
+
+ `; +} + +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('-'); +} diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 6ed0708..500b58f 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -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 ? `${escapeHtml(weeksText)}` : ''; + const sourceDate = lesson.originalLessonDate || lesson.date; + const overrideBadge = lesson.scheduleOverrideId + ? 'Разовая правка' + : ''; + const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate + ? ` + + ` + : ''; return `
-
+
${escapeHtml(lesson.subjectName || 'Без дисциплины')} ${weeksBadge}
+ ${overrideBadge}
${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}
${subgroupText ? `
${escapeHtml(subgroupText)}
` : ''}
${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}
${escapeHtml(lesson.classroomName || 'Аудитория не указана')}
${meta ? `
${escapeHtml(meta)}
` : ''} ${lesson.scheduleRuleSlotId ? `
ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}
` : ''} + ${editButton}
`; } @@ -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) { diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index 46b9744..374117e 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -47,12 +47,18 @@
- +
Дополнительные фильтры
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+ + + +
+ + + + + + + diff --git a/frontend/tests/schedule-overrides.test.mjs b/frontend/tests/schedule-overrides.test.mjs new file mode 100644 index 0000000..ea5480e --- /dev/null +++ b/frontend/tests/schedule-overrides.test.mjs @@ -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); +}); diff --git a/frontend/tests/schedule-view-semesters.test.mjs b/frontend/tests/schedule-view-semesters.test.mjs new file mode 100644 index 0000000..6634822 --- /dev/null +++ b/frontend/tests/schedule-view-semesters.test.mjs @@ -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' + ); +}); diff --git a/graphify-out/.graphify_learning.json b/graphify-out/.graphify_learning.json index ac73e7b..2fa3308 100644 --- a/graphify-out/.graphify_learning.json +++ b/graphify-out/.graphify_learning.json @@ -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 diff --git a/graphify-out/memory/query_20260721_195213_в_дополнительные_фильры_просмотра_расписаний_нужно.md b/graphify-out/memory/query_20260721_195213_в_дополнительные_фильры_просмотра_расписаний_нужно.md new file mode 100644 index 0000000..e4db140 --- /dev/null +++ b/graphify-out/memory/query_20260721_195213_в_дополнительные_фильры_просмотра_расписаний_нужно.md @@ -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 \ No newline at end of file diff --git a/graphify-out/memory/query_20260721_204948_почему_в_выборе_аудитории_показаны_корпус_и_этаж.md b/graphify-out/memory/query_20260721_204948_почему_в_выборе_аудитории_показаны_корпус_и_этаж.md new file mode 100644 index 0000000..b790382 --- /dev/null +++ b/graphify-out/memory/query_20260721_204948_почему_в_выборе_аудитории_показаны_корпус_и_этаж.md @@ -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 \ No newline at end of file diff --git a/graphify-out/reflections/LESSONS.md b/graphify-out/reflections/LESSONS.md index a1af700..4a9c60b 100644 --- a/graphify-out/reflections/LESSONS.md +++ b/graphify-out/reflections/LESSONS.md @@ -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)