3 Задача
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TeacherPreferenceMetaDto(
|
||||
List<SemesterDto> semesters,
|
||||
List<TimeSlotDto> timeSlots
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user