3 Задача

This commit is contained in:
dipatrik10
2026-08-11 01:55:02 +03:00
parent 5276729a69
commit 519864b962
42 changed files with 4083 additions and 21 deletions

View File

@@ -0,0 +1,64 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherChangeCandidatesDto;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.dto.TeacherChangeRequestPayload;
import com.magistr.app.dto.TeacherChangeReviewRequest;
import com.magistr.app.model.Role;
import com.magistr.app.service.TeacherChangeRequestService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/teacher-change-requests")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
public class TeacherChangeRequestController {
private final TeacherChangeRequestService requestService;
public TeacherChangeRequestController(TeacherChangeRequestService requestService) {
this.requestService = requestService;
}
@GetMapping
public List<TeacherChangeRequestDto> getAll(@RequestParam(required = false) String status) {
return requestService.getAll(status);
}
@GetMapping("/candidates")
public TeacherChangeCandidatesDto getCandidates(
@RequestParam Long baseRuleSlotId,
@RequestParam LocalDate lessonDate,
@RequestParam(required = false) LocalDate targetDate
) {
return requestService.getCandidates(baseRuleSlotId, lessonDate, targetDate);
}
@PostMapping
public TeacherChangeRequestDto create(@RequestBody TeacherChangeRequestPayload request) {
return requestService.create(request);
}
@PostMapping("/{id}/review")
public TeacherChangeRequestDto review(
@PathVariable Long id,
@RequestBody TeacherChangeReviewRequest request
) {
return requestService.review(id, request);
}
@DeleteMapping("/{id}")
public TeacherChangeRequestDto cancel(@PathVariable Long id) {
return requestService.cancel(id);
}
}

View File

@@ -0,0 +1,62 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.dto.TeacherPreferenceMetaDto;
import com.magistr.app.dto.TeacherPreferenceRequest;
import com.magistr.app.dto.TeacherPreferenceReviewRequest;
import com.magistr.app.model.Role;
import com.magistr.app.service.TeacherPreferenceService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/teacher-preferences")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
public class TeacherPreferenceController {
private final TeacherPreferenceService preferenceService;
public TeacherPreferenceController(TeacherPreferenceService preferenceService) {
this.preferenceService = preferenceService;
}
@GetMapping("/meta")
public TeacherPreferenceMetaDto getMeta() {
return preferenceService.getMeta();
}
@GetMapping
public List<TeacherPreferenceDto> getAll(
@RequestParam(required = false) Long semesterId,
@RequestParam(required = false) String status
) {
return preferenceService.getAll(semesterId, status);
}
@PostMapping
public TeacherPreferenceDto create(@RequestBody TeacherPreferenceRequest request) {
return preferenceService.create(request);
}
@PostMapping("/{id}/review")
public TeacherPreferenceDto review(
@PathVariable Long id,
@RequestBody TeacherPreferenceReviewRequest request
) {
return preferenceService.review(id, request);
}
@DeleteMapping("/{id}")
public TeacherPreferenceDto cancel(@PathVariable Long id) {
return preferenceService.cancel(id);
}
}

View File

@@ -0,0 +1,23 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record TeacherChangeCandidatesDto(
RenderedLessonDto lesson,
Long semesterId,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
List<LocalDate> availableDates,
LocalDate checkedDate,
List<CandidateOption> timeSlots,
List<CandidateOption> classrooms
) {
public record CandidateOption(
Long id,
String label,
boolean available,
String conflictMessage
) {
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
public record TeacherChangeRequestDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
Long baseRuleSlotId,
LocalDate lessonDate,
String subjectName,
String lessonTypeName,
List<String> groupNames,
Long sourceTimeSlotId,
Integer sourceTimeSlotOrder,
LocalTime sourceStartTime,
LocalTime sourceEndTime,
Long sourceClassroomId,
String sourceClassroomName,
String requestType,
LocalDate targetLessonDate,
Long requestedTimeSlotId,
Integer requestedTimeSlotOrder,
LocalTime requestedStartTime,
LocalTime requestedEndTime,
Long requestedClassroomId,
String requestedClassroomName,
String reason,
String status,
Long reviewedBy,
String reviewComment,
Instant reviewedAt,
Long appliedOverrideId,
Instant createdAt,
Instant updatedAt,
List<HistoryItem> history
) {
public record HistoryItem(
Long id,
String status,
String comment,
Long actorId,
Instant createdAt
) {
}
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherChangeRequestPayload(
Long baseRuleSlotId,
LocalDate lessonDate,
String requestType,
LocalDate targetLessonDate,
Long requestedTimeSlotId,
Long requestedClassroomId,
String reason
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
}

View File

@@ -0,0 +1,34 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
public record TeacherPreferenceDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
Long semesterId,
String academicYearTitle,
String semesterType,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
String preferenceType,
Integer dayOfWeek,
String dayName,
Long timeSlotId,
Integer timeSlotOrder,
LocalTime startTime,
LocalTime endTime,
LocalDate preferenceDate,
String comment,
String status,
Long requestedBy,
Long reviewedBy,
String reviewComment,
Instant reviewedAt,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -0,0 +1,9 @@
package com.magistr.app.dto;
import java.util.List;
public record TeacherPreferenceMetaDto(
List<SemesterDto> semesters,
List<TimeSlotDto> timeSlots
) {
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherPreferenceRequest(
Long teacherId,
Long semesterId,
String preferenceType,
Integer dayOfWeek,
Long timeSlotId,
LocalDate preferenceDate,
String comment
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
}

View File

@@ -0,0 +1,104 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_change_requests")
public class TeacherChangeRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "base_rule_slot_id", nullable = false)
private ScheduleRuleSlot baseRuleSlot;
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(name = "request_type", nullable = false, length = 30)
private String requestType;
@Column(name = "target_lesson_date")
private LocalDate targetLessonDate;
@ManyToOne
@JoinColumn(name = "requested_time_slot_id")
private TimeSlot requestedTimeSlot;
@ManyToOne
@JoinColumn(name = "requested_classroom_id")
private Classroom requestedClassroom;
@Column(nullable = false, columnDefinition = "TEXT")
private String reason;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "reviewed_at")
private Instant reviewedAt;
@ManyToOne
@JoinColumn(name = "applied_override_id")
private ScheduleOverride appliedOverride;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() { return id; }
public User getTeacher() { return teacher; }
public void setTeacher(User teacher) { this.teacher = teacher; }
public ScheduleRuleSlot getBaseRuleSlot() { return baseRuleSlot; }
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) { this.baseRuleSlot = baseRuleSlot; }
public LocalDate getLessonDate() { return lessonDate; }
public void setLessonDate(LocalDate lessonDate) { this.lessonDate = lessonDate; }
public String getRequestType() { return requestType; }
public void setRequestType(String requestType) { this.requestType = requestType; }
public LocalDate getTargetLessonDate() { return targetLessonDate; }
public void setTargetLessonDate(LocalDate targetLessonDate) { this.targetLessonDate = targetLessonDate; }
public TimeSlot getRequestedTimeSlot() { return requestedTimeSlot; }
public void setRequestedTimeSlot(TimeSlot requestedTimeSlot) { this.requestedTimeSlot = requestedTimeSlot; }
public Classroom getRequestedClassroom() { return requestedClassroom; }
public void setRequestedClassroom(Classroom requestedClassroom) { this.requestedClassroom = requestedClassroom; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }
public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
public ScheduleOverride getAppliedOverride() { return appliedOverride; }
public void setAppliedOverride(ScheduleOverride appliedOverride) { this.appliedOverride = appliedOverride; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,49 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "teacher_change_request_history")
public class TeacherChangeRequestHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "request_id", nullable = false)
private TeacherChangeRequest request;
@Column(nullable = false, length = 20)
private String status;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "actor_id")
private Long actorId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public Long getId() { return id; }
public TeacherChangeRequest getRequest() { return request; }
public void setRequest(TeacherChangeRequest request) { this.request = request; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public Long getActorId() { return actorId; }
public void setActorId(Long actorId) { this.actorId = actorId; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,97 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_preferences")
public class TeacherPreference {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(name = "preference_type", nullable = false, length = 30)
private String preferenceType;
@Column(name = "day_of_week")
private Integer dayOfWeek;
@ManyToOne
@JoinColumn(name = "time_slot_id")
private TimeSlot timeSlot;
@Column(name = "preference_date")
private LocalDate preferenceDate;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "reviewed_at")
private Instant reviewedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() { return id; }
public User getTeacher() { return teacher; }
public void setTeacher(User teacher) { this.teacher = teacher; }
public Semester getSemester() { return semester; }
public void setSemester(Semester semester) { this.semester = semester; }
public String getPreferenceType() { return preferenceType; }
public void setPreferenceType(String preferenceType) { this.preferenceType = preferenceType; }
public Integer getDayOfWeek() { return dayOfWeek; }
public void setDayOfWeek(Integer dayOfWeek) { this.dayOfWeek = dayOfWeek; }
public TimeSlot getTimeSlot() { return timeSlot; }
public void setTimeSlot(TimeSlot timeSlot) { this.timeSlot = timeSlot; }
public LocalDate getPreferenceDate() { return preferenceDate; }
public void setPreferenceDate(LocalDate preferenceDate) { this.preferenceDate = preferenceDate; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getRequestedBy() { return requestedBy; }
public void setRequestedBy(Long requestedBy) { this.requestedBy = requestedBy; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }
public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherChangeRequestHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherChangeRequestHistoryRepository extends JpaRepository<TeacherChangeRequestHistory, Long> {
List<TeacherChangeRequestHistory> findByRequestIdOrderByCreatedAtAscIdAsc(Long requestId);
}

View File

@@ -0,0 +1,54 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherChangeRequest;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface TeacherChangeRequestRepository extends JpaRepository<TeacherChangeRequest, Long> {
@Query("""
select request
from TeacherChangeRequest request
join fetch request.teacher
join fetch request.baseRuleSlot slot
join fetch slot.scheduleRule rule
join fetch rule.subject
join fetch slot.timeSlot
join fetch slot.classroom
left join fetch request.requestedTimeSlot
left join fetch request.requestedClassroom
left join fetch request.appliedOverride
order by request.createdAt desc, request.id desc
""")
List<TeacherChangeRequest> findAllWithDetails();
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select request
from TeacherChangeRequest request
join fetch request.teacher
join fetch request.baseRuleSlot slot
join fetch slot.scheduleRule rule
join fetch rule.subject
join fetch slot.timeSlot
join fetch slot.classroom
left join fetch request.requestedTimeSlot
left join fetch request.requestedClassroom
left join fetch request.appliedOverride
where request.id = :id
""")
Optional<TeacherChangeRequest> findByIdForUpdate(@Param("id") Long id);
boolean existsByBaseRuleSlotIdAndLessonDateAndStatus(
Long baseRuleSlotId,
LocalDate lessonDate,
String status
);
}

View File

@@ -0,0 +1,90 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherPreference;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
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;
public interface TeacherPreferenceRepository extends JpaRepository<TeacherPreference, Long> {
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
order by preference.createdAt desc, preference.id desc
""")
List<TeacherPreference> findAllWithDetails();
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.id = :id
""")
Optional<TeacherPreference> findByIdWithDetails(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.id = :id
""")
Optional<TeacherPreference> findByIdForUpdate(@Param("id") Long id);
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
left join fetch preference.timeSlot slot
where preference.teacher.id = :teacherId
and preference.semester.id = :semesterId
and preference.status = 'APPROVED'
order by preference.preferenceType, preference.preferenceDate, preference.dayOfWeek, slot.orderNumber
""")
List<TeacherPreference> findApprovedForTeacherAndSemester(
@Param("teacherId") Long teacherId,
@Param("semesterId") Long semesterId
);
@Query("""
select count(preference) > 0
from TeacherPreference preference
where preference.teacher.id = :teacherId
and preference.semester.id = :semesterId
and preference.preferenceType = :preferenceType
and preference.status in :statuses
and ((preference.dayOfWeek is null and :dayOfWeek is null) or preference.dayOfWeek = :dayOfWeek)
and ((preference.timeSlot is null and :timeSlotId is null) or preference.timeSlot.id = :timeSlotId)
and ((preference.preferenceDate is null and :preferenceDate is null) or preference.preferenceDate = :preferenceDate)
""")
boolean existsDuplicate(
@Param("teacherId") Long teacherId,
@Param("semesterId") Long semesterId,
@Param("preferenceType") String preferenceType,
@Param("dayOfWeek") Integer dayOfWeek,
@Param("timeSlotId") Long timeSlotId,
@Param("preferenceDate") LocalDate preferenceDate,
@Param("statuses") Collection<String> statuses
);
}

View File

@@ -56,6 +56,7 @@ public class ScheduleOverrideService {
private final PostgreSqlAdvisoryLock advisoryLock;
private final AcademicDateService academicDateService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
@Autowired
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
@@ -106,6 +107,11 @@ public class ScheduleOverrideService {
);
}
@Autowired(required = false)
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
if ((startDate == null) != (endDate == null)) {
@@ -331,6 +337,7 @@ public class ScheduleOverrideService {
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
validateTeacherAvailability(candidate, candidateOccurrences);
if (sourceTuples.equals(candidateTuples)) {
throw new IllegalArgumentException(
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
@@ -345,6 +352,27 @@ public class ScheduleOverrideService {
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
}
private void validateTeacherAvailability(
ScheduleOverride candidate,
List<RenderedLessonDto> candidateOccurrences
) {
if (teacherAvailabilityPolicyService == null
|| candidate.getBaseRuleSlot() == null
|| candidate.getBaseRuleSlot().getScheduleRule() == null
|| candidate.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
return;
}
Long semesterId = candidate.getBaseRuleSlot().getScheduleRule().getSemester().getId();
for (RenderedLessonDto occurrence : candidateOccurrences) {
teacherAvailabilityPolicyService.assertLessonAvailable(
occurrence.teacherId(),
semesterId,
occurrence.date(),
occurrence.timeSlotId()
);
}
}
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())

View File

@@ -25,6 +25,7 @@ import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -70,6 +71,7 @@ public class ScheduleRuleService {
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
@@ -93,6 +95,11 @@ public class ScheduleRuleService {
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Autowired(required = false)
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
@@ -424,6 +431,21 @@ public class ScheduleRuleService {
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
validateExactDuplicates(slots);
validateInternalConflicts(slots);
validateTeacherAvailability(candidate, slots);
}
private void validateTeacherAvailability(ScheduleRule candidate, List<ScheduleRuleSlot> slots) {
if (teacherAvailabilityPolicyService == null) return;
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
for (ScheduleRuleSlot slot : slots) {
teacherAvailabilityPolicyService.assertRuleSlotAvailable(
teacherId(slot),
candidate.getSemester(),
slot.getDayOfWeek(),
timeSlotId(slot),
activeWeeks.get(slot)
);
}
}
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {

View File

@@ -0,0 +1,106 @@
package com.magistr.app.service;
import com.magistr.app.model.Semester;
import com.magistr.app.model.TeacherPreference;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.TeacherPreferenceRepository;
import com.magistr.app.repository.TimeSlotRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherAvailabilityPolicyService {
private final TeacherPreferenceRepository preferenceRepository;
private final TimeSlotRepository timeSlotRepository;
public TeacherAvailabilityPolicyService(
TeacherPreferenceRepository preferenceRepository,
TimeSlotRepository timeSlotRepository
) {
this.preferenceRepository = preferenceRepository;
this.timeSlotRepository = timeSlotRepository;
}
@Transactional(readOnly = true)
public void assertLessonAvailable(Long teacherId, Long semesterId, LocalDate date, Long timeSlotId) {
if (teacherId == null || semesterId == null || date == null || timeSlotId == null) return;
TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId)
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
List<TeacherPreference> constraints = preferenceRepository
.findApprovedForTeacherAndSemester(teacherId, semesterId);
TeacherPreference conflict = constraints.stream()
.filter(this::isHard)
.filter(preference -> conflicts(preference, date, candidateSlot))
.findFirst()
.orElse(null);
if (conflict != null) {
throw new ScheduleConflictException(conflict.getPreferenceDate() == null
? "Преподаватель отметил строгую недоступность в выбранный день и время"
: "Преподаватель отметил полную недоступность на выбранную дату");
}
}
@Transactional(readOnly = true)
public void assertRuleSlotAvailable(
Long teacherId,
Semester semester,
Integer dayOfWeek,
Long timeSlotId,
Set<Integer> activeWeeks
) {
if (teacherId == null || semester == null || dayOfWeek == null || timeSlotId == null) return;
TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId)
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
List<TeacherPreference> constraints = preferenceRepository
.findApprovedForTeacherAndSemester(teacherId, semester.getId());
for (TeacherPreference preference : constraints) {
if (!isHard(preference)) continue;
if (preference.getPreferenceDate() == null) {
if (Objects.equals(preference.getDayOfWeek(), dayOfWeek)
&& sameOrder(preference.getTimeSlot(), candidateSlot)) {
throw unavailableRuleConflict();
}
continue;
}
LocalDate date = preference.getPreferenceDate();
if (date.getDayOfWeek().getValue() != dayOfWeek) continue;
int week = Math.toIntExact(ChronoUnit.DAYS.between(semester.getStartDate(), date) / 7 + 1);
if (activeWeeks == null || activeWeeks.contains(week)) {
throw unavailableRuleConflict();
}
}
}
private ScheduleRuleConflictException unavailableRuleConflict() {
return new ScheduleRuleConflictException(
"Невозможно сохранить правило: преподаватель строго недоступен в одном из выбранных интервалов",
null,
List.of("teacherAvailability"),
List.of("Строгая недоступность преподавателя")
);
}
private boolean conflicts(TeacherPreference preference, LocalDate date, TimeSlot candidateSlot) {
if (preference.getPreferenceDate() != null) {
return Objects.equals(preference.getPreferenceDate(), date);
}
return Objects.equals(preference.getDayOfWeek(), date.getDayOfWeek().getValue())
&& sameOrder(preference.getTimeSlot(), candidateSlot);
}
private boolean sameOrder(TimeSlot first, TimeSlot second) {
return first != null && second != null && Objects.equals(first.getOrderNumber(), second.getOrderNumber());
}
private boolean isHard(TeacherPreference preference) {
return "APPROVED".equals(preference.getStatus())
&& "HARD_UNAVAILABLE".equals(preference.getPreferenceType());
}
}

View File

@@ -0,0 +1,493 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.TeacherChangeCandidatesDto;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.dto.TeacherChangeRequestPayload;
import com.magistr.app.dto.TeacherChangeReviewRequest;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TeacherChangeRequest;
import com.magistr.app.model.TeacherChangeRequestHistory;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.TeacherChangeRequestHistoryRepository;
import com.magistr.app.repository.TeacherChangeRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherChangeRequestService {
private static final Logger log = LoggerFactory.getLogger(TeacherChangeRequestService.class);
private static final Set<String> TYPES = Set.of("MOVE", "CHANGE_CLASSROOM", "CANCEL");
private static final Set<String> STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED");
private final TeacherChangeRequestRepository requestRepository;
private final TeacherChangeRequestHistoryRepository historyRepository;
private final ScheduleRuleSlotRepository ruleSlotRepository;
private final ScheduleOverrideRepository overrideRepository;
private final ClassroomRepository classroomRepository;
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final ScheduleQueryService scheduleQueryService;
private final ScheduleOverrideService scheduleOverrideService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
private final BusinessTimeService businessTime;
public TeacherChangeRequestService(
TeacherChangeRequestRepository requestRepository,
TeacherChangeRequestHistoryRepository historyRepository,
ScheduleRuleSlotRepository ruleSlotRepository,
ScheduleOverrideRepository overrideRepository,
ClassroomRepository classroomRepository,
TeacherDepartmentAssignmentRepository assignmentRepository,
ScheduleQueryService scheduleQueryService,
ScheduleOverrideService scheduleOverrideService,
EffectiveTimeSlotService effectiveTimeSlotService,
BusinessTimeService businessTime
) {
this.requestRepository = requestRepository;
this.historyRepository = historyRepository;
this.ruleSlotRepository = ruleSlotRepository;
this.overrideRepository = overrideRepository;
this.classroomRepository = classroomRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleQueryService = scheduleQueryService;
this.scheduleOverrideService = scheduleOverrideService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public List<TeacherChangeRequestDto> getAll(String status) {
AuthenticatedUser current = currentUser();
String normalizedStatus = cleanUpper(status);
if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) {
throw new IllegalArgumentException("Неизвестный статус заявки на изменение расписания");
}
return requestRepository.findAllWithDetails().stream()
.filter(request -> normalizedStatus == null || normalizedStatus.equals(request.getStatus()))
.filter(request -> canView(request, current))
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public TeacherChangeCandidatesDto getCandidates(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetDate
) {
AuthenticatedUser current = currentUser();
RenderedLessonDto lesson = sourceLesson(baseRuleSlotId, lessonDate);
ensureCanUseLesson(lesson, current);
var availability = scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
LocalDate checkedDate = targetDate == null ? lessonDate : targetDate;
if (!availability.availableDates().contains(checkedDate)) {
throw new IllegalArgumentException("Целевая дата недоступна для этого правила расписания");
}
List<TeacherChangeCandidatesDto.CandidateOption> timeSlots = effectiveTimeSlotService.findEffective(checkedDate)
.stream()
.sorted(Comparator.comparing(TimeSlot::getOrderNumber).thenComparing(TimeSlot::getStartTime))
.map(slot -> candidateOption(
slot.getId(),
slot.getOrderNumber() + " пара · " + shortTime(slot),
buildOverride(
baseRuleSlotId,
lessonDate,
checkedDate,
"MOVE",
slot.getId(),
null,
"Предварительная проверка заявки преподавателя"
)
))
.toList();
List<TeacherChangeCandidatesDto.CandidateOption> classrooms = classroomRepository.findByStatusNot("ARCHIVED")
.stream()
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.sorted(Comparator.comparing(Classroom::getName))
.map(classroom -> candidateOption(
classroom.getId(),
classroom.getName(),
buildOverride(
baseRuleSlotId,
lessonDate,
lessonDate,
"REPLACE",
null,
classroom.getId(),
"Предварительная проверка заявки преподавателя"
)
))
.toList();
return new TeacherChangeCandidatesDto(
lesson,
availability.semesterId(),
availability.semesterStartDate(),
availability.semesterEndDate(),
availability.availableDates(),
checkedDate,
timeSlots,
classrooms
);
}
@Transactional
public TeacherChangeRequestDto create(TeacherChangeRequestPayload payload) {
AuthenticatedUser current = currentUser();
if (current.role() != Role.TEACHER) {
throw forbidden("Заявку на изменение занятия отправляет преподаватель");
}
if (payload == null || payload.baseRuleSlotId() == null || payload.lessonDate() == null) {
throw new IllegalArgumentException("Выберите занятие для заявки");
}
String type = cleanUpper(payload.requestType());
if (type == null || !TYPES.contains(type)) {
throw new IllegalArgumentException("Неизвестный тип изменения занятия");
}
String reason = clean(payload.reason());
if (reason == null) {
throw new IllegalArgumentException("Обоснование заявки обязательно");
}
if (reason.length() > 2000) {
throw new IllegalArgumentException("Обоснование не может превышать 2000 символов");
}
RenderedLessonDto lesson = sourceLesson(payload.baseRuleSlotId(), payload.lessonDate());
ensureCanUseLesson(lesson, current);
if (requestRepository.existsByBaseRuleSlotIdAndLessonDateAndStatus(
payload.baseRuleSlotId(), payload.lessonDate(), "PENDING")) {
throw new ScheduleConflictException("По выбранному занятию уже есть заявка на рассмотрении");
}
ScheduleRuleSlot baseSlot = ruleSlotRepository.findById(payload.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
ScheduleOverrideDto candidate = validatePayload(payload, type, lesson);
scheduleOverrideService.validateCandidate(candidate);
var now = businessTime.now();
TeacherChangeRequest request = new TeacherChangeRequest();
request.setTeacher(baseSlot.getTeacher());
request.setBaseRuleSlot(baseSlot);
request.setLessonDate(payload.lessonDate());
request.setRequestType(type);
request.setTargetLessonDate("MOVE".equals(type) ? candidate.targetLessonDate() : null);
request.setRequestedTimeSlot(candidate.newTimeSlotId() == null
? null
: effectiveTimeSlotService.findEffective(
candidate.targetLessonDate() == null ? payload.lessonDate() : candidate.targetLessonDate())
.stream()
.filter(slot -> Objects.equals(slot.getId(), candidate.newTimeSlotId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Выбранный временной слот недоступен на целевую дату")));
request.setRequestedClassroom(candidate.newClassroomId() == null
? null
: classroomRepository.findById(candidate.newClassroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")));
request.setReason(reason);
request.setStatus("PENDING");
request.setCreatedAt(now);
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, "PENDING", reason, current.id(), now);
log.info("Преподаватель {} отправил заявку {} по занятию {} на {}",
current.id(), saved.getId(), payload.baseRuleSlotId(), payload.lessonDate());
return toDto(saved);
}
@Transactional
public TeacherChangeRequestDto review(Long id, TeacherChangeReviewRequest review) {
AuthenticatedUser current = currentUser();
if (current.role() != Role.ADMIN && current.role() != Role.EDUCATION_OFFICE) {
throw forbidden("Применять заявки на изменение расписания может только учебный отдел");
}
if (review == null || review.approved() == null) {
throw new IllegalArgumentException("Укажите решение по заявке");
}
TeacherChangeRequest request = requestRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена"));
if (!"PENDING".equals(request.getStatus())) {
throw new IllegalArgumentException("Рассмотреть можно только ожидающую заявку");
}
String comment = clean(review.comment());
if (!Boolean.TRUE.equals(review.approved()) && comment == null) {
throw new IllegalArgumentException("При отклонении укажите комментарий");
}
var now = businessTime.now();
if (Boolean.TRUE.equals(review.approved())) {
ScheduleOverrideDto applied = scheduleOverrideService.create(toOverride(request, comment));
ScheduleOverride override = overrideRepository.findById(applied.id())
.orElseThrow(() -> new IllegalStateException("Применённое изменение расписания не найдено"));
request.setAppliedOverride(override);
request.setStatus("APPROVED");
} else {
request.setStatus("REJECTED");
}
request.setReviewedBy(current.id());
request.setReviewComment(comment);
request.setReviewedAt(now);
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, saved.getStatus(), comment, current.id(), now);
log.info("Заявка преподавателя {} переведена в статус {}", id, saved.getStatus());
return toDto(saved);
}
@Transactional
public TeacherChangeRequestDto cancel(Long id) {
AuthenticatedUser current = currentUser();
TeacherChangeRequest request = requestRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена"));
if (current.role() != Role.TEACHER || !Objects.equals(current.id(), request.getTeacher().getId())) {
throw forbidden("Отменить заявку может только отправивший её преподаватель");
}
if (!"PENDING".equals(request.getStatus())) {
throw new IllegalArgumentException("Отменить можно только ожидающую заявку");
}
var now = businessTime.now();
request.setStatus("CANCELLED");
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, "CANCELLED", "Заявка отозвана преподавателем", current.id(), now);
return toDto(saved);
}
private ScheduleOverrideDto validatePayload(
TeacherChangeRequestPayload payload,
String type,
RenderedLessonDto lesson
) {
if ("MOVE".equals(type)) {
if (payload.requestedTimeSlotId() == null) {
throw new IllegalArgumentException("Для переноса выберите новую пару");
}
if (payload.requestedClassroomId() != null) {
throw new IllegalArgumentException("Смену аудитории оформите отдельным типом заявки");
}
LocalDate targetDate = payload.targetLessonDate() == null ? payload.lessonDate() : payload.targetLessonDate();
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), targetDate, "MOVE",
payload.requestedTimeSlotId(), null, payload.reason());
}
if ("CHANGE_CLASSROOM".equals(type)) {
if (payload.requestedClassroomId() == null) {
throw new IllegalArgumentException("Выберите новую аудиторию");
}
if (payload.targetLessonDate() != null || payload.requestedTimeSlotId() != null) {
throw new IllegalArgumentException("Для смены аудитории дата и время не изменяются");
}
if (Objects.equals(payload.requestedClassroomId(), lesson.classroomId())) {
throw new IllegalArgumentException("Новая аудитория должна отличаться от текущей");
}
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "REPLACE",
null, payload.requestedClassroomId(), payload.reason());
}
if (payload.targetLessonDate() != null
|| payload.requestedTimeSlotId() != null
|| payload.requestedClassroomId() != null) {
throw new IllegalArgumentException("Для отмены занятия новые параметры не указываются");
}
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "CANCEL",
null, null, payload.reason());
}
private TeacherChangeCandidatesDto.CandidateOption candidateOption(
Long id,
String label,
ScheduleOverrideDto candidate
) {
try {
scheduleOverrideService.validateCandidate(candidate);
return new TeacherChangeCandidatesDto.CandidateOption(id, label, true, null);
} catch (RuntimeException exception) {
return new TeacherChangeCandidatesDto.CandidateOption(id, label, false, exception.getMessage());
}
}
private ScheduleOverrideDto buildOverride(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetDate,
String action,
Long timeSlotId,
Long classroomId,
String comment
) {
LocalDate storedTargetDate = Objects.equals(lessonDate, targetDate) ? null : targetDate;
return new ScheduleOverrideDto(
null,
baseRuleSlotId,
lessonDate,
storedTargetDate,
action,
timeSlotId,
classroomId,
null,
null,
comment,
null,
null
);
}
private ScheduleOverrideDto toOverride(TeacherChangeRequest request, String reviewComment) {
String comment = "Одобрена заявка преподавателя №" + request.getId() + ": " + request.getReason();
if (reviewComment != null) comment += ". Решение: " + reviewComment;
String action = switch (request.getRequestType()) {
case "MOVE" -> "MOVE";
case "CHANGE_CLASSROOM" -> "REPLACE";
case "CANCEL" -> "CANCEL";
default -> throw new IllegalStateException("Неизвестный тип заявки");
};
return buildOverride(
request.getBaseRuleSlot().getId(),
request.getLessonDate(),
request.getTargetLessonDate() == null ? request.getLessonDate() : request.getTargetLessonDate(),
action,
request.getRequestedTimeSlot() == null ? null : request.getRequestedTimeSlot().getId(),
request.getRequestedClassroom() == null ? null : request.getRequestedClassroom().getId(),
comment
);
}
private RenderedLessonDto sourceLesson(Long baseRuleSlotId, LocalDate lessonDate) {
if (baseRuleSlotId == null || lessonDate == null) {
throw new IllegalArgumentException("Базовый слот и дата занятия обязательны");
}
if (overrideRepository.findByBaseRuleSlotIdAndLessonDate(baseRuleSlotId, lessonDate).isPresent()) {
throw new ScheduleConflictException("Для занятия уже существует точечное изменение расписания");
}
return scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId))
.filter(lesson -> Objects.equals(lesson.date(), lessonDate))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Выбранное занятие не найдено в базовом расписании"));
}
private void ensureCanUseLesson(RenderedLessonDto lesson, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return;
if (current.role() == Role.TEACHER && Objects.equals(current.id(), lesson.teacherId())) return;
if (current.role() == Role.DEPARTMENT
&& current.departmentId() != null
&& assignmentRepository.existsActiveAssignment(
lesson.teacherId(), current.departmentId(), lesson.date())) return;
throw forbidden("Выбранное занятие находится вне доступной области");
}
private boolean canView(TeacherChangeRequest request, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true;
if (current.role() == Role.TEACHER) {
return Objects.equals(current.id(), request.getTeacher().getId());
}
return current.role() == Role.DEPARTMENT
&& current.departmentId() != null
&& assignmentRepository.existsActiveAssignment(
request.getTeacher().getId(), current.departmentId(), request.getLessonDate());
}
private void addHistory(
TeacherChangeRequest request,
String status,
String comment,
Long actorId,
java.time.Instant now
) {
TeacherChangeRequestHistory history = new TeacherChangeRequestHistory();
history.setRequest(request);
history.setStatus(status);
history.setComment(comment);
history.setActorId(actorId);
history.setCreatedAt(now);
historyRepository.save(history);
}
private TeacherChangeRequestDto toDto(TeacherChangeRequest request) {
ScheduleRuleSlot source = request.getBaseRuleSlot();
TimeSlot sourceSlot = source.getTimeSlot();
TimeSlot targetSlot = request.getRequestedTimeSlot();
Classroom sourceClassroom = source.getClassroom();
Classroom targetClassroom = request.getRequestedClassroom();
List<TeacherChangeRequestDto.HistoryItem> history = historyRepository
.findByRequestIdOrderByCreatedAtAscIdAsc(request.getId()).stream()
.map(item -> new TeacherChangeRequestDto.HistoryItem(
item.getId(), item.getStatus(), item.getComment(), item.getActorId(), item.getCreatedAt()))
.toList();
return new TeacherChangeRequestDto(
request.getId(),
request.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(request.getTeacher()),
request.getTeacher().getDepartmentId(),
source.getId(),
request.getLessonDate(),
source.getScheduleRule().getSubject().getName(),
source.getLessonType().getLessonType(),
source.getScheduleRule().getGroups().stream().map(group -> group.getName()).sorted().toList(),
sourceSlot.getId(),
sourceSlot.getOrderNumber(),
sourceSlot.getStartTime(),
sourceSlot.getEndTime(),
sourceClassroom.getId(),
sourceClassroom.getName(),
request.getRequestType(),
request.getTargetLessonDate(),
targetSlot == null ? null : targetSlot.getId(),
targetSlot == null ? null : targetSlot.getOrderNumber(),
targetSlot == null ? null : targetSlot.getStartTime(),
targetSlot == null ? null : targetSlot.getEndTime(),
targetClassroom == null ? null : targetClassroom.getId(),
targetClassroom == null ? null : targetClassroom.getName(),
request.getReason(),
request.getStatus(),
request.getReviewedBy(),
request.getReviewComment(),
request.getReviewedAt(),
request.getAppliedOverride() == null ? null : request.getAppliedOverride().getId(),
request.getCreatedAt(),
request.getUpdatedAt(),
history
);
}
private String shortTime(TimeSlot slot) {
return slot.getStartTime().toString() + "–" + slot.getEndTime();
}
private AuthenticatedUser currentUser() {
AuthenticatedUser current = AuthContext.getCurrentUser();
if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация");
return current;
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String cleanUpper(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,368 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.dto.TeacherPreferenceMetaDto;
import com.magistr.app.dto.TeacherPreferenceRequest;
import com.magistr.app.dto.TeacherPreferenceReviewRequest;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.Semester;
import com.magistr.app.model.TeacherPreference;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.TeacherPreferenceRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherPreferenceService {
private static final Logger log = LoggerFactory.getLogger(TeacherPreferenceService.class);
private static final Set<String> TYPES = Set.of(
"HARD_UNAVAILABLE", "SOFT_PREFERRED", "SOFT_UNWANTED", "CONSECUTIVE", "NO_GAPS"
);
private static final Set<String> STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED");
private static final Set<String> ACTIVE_STATUSES = Set.of("PENDING", "APPROVED");
private final TeacherPreferenceRepository preferenceRepository;
private final UserRepository userRepository;
private final SemesterRepository semesterRepository;
private final TimeSlotRepository timeSlotRepository;
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final BusinessTimeService businessTime;
public TeacherPreferenceService(
TeacherPreferenceRepository preferenceRepository,
UserRepository userRepository,
SemesterRepository semesterRepository,
TimeSlotRepository timeSlotRepository,
TeacherDepartmentAssignmentRepository assignmentRepository,
BusinessTimeService businessTime
) {
this.preferenceRepository = preferenceRepository;
this.userRepository = userRepository;
this.semesterRepository = semesterRepository;
this.timeSlotRepository = timeSlotRepository;
this.assignmentRepository = assignmentRepository;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public TeacherPreferenceMetaDto getMeta() {
List<SemesterDto> semesters = semesterRepository.findAll().stream()
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
.map(semester -> new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
))
.toList();
List<TimeSlotDto> timeSlots = timeSlotRepository.findAllForSchedule().stream()
.filter(slot -> slot.getTimeSlotScope() != null)
.filter(slot -> "DEFAULT".equals(slot.getTimeSlotScope().getApplyMode()))
.map(this::toTimeSlotDto)
.toList();
return new TeacherPreferenceMetaDto(semesters, timeSlots);
}
@Transactional(readOnly = true)
public List<TeacherPreferenceDto> getAll(Long semesterId, String status) {
AuthenticatedUser current = currentUser();
String normalizedStatus = cleanUpper(status);
if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) {
throw new IllegalArgumentException("Неизвестный статус пожелания преподавателя");
}
return preferenceRepository.findAllWithDetails().stream()
.filter(thisPreference -> semesterId == null
|| Objects.equals(thisPreference.getSemester().getId(), semesterId))
.filter(thisPreference -> normalizedStatus == null
|| normalizedStatus.equals(thisPreference.getStatus()))
.filter(thisPreference -> canView(thisPreference, current))
.map(this::toDto)
.toList();
}
@Transactional
public TeacherPreferenceDto create(TeacherPreferenceRequest request) {
AuthenticatedUser current = currentUser();
if (request == null) {
throw new IllegalArgumentException("Передайте данные пожелания преподавателя");
}
String type = cleanUpper(request.preferenceType());
if (type == null || !TYPES.contains(type)) {
throw new IllegalArgumentException("Неизвестный тип пожелания преподавателя");
}
if (request.semesterId() == null) {
throw new IllegalArgumentException("Выберите семестр");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
Long teacherId = current.role() == Role.TEACHER ? current.id() : request.teacherId();
if (teacherId == null) {
throw new IllegalArgumentException("Выберите преподавателя");
}
if (current.role() == Role.TEACHER
&& request.teacherId() != null
&& !Objects.equals(request.teacherId(), current.id())) {
throw forbidden("Преподаватель может создавать пожелания только для себя");
}
User teacher = userRepository.findById(teacherId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(User::isActiveRecord)
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
ensureManageScope(teacher, semester, current);
PreferencePayload payload = validatePayload(type, semester, request);
String comment = clean(request.comment());
if (comment != null && comment.length() > 2000) {
throw new IllegalArgumentException("Комментарий не может превышать 2000 символов");
}
if (preferenceRepository.existsDuplicate(
teacherId,
semester.getId(),
type,
payload.dayOfWeek(),
payload.timeSlot() == null ? null : payload.timeSlot().getId(),
payload.preferenceDate(),
ACTIVE_STATUSES
)) {
throw new ScheduleConflictException("Такое пожелание уже ожидает решения или согласовано");
}
var now = businessTime.now();
TeacherPreference preference = new TeacherPreference();
preference.setTeacher(teacher);
preference.setSemester(semester);
preference.setPreferenceType(type);
preference.setDayOfWeek(payload.dayOfWeek());
preference.setTimeSlot(payload.timeSlot());
preference.setPreferenceDate(payload.preferenceDate());
preference.setComment(comment);
preference.setRequestedBy(current.id());
preference.setCreatedAt(now);
preference.setUpdatedAt(now);
if (current.role() == Role.TEACHER) {
preference.setStatus("PENDING");
} else {
preference.setStatus("APPROVED");
preference.setReviewedBy(current.id());
preference.setReviewedAt(now);
preference.setReviewComment("Добавлено ответственным сотрудником");
}
TeacherPreference saved = preferenceRepository.saveAndFlush(preference);
log.info("Создано пожелание преподавателя {} на семестр {}, тип {}", teacherId, semester.getId(), type);
return toDto(saved);
}
@Transactional
public TeacherPreferenceDto review(Long id, TeacherPreferenceReviewRequest request) {
AuthenticatedUser current = currentUser();
if (current.role() == Role.TEACHER) {
throw forbidden("Преподаватель не может согласовывать пожелания");
}
if (request == null || request.approved() == null) {
throw new IllegalArgumentException("Укажите решение по пожеланию преподавателя");
}
TeacherPreference preference = getForUpdate(id);
ensureManageScope(preference.getTeacher(), preference.getSemester(), current);
if (!"PENDING".equals(preference.getStatus())) {
throw new IllegalArgumentException("Рассмотреть можно только ожидающее пожелание");
}
String comment = clean(request.comment());
if (!Boolean.TRUE.equals(request.approved()) && comment == null) {
throw new IllegalArgumentException("При отклонении укажите комментарий");
}
var now = businessTime.now();
preference.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED");
preference.setReviewedBy(current.id());
preference.setReviewComment(comment);
preference.setReviewedAt(now);
preference.setUpdatedAt(now);
TeacherPreference saved = preferenceRepository.saveAndFlush(preference);
log.info("Пожелание преподавателя {} переведено в статус {}", id, saved.getStatus());
return toDto(saved);
}
@Transactional
public TeacherPreferenceDto cancel(Long id) {
AuthenticatedUser current = currentUser();
TeacherPreference preference = getForUpdate(id);
if (current.role() == Role.TEACHER) {
if (!Objects.equals(current.id(), preference.getTeacher().getId())) {
throw forbidden("Нельзя отменить пожелание другого преподавателя");
}
if (!"PENDING".equals(preference.getStatus())) {
throw new IllegalArgumentException("Преподаватель может отменить только ожидающее пожелание");
}
} else {
ensureManageScope(preference.getTeacher(), preference.getSemester(), current);
if (!Set.of("PENDING", "APPROVED").contains(preference.getStatus())) {
throw new IllegalArgumentException("Это пожелание уже нельзя отменить");
}
}
preference.setStatus("CANCELLED");
preference.setUpdatedAt(businessTime.now());
return toDto(preferenceRepository.saveAndFlush(preference));
}
private PreferencePayload validatePayload(String type, Semester semester, TeacherPreferenceRequest request) {
boolean compact = Set.of("CONSECUTIVE", "NO_GAPS").contains(type);
if (compact) {
if (request.dayOfWeek() != null || request.timeSlotId() != null || request.preferenceDate() != null) {
throw new IllegalArgumentException("Для пожелания к компактности день, дата и пара не указываются");
}
return new PreferencePayload(null, null, null);
}
if (request.preferenceDate() != null) {
if (!"HARD_UNAVAILABLE".equals(type)) {
throw new IllegalArgumentException("Полную недоступность по дате можно отметить только как строгую");
}
if (request.dayOfWeek() != null || request.timeSlotId() != null) {
throw new IllegalArgumentException("Для полной недоступности по дате день недели и пара не указываются");
}
if (request.preferenceDate().isBefore(semester.getStartDate())
|| request.preferenceDate().isAfter(semester.getEndDate())) {
throw new IllegalArgumentException("Дата недоступности должна находиться внутри семестра");
}
return new PreferencePayload(null, null, request.preferenceDate());
}
if (request.dayOfWeek() == null || request.dayOfWeek() < 1 || request.dayOfWeek() > 7) {
throw new IllegalArgumentException("Выберите день недели");
}
if (request.timeSlotId() == null) {
throw new IllegalArgumentException("Выберите временной слот");
}
TimeSlot slot = timeSlotRepository.findById(request.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (slot.getTimeSlotScope() == null || !"DEFAULT".equals(slot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("Для пожеланий используйте базовую сетку времени");
}
return new PreferencePayload(request.dayOfWeek(), slot, null);
}
private TeacherPreference getForUpdate(Long id) {
return preferenceRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Пожелание преподавателя не найдено"));
}
private boolean canView(TeacherPreference preference, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true;
if (current.role() == Role.TEACHER) {
return Objects.equals(current.id(), preference.getTeacher().getId());
}
return current.role() == Role.DEPARTMENT
&& belongsToDepartment(preference.getTeacher(), preference.getSemester(), current.departmentId());
}
private void ensureManageScope(User teacher, Semester semester, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return;
if (current.role() == Role.TEACHER && Objects.equals(current.id(), teacher.getId())) return;
if (current.role() == Role.DEPARTMENT
&& belongsToDepartment(teacher, semester, current.departmentId())) return;
throw forbidden("Пожелание преподавателя находится вне доступной кафедры");
}
private boolean belongsToDepartment(User teacher, Semester semester, Long departmentId) {
if (departmentId == null) return false;
return assignmentRepository.findForTeachersBetween(
Set.of(teacher.getId()),
semester.getStartDate(),
semester.getEndDate()
).stream()
.anyMatch(assignment -> assignment.getDepartment() != null
&& Objects.equals(assignment.getDepartment().getId(), departmentId));
}
private TeacherPreferenceDto toDto(TeacherPreference preference) {
User teacher = preference.getTeacher();
Semester semester = preference.getSemester();
TimeSlot slot = preference.getTimeSlot();
return new TeacherPreferenceDto(
preference.getId(),
teacher.getId(),
ScheduleGeneratorService.displayUserName(teacher),
teacher.getDepartmentId(),
semester.getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType().name(),
semester.getStartDate(),
semester.getEndDate(),
preference.getPreferenceType(),
preference.getDayOfWeek(),
dayName(preference.getDayOfWeek()),
slot == null ? null : slot.getId(),
slot == null ? null : slot.getOrderNumber(),
slot == null ? null : slot.getStartTime(),
slot == null ? null : slot.getEndTime(),
preference.getPreferenceDate(),
preference.getComment(),
preference.getStatus(),
preference.getRequestedBy(),
preference.getReviewedBy(),
preference.getReviewComment(),
preference.getReviewedAt(),
preference.getCreatedAt(),
preference.getUpdatedAt()
);
}
private TimeSlotDto toTimeSlotDto(TimeSlot slot) {
var scope = slot.getTimeSlotScope();
return new TimeSlotDto(
slot.getId(), slot.getOrderNumber(), scope.getId(), scope.getName(), scope.getApplyMode(),
scope.getDayOfWeek(), slot.getStartTime(), slot.getEndTime(), slot.getDurationMinutes()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) return null;
String value = DayOfWeek.of(dayOfWeek).getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru"));
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru")) + value.substring(1);
}
private AuthenticatedUser currentUser() {
AuthenticatedUser current = AuthContext.getCurrentUser();
if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация");
return current;
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String cleanUpper(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
}
private record PreferencePayload(Integer dayOfWeek, TimeSlot timeSlot, LocalDate preferenceDate) {
}
}

View File

@@ -0,0 +1,151 @@
-- Пожелания преподавателей на семестр и журнал заявок на изменение опубликованных занятий.
CREATE TABLE teacher_preferences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
semester_id BIGINT NOT NULL REFERENCES semesters(id),
preference_type VARCHAR(30) NOT NULL,
day_of_week SMALLINT,
time_slot_id BIGINT REFERENCES time_slots(id),
preference_date DATE,
comment TEXT,
status VARCHAR(20) NOT NULL,
requested_by BIGINT REFERENCES users(id),
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_preferences_type CHECK (
preference_type IN (
'HARD_UNAVAILABLE',
'SOFT_PREFERRED',
'SOFT_UNWANTED',
'CONSECUTIVE',
'NO_GAPS'
)
),
CONSTRAINT chk_teacher_preferences_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_preferences_day CHECK (
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
),
CONSTRAINT chk_teacher_preferences_payload CHECK (
(
preference_type = 'HARD_UNAVAILABLE'
AND (
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
OR
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
)
)
OR
(
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
AND preference_date IS NULL
AND day_of_week IS NOT NULL
AND time_slot_id IS NOT NULL
)
OR
(
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
AND preference_date IS NULL
AND day_of_week IS NULL
AND time_slot_id IS NULL
)
),
CONSTRAINT chk_teacher_preferences_comment CHECK (
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
)
);
CREATE INDEX idx_teacher_preferences_teacher_semester
ON teacher_preferences(teacher_id, semester_id, status);
CREATE INDEX idx_teacher_preferences_slot
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
WHERE status = 'APPROVED';
CREATE INDEX idx_teacher_preferences_date
ON teacher_preferences(teacher_id, preference_date)
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
CREATE TABLE teacher_change_requests (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
request_type VARCHAR(30) NOT NULL,
target_lesson_date DATE,
requested_time_slot_id BIGINT REFERENCES time_slots(id),
requested_classroom_id BIGINT REFERENCES classrooms(id),
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_requests_type CHECK (
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
),
CONSTRAINT chk_teacher_change_requests_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_change_requests_reason CHECK (
length(trim(reason)) BETWEEN 1 AND 2000
),
CONSTRAINT chk_teacher_change_requests_payload CHECK (
(
request_type = 'MOVE'
AND requested_time_slot_id IS NOT NULL
AND requested_classroom_id IS NULL
)
OR
(
request_type = 'CHANGE_CLASSROOM'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NOT NULL
)
OR
(
request_type = 'CANCEL'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NULL
)
)
);
CREATE INDEX idx_teacher_change_requests_teacher
ON teacher_change_requests(teacher_id, created_at DESC);
CREATE INDEX idx_teacher_change_requests_status
ON teacher_change_requests(status, created_at);
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
ON teacher_change_requests(base_rule_slot_id, lesson_date)
WHERE status = 'PENDING';
CREATE TABLE teacher_change_request_history (
id BIGSERIAL PRIMARY KEY,
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
comment TEXT,
actor_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_request_history_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
)
);
CREATE INDEX idx_teacher_change_request_history_request
ON teacher_change_request_history(request_id, created_at, id);
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';

View File

@@ -0,0 +1,90 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.service.TeacherChangeRequestService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class TeacherChangeRequestControllerTest {
private TeacherChangeRequestService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(TeacherChangeRequestService.class);
mockMvc = standaloneSetup(new TeacherChangeRequestController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void createsMoveRequestWithRussianReason() throws Exception {
when(service.create(any())).thenReturn(request("PENDING"));
mockMvc.perform(post("/api/teacher-change-requests")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"baseRuleSlotId": 40,
"lessonDate": "2026-09-14",
"requestType": "MOVE",
"targetLessonDate": "2026-09-16",
"requestedTimeSlotId": 4,
"reason": "Участие в конференции"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.reason").value("Участие в конференции"));
}
@Test
void approvesRequestAndReturnsAppliedOverride() throws Exception {
when(service.review(eq(12L), any())).thenReturn(request("APPROVED"));
mockMvc.perform(post("/api/teacher-change-requests/12/review")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"approved": true, "comment": "Свободный слот подтверждён"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("APPROVED"))
.andExpect(jsonPath("$.appliedOverrideId").value(77));
verify(service).review(eq(12L), any());
}
private TeacherChangeRequestDto request(String status) {
Instant now = Instant.parse("2026-08-11T10:00:00Z");
return new TeacherChangeRequestDto(
12L, 11L, "Иванов Иван Иванович", 7L, 40L,
LocalDate.of(2026, 9, 14), "Базы данных", "Лекция", List.of("ИВТ-21"),
2L, 1, LocalTime.of(9, 0), LocalTime.of(10, 30), 8L, "101",
"MOVE", LocalDate.of(2026, 9, 16), 4L, 2,
LocalTime.of(10, 40), LocalTime.of(12, 10), null, null,
"Участие в конференции", status, status.equals("APPROVED") ? 1L : null,
status.equals("APPROVED") ? "Свободный слот подтверждён" : null,
status.equals("APPROVED") ? now : null, status.equals("APPROVED") ? 77L : null,
now, now,
List.of(new TeacherChangeRequestDto.HistoryItem(1L, status, "Комментарий", 1L, now))
);
}
}

View File

@@ -0,0 +1,77 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.service.TeacherPreferenceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class TeacherPreferenceControllerTest {
private TeacherPreferenceService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(TeacherPreferenceService.class);
mockMvc = standaloneSetup(new TeacherPreferenceController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsApprovedPreferencesForSemester() throws Exception {
when(service.getAll(5L, "APPROVED")).thenReturn(List.of(preference("APPROVED")));
mockMvc.perform(get("/api/teacher-preferences")
.param("semesterId", "5")
.param("status", "APPROVED"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].preferenceType").value("SOFT_PREFERRED"))
.andExpect(jsonPath("$[0].teacherName").value("Иванов Иван Иванович"));
}
@Test
void reviewsPendingPreference() throws Exception {
when(service.review(eq(9L), any())).thenReturn(preference("APPROVED"));
mockMvc.perform(post("/api/teacher-preferences/9/review")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"approved": true, "comment": "Учтено при составлении расписания"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("APPROVED"));
verify(service).review(eq(9L), any());
}
private TeacherPreferenceDto preference(String status) {
Instant now = Instant.parse("2026-08-11T10:00:00Z");
return new TeacherPreferenceDto(
9L, 11L, "Иванов Иван Иванович", 7L, 5L, "2026/2027", "AUTUMN",
LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
"SOFT_PREFERRED", 1, "Понедельник", 3L, 2,
LocalTime.of(10, 40), LocalTime.of(12, 10), null, null, status,
11L, status.equals("APPROVED") ? 1L : null, null,
status.equals("APPROVED") ? now : null, now, now
);
}
}

View File

@@ -0,0 +1,83 @@
package com.magistr.app.migration;
import com.magistr.app.testing.TestContainerImages;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция пожеланий и заявок преподавателей")
class TeacherPreferencesMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
@Test
@DisplayName("V3 создаёт три таблицы и отклоняет противоречивое пожелание")
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("3");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'teacher_preferences',
'teacher_change_requests',
'teacher_change_request_history'
)
""")).isEqualTo(3L);
long teacherId = queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1");
long semesterId = queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1");
long timeSlotId = queryLong(connection, "SELECT id FROM time_slots ORDER BY id LIMIT 1");
Throwable failure = catchThrowable(() -> {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO teacher_preferences (
teacher_id, semester_id, preference_type,
day_of_week, time_slot_id, preference_date, status
) VALUES (?, ?, 'SOFT_PREFERRED', 1, ?, CURRENT_DATE, 'PENDING')
""")) {
statement.setLong(1, teacherId);
statement.setLong(2, semesterId);
statement.setLong(3, timeSlotId);
statement.executeUpdate();
}
});
assertThat(failure).isInstanceOf(SQLException.class);
assertThat(((SQLException) failure).getSQLState()).isEqualTo("23514");
assertThat(failure.getMessage()).contains("chk_teacher_preferences_payload");
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.magistr.app.service;
import com.magistr.app.model.TeacherPreference;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.TeacherPreferenceRepository;
import com.magistr.app.repository.TimeSlotRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TeacherAvailabilityPolicyServiceTest {
@Mock
private TeacherPreferenceRepository preferenceRepository;
@Mock
private TimeSlotRepository timeSlotRepository;
private TeacherAvailabilityPolicyService service;
private TimeSlot candidateSlot;
@BeforeEach
void setUp() {
service = new TeacherAvailabilityPolicyService(preferenceRepository, timeSlotRepository);
candidateSlot = new TimeSlot();
candidateSlot.setId(3L);
candidateSlot.setOrderNumber(2);
when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(candidateSlot));
}
@Test
void approvedHardDateBlocksCandidate() {
TeacherPreference preference = preference("HARD_UNAVAILABLE");
preference.setPreferenceDate(LocalDate.of(2026, 9, 14));
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
.thenReturn(List.of(preference));
assertThatThrownBy(() -> service.assertLessonAvailable(
11L, 5L, LocalDate.of(2026, 9, 14), 3L
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessageContaining("полную недоступность");
}
@Test
void weeklyHardIntervalMatchesSlotOrderAcrossTimeGrids() {
TeacherPreference preference = preference("HARD_UNAVAILABLE");
preference.setDayOfWeek(1);
TimeSlot baseSlot = new TimeSlot();
baseSlot.setId(8L);
baseSlot.setOrderNumber(2);
preference.setTimeSlot(baseSlot);
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
.thenReturn(List.of(preference));
assertThatThrownBy(() -> service.assertLessonAvailable(
11L, 5L, LocalDate.of(2026, 9, 14), 3L
))
.isInstanceOf(ScheduleConflictException.class)
.hasMessageContaining("день и время");
}
@Test
void softPreferenceDoesNotBlockCandidate() {
TeacherPreference preference = preference("SOFT_UNWANTED");
preference.setDayOfWeek(1);
preference.setTimeSlot(candidateSlot);
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
.thenReturn(List.of(preference));
assertThatCode(() -> service.assertLessonAvailable(
11L, 5L, LocalDate.of(2026, 9, 14), 3L
)).doesNotThrowAnyException();
}
private TeacherPreference preference(String type) {
TeacherPreference preference = new TeacherPreference();
preference.setPreferenceType(type);
preference.setStatus("APPROVED");
return preference;
}
}