1 Задача
This commit is contained in:
@@ -11,6 +11,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -51,6 +52,19 @@ public class GlobalExceptionHandler {
|
||||
return error(HttpStatus.NOT_FOUND, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleResponseStatus(
|
||||
ResponseStatusException exception,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
HttpStatus status = HttpStatus.resolve(exception.getStatusCode().value());
|
||||
HttpStatus resolvedStatus = status == null ? HttpStatus.INTERNAL_SERVER_ERROR : status;
|
||||
String message = exception.getReason() == null || exception.getReason().isBlank()
|
||||
? "Операция недоступна"
|
||||
: exception.getReason();
|
||||
return error(resolvedStatus, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
HttpMessageNotReadableException.class,
|
||||
MethodArgumentTypeMismatchException.class
|
||||
@@ -100,6 +114,8 @@ public class GlobalExceptionHandler {
|
||||
case BAD_REQUEST -> "Некорректный запрос";
|
||||
case CONFLICT -> "Конфликт";
|
||||
case NOT_FOUND -> "Не найдено";
|
||||
case FORBIDDEN -> "Доступ запрещён";
|
||||
case UNAUTHORIZED -> "Требуется вход";
|
||||
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
|
||||
default -> status.name();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.TeacherAbsenceDto;
|
||||
import com.magistr.app.dto.TeacherAbsenceRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceReviewRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceWizardDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.TeacherAbsenceService;
|
||||
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-absences")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
|
||||
public class TeacherAbsenceController {
|
||||
|
||||
private final TeacherAbsenceService absenceService;
|
||||
|
||||
public TeacherAbsenceController(TeacherAbsenceService absenceService) {
|
||||
this.absenceService = absenceService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TeacherAbsenceDto> getAll(@RequestParam(required = false) String status) {
|
||||
return absenceService.getAll(status);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public TeacherAbsenceDto create(@RequestBody TeacherAbsenceRequest request) {
|
||||
return absenceService.create(request);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/review")
|
||||
public TeacherAbsenceDto review(
|
||||
@PathVariable Long id,
|
||||
@RequestBody TeacherAbsenceReviewRequest request
|
||||
) {
|
||||
return absenceService.review(id, request);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/wizard")
|
||||
public TeacherAbsenceWizardDto getWizard(@PathVariable Long id) {
|
||||
return absenceService.getWizard(id);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/resolve")
|
||||
public TeacherAbsenceWizardDto resolve(
|
||||
@PathVariable Long id,
|
||||
@RequestBody TeacherAbsenceResolutionRequest request
|
||||
) {
|
||||
return absenceService.resolve(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public TeacherAbsenceDto cancel(@PathVariable Long id) {
|
||||
return absenceService.cancel(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TeacherAbsenceDto(
|
||||
Long id,
|
||||
Long teacherId,
|
||||
String teacherName,
|
||||
Long teacherDepartmentId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String reason,
|
||||
String status,
|
||||
Long requestedBy,
|
||||
Long reviewedBy,
|
||||
String reviewComment,
|
||||
Instant reviewedAt,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
long decisionCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TeacherAbsenceRequest(
|
||||
Long teacherId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record TeacherAbsenceResolutionRequest(
|
||||
List<Decision> decisions
|
||||
) {
|
||||
public record Decision(
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String resolution,
|
||||
LocalDate targetLessonDate,
|
||||
Long newTimeSlotId,
|
||||
Long newClassroomId,
|
||||
Long newTeacherId,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherAbsenceReviewRequest(
|
||||
Boolean approved,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
public record TeacherAbsenceWizardDto(
|
||||
TeacherAbsenceDto absence,
|
||||
List<AffectedLesson> lessons,
|
||||
List<DecisionLog> journal
|
||||
) {
|
||||
public record Candidate(Long id, String name, String description) {
|
||||
}
|
||||
|
||||
public record MoveCandidate(
|
||||
LocalDate targetDate,
|
||||
Long timeSlotId,
|
||||
Integer orderNumber,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
|
||||
public record AffectedLesson(
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
LocalTime startTime,
|
||||
LocalTime endTime,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Long lessonTypeId,
|
||||
String lessonTypeName,
|
||||
Long classroomId,
|
||||
String classroomName,
|
||||
List<String> groupNames,
|
||||
Long existingOverrideId,
|
||||
String decisionStatus,
|
||||
List<Candidate> teacherCandidates,
|
||||
List<MoveCandidate> moveCandidates,
|
||||
List<Candidate> classroomCandidates,
|
||||
boolean cancellationAvailable
|
||||
) {
|
||||
}
|
||||
|
||||
public record DecisionLog(
|
||||
Long id,
|
||||
Long baseRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String resolution,
|
||||
String status,
|
||||
Long scheduleOverrideId,
|
||||
String comment,
|
||||
Long decidedBy,
|
||||
Instant decidedAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal file
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal file
@@ -0,0 +1,141 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_absences")
|
||||
public class TeacherAbsence {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "teacher_id", nullable = false)
|
||||
private User teacher;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String reason;
|
||||
|
||||
@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 LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
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 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,121 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teacher_absence_decisions")
|
||||
public class TeacherAbsenceDecision {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "absence_id", nullable = false)
|
||||
private TeacherAbsence absence;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "base_rule_slot_id", nullable = false)
|
||||
private ScheduleRuleSlot baseRuleSlot;
|
||||
|
||||
@Column(name = "lesson_date", nullable = false)
|
||||
private LocalDate lessonDate;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String resolution;
|
||||
|
||||
@Column(name = "decision_status", nullable = false, length = 20)
|
||||
private String decisionStatus;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "schedule_override_id")
|
||||
private ScheduleOverride scheduleOverride;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String comment;
|
||||
|
||||
@Column(name = "decided_by")
|
||||
private Long decidedBy;
|
||||
|
||||
@Column(name = "decided_at", nullable = false)
|
||||
private Instant decidedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public TeacherAbsence getAbsence() {
|
||||
return absence;
|
||||
}
|
||||
|
||||
public void setAbsence(TeacherAbsence absence) {
|
||||
this.absence = absence;
|
||||
}
|
||||
|
||||
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 getResolution() {
|
||||
return resolution;
|
||||
}
|
||||
|
||||
public void setResolution(String resolution) {
|
||||
this.resolution = resolution;
|
||||
}
|
||||
|
||||
public String getDecisionStatus() {
|
||||
return decisionStatus;
|
||||
}
|
||||
|
||||
public void setDecisionStatus(String decisionStatus) {
|
||||
this.decisionStatus = decisionStatus;
|
||||
}
|
||||
|
||||
public ScheduleOverride getScheduleOverride() {
|
||||
return scheduleOverride;
|
||||
}
|
||||
|
||||
public void setScheduleOverride(ScheduleOverride scheduleOverride) {
|
||||
this.scheduleOverride = scheduleOverride;
|
||||
}
|
||||
|
||||
public String getComment() {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public void setComment(String comment) {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public Long getDecidedBy() {
|
||||
return decidedBy;
|
||||
}
|
||||
|
||||
public void setDecidedBy(Long decidedBy) {
|
||||
this.decidedBy = decidedBy;
|
||||
}
|
||||
|
||||
public Instant getDecidedAt() {
|
||||
return decidedAt;
|
||||
}
|
||||
|
||||
public void setDecidedAt(Instant decidedAt) {
|
||||
this.decidedAt = decidedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TeacherAbsenceDecision;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherAbsenceDecisionRepository extends JpaRepository<TeacherAbsenceDecision, Long> {
|
||||
|
||||
long countByAbsenceId(Long absenceId);
|
||||
|
||||
@Query("""
|
||||
select decision
|
||||
from TeacherAbsenceDecision decision
|
||||
join fetch decision.baseRuleSlot baseSlot
|
||||
left join fetch decision.scheduleOverride
|
||||
where decision.absence.id = :absenceId
|
||||
order by decision.lessonDate, decision.id
|
||||
""")
|
||||
List<TeacherAbsenceDecision> findByAbsenceIdWithDetails(@Param("absenceId") Long absenceId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.TeacherAbsence;
|
||||
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 TeacherAbsenceRepository extends JpaRepository<TeacherAbsence, Long> {
|
||||
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
order by absence.startDate desc, absence.id desc
|
||||
""")
|
||||
List<TeacherAbsence> findAllWithTeacher();
|
||||
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
where absence.id = :id
|
||||
""")
|
||||
Optional<TeacherAbsence> findByIdWithTeacher(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select absence
|
||||
from TeacherAbsence absence
|
||||
join fetch absence.teacher
|
||||
where absence.id = :id
|
||||
""")
|
||||
Optional<TeacherAbsence> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select count(absence) > 0
|
||||
from TeacherAbsence absence
|
||||
where absence.teacher.id = :teacherId
|
||||
and absence.status in :statuses
|
||||
and absence.startDate <= :endDate
|
||||
and absence.endDate >= :startDate
|
||||
""")
|
||||
boolean existsOverlapping(
|
||||
@Param("teacherId") Long teacherId,
|
||||
@Param("statuses") Collection<String> statuses,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select count(absence) > 0
|
||||
from TeacherAbsence absence
|
||||
where absence.teacher.id = :teacherId
|
||||
and absence.status in ('APPROVED', 'RESOLVED')
|
||||
and absence.startDate <= :date
|
||||
and absence.endDate >= :date
|
||||
""")
|
||||
boolean isTeacherAbsentOn(@Param("teacherId") Long teacherId, @Param("date") LocalDate date);
|
||||
}
|
||||
@@ -3,10 +3,38 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.TeacherSubject;
|
||||
import com.magistr.app.model.TeacherSubjectId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
|
||||
List<TeacherSubject> findByUserId(Long userId);
|
||||
List<TeacherSubject> findBySubjectId(Long subjectId);
|
||||
|
||||
@Query(value = """
|
||||
select subject_link.user_id
|
||||
from teacher_subjects subject_link
|
||||
where subject_link.subject_id = :subjectId
|
||||
and (
|
||||
not exists (
|
||||
select 1
|
||||
from teacher_lesson_types configured_type
|
||||
where configured_type.user_id = subject_link.user_id
|
||||
and configured_type.subject_id = subject_link.subject_id
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from teacher_lesson_types supported_type
|
||||
where supported_type.user_id = subject_link.user_id
|
||||
and supported_type.subject_id = subject_link.subject_id
|
||||
and supported_type.lesson_type_id = :lessonTypeId
|
||||
)
|
||||
)
|
||||
order by subject_link.user_id
|
||||
""", nativeQuery = true)
|
||||
List<Long> findQualifiedTeacherIds(
|
||||
@Param("subjectId") Long subjectId,
|
||||
@Param("lessonTypeId") Long lessonTypeId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,43 @@ public class ScheduleOverrideService {
|
||||
return validateAndPersist(null, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет кандидат мастера замены тем же механизмом, что и обычное точечное изменение,
|
||||
* но не блокирует строки и не сохраняет результат.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public void validateCandidate(ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
if (scheduleOverrideRepository
|
||||
.findByBaseRuleSlotIdAndLessonDate(command.baseRuleSlotId(), command.lessonDate())
|
||||
.isPresent()) {
|
||||
throw new ScheduleConflictException(
|
||||
"Для выбранной базовой пары и даты уже существует точечное изменение"
|
||||
);
|
||||
}
|
||||
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
|
||||
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
|
||||
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
|
||||
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
|
||||
.toList();
|
||||
if (sourceOccurrences.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"На выбранную дату базовая пара не формируется по правилам расписания"
|
||||
);
|
||||
}
|
||||
ResolvedReferences references = resolveReferences(command, sourceOccurrences);
|
||||
validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);
|
||||
if (!"CANCEL".equals(command.action())) {
|
||||
validateEffectiveResult(
|
||||
null,
|
||||
command,
|
||||
buildCandidate(command, references),
|
||||
baseDay,
|
||||
sourceOccurrences
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
|
||||
OverrideCommand command = validateAndNormalize(request);
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
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.TeacherAbsenceDto;
|
||||
import com.magistr.app.dto.TeacherAbsenceRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceReviewRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceWizardDto;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.TeacherAbsence;
|
||||
import com.magistr.app.model.TeacherAbsenceDecision;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.TeacherAbsenceDecisionRepository;
|
||||
import com.magistr.app.repository.TeacherAbsenceRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
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.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TeacherAbsenceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TeacherAbsenceService.class);
|
||||
private static final Set<String> ACTIVE_ABSENCE_STATUSES = Set.of("PENDING", "APPROVED", "RESOLVED");
|
||||
private static final Set<String> ALL_STATUSES = Set.of(
|
||||
"PENDING", "APPROVED", "REJECTED", "RESOLVED", "CANCELLED"
|
||||
);
|
||||
private static final Set<String> RESOLUTIONS = Set.of(
|
||||
"REPLACE_TEACHER", "MOVE_TIME", "CHANGE_CLASSROOM", "CANCEL", "REJECT"
|
||||
);
|
||||
private static final int MAX_RANGE_DAYS = 120;
|
||||
private static final int MAX_DECISIONS = 100;
|
||||
private static final int CANDIDATE_LIMIT = 5;
|
||||
|
||||
private final TeacherAbsenceRepository absenceRepository;
|
||||
private final TeacherAbsenceDecisionRepository decisionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TeacherSubjectRepository teacherSubjectRepository;
|
||||
private final TeacherDepartmentAssignmentRepository assignmentRepository;
|
||||
private final ClassroomRepository classroomRepository;
|
||||
private final TimeSlotRepository timeSlotRepository;
|
||||
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final ScheduleQueryService scheduleQueryService;
|
||||
private final ScheduleOverrideService scheduleOverrideService;
|
||||
private final BusinessTimeService businessTime;
|
||||
|
||||
public TeacherAbsenceService(
|
||||
TeacherAbsenceRepository absenceRepository,
|
||||
TeacherAbsenceDecisionRepository decisionRepository,
|
||||
UserRepository userRepository,
|
||||
TeacherSubjectRepository teacherSubjectRepository,
|
||||
TeacherDepartmentAssignmentRepository assignmentRepository,
|
||||
ClassroomRepository classroomRepository,
|
||||
TimeSlotRepository timeSlotRepository,
|
||||
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
ScheduleQueryService scheduleQueryService,
|
||||
ScheduleOverrideService scheduleOverrideService,
|
||||
BusinessTimeService businessTime
|
||||
) {
|
||||
this.absenceRepository = absenceRepository;
|
||||
this.decisionRepository = decisionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.teacherSubjectRepository = teacherSubjectRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.classroomRepository = classroomRepository;
|
||||
this.timeSlotRepository = timeSlotRepository;
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.scheduleQueryService = scheduleQueryService;
|
||||
this.scheduleOverrideService = scheduleOverrideService;
|
||||
this.businessTime = businessTime;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TeacherAbsenceDto> getAll(String status) {
|
||||
String normalizedStatus = cleanUpper(status);
|
||||
if (normalizedStatus != null && !ALL_STATUSES.contains(normalizedStatus)) {
|
||||
throw new IllegalArgumentException("Неизвестный статус отсутствия");
|
||||
}
|
||||
return absenceRepository.findAllWithTeacher().stream()
|
||||
.filter(this::canView)
|
||||
.filter(absence -> normalizedStatus == null || normalizedStatus.equals(absence.getStatus()))
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherAbsenceDto create(TeacherAbsenceRequest request) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Передайте данные отсутствия преподавателя");
|
||||
}
|
||||
validateRange(request.startDate(), request.endDate());
|
||||
String reason = clean(request.reason());
|
||||
if (reason == null) {
|
||||
throw new IllegalArgumentException("Причина отсутствия обязательна");
|
||||
}
|
||||
if (reason.length() > 2000) {
|
||||
throw new IllegalArgumentException("Причина отсутствия не может превышать 2000 символов");
|
||||
}
|
||||
|
||||
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.findByIdForUpdate(teacherId)
|
||||
.filter(user -> user.getRole() == Role.TEACHER)
|
||||
.filter(user -> !user.isArchivedRecord())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
|
||||
ensureDepartmentScope(teacher, request.startDate(), request.endDate(), current);
|
||||
if (absenceRepository.existsOverlapping(
|
||||
teacherId,
|
||||
ACTIVE_ABSENCE_STATUSES,
|
||||
request.startDate(),
|
||||
request.endDate()
|
||||
)) {
|
||||
throw new ScheduleConflictException(
|
||||
"Для преподавателя уже зарегистрировано отсутствие, пересекающееся с выбранным периодом"
|
||||
);
|
||||
}
|
||||
|
||||
var now = businessTime.now();
|
||||
TeacherAbsence absence = new TeacherAbsence();
|
||||
absence.setTeacher(teacher);
|
||||
absence.setStartDate(request.startDate());
|
||||
absence.setEndDate(request.endDate());
|
||||
absence.setReason(reason);
|
||||
absence.setRequestedBy(current.id());
|
||||
absence.setCreatedAt(now);
|
||||
absence.setUpdatedAt(now);
|
||||
if (current.role() == Role.TEACHER) {
|
||||
absence.setStatus("PENDING");
|
||||
} else {
|
||||
absence.setStatus("APPROVED");
|
||||
absence.setReviewedBy(current.id());
|
||||
absence.setReviewedAt(now);
|
||||
absence.setReviewComment("Зарегистрировано ответственным сотрудником");
|
||||
}
|
||||
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
|
||||
log.info(
|
||||
"Зарегистрировано отсутствие преподавателя {} на период {}–{}, статус {}",
|
||||
teacher.getId(),
|
||||
request.startDate(),
|
||||
request.endDate(),
|
||||
saved.getStatus()
|
||||
);
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherAbsenceDto review(Long id, TeacherAbsenceReviewRequest request) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
if (current.role() == Role.TEACHER) {
|
||||
throw forbidden("Преподаватель не может согласовывать собственное отсутствие");
|
||||
}
|
||||
if (request == null || request.approved() == null) {
|
||||
throw new IllegalArgumentException("Укажите решение по заявке на отсутствие");
|
||||
}
|
||||
TeacherAbsence absence = getForUpdate(id);
|
||||
ensureCanManage(absence, current);
|
||||
if (!"PENDING".equals(absence.getStatus())) {
|
||||
throw new IllegalArgumentException("Рассмотреть можно только отсутствие со статусом PENDING");
|
||||
}
|
||||
var now = businessTime.now();
|
||||
absence.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED");
|
||||
absence.setReviewedBy(current.id());
|
||||
absence.setReviewedAt(now);
|
||||
absence.setReviewComment(clean(request.comment()));
|
||||
absence.setUpdatedAt(now);
|
||||
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
|
||||
log.info("Отсутствие {} переведено в статус {}", id, saved.getStatus());
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherAbsenceDto cancel(Long id) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
TeacherAbsence absence = getForUpdate(id);
|
||||
if (current.role() == Role.TEACHER) {
|
||||
if (!Objects.equals(current.id(), absence.getTeacher().getId())) {
|
||||
throw forbidden("Нельзя отменить отсутствие другого преподавателя");
|
||||
}
|
||||
if (!"PENDING".equals(absence.getStatus())) {
|
||||
throw new IllegalArgumentException("Преподаватель может отменить только ожидающую заявку");
|
||||
}
|
||||
} else {
|
||||
ensureCanManage(absence, current);
|
||||
if (!Set.of("PENDING", "APPROVED").contains(absence.getStatus())) {
|
||||
throw new IllegalArgumentException("Это отсутствие уже нельзя отменить");
|
||||
}
|
||||
if (decisionRepository.countByAbsenceId(id) > 0) {
|
||||
throw new IllegalArgumentException("Нельзя отменить отсутствие после применения решений");
|
||||
}
|
||||
}
|
||||
absence.setStatus("CANCELLED");
|
||||
absence.setUpdatedAt(businessTime.now());
|
||||
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
|
||||
log.info("Отсутствие {} отменено", id);
|
||||
return toDto(saved);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TeacherAbsenceWizardDto getWizard(Long id) {
|
||||
TeacherAbsence absence = getVisible(id);
|
||||
List<TeacherAbsenceDecision> decisions = decisionRepository.findByAbsenceIdWithDetails(id);
|
||||
Map<LessonKey, TeacherAbsenceDecision> decisionsByLesson = decisions.stream()
|
||||
.collect(Collectors.toMap(
|
||||
decision -> new LessonKey(
|
||||
decision.getBaseRuleSlot().getId(),
|
||||
decision.getLessonDate()
|
||||
),
|
||||
decision -> decision,
|
||||
(first, second) -> second,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
List<RenderedLessonDto> lessons = affectedLessons(absence);
|
||||
List<TeacherAbsenceWizardDto.AffectedLesson> affected = lessons.stream()
|
||||
.map(lesson -> toAffectedLesson(
|
||||
absence,
|
||||
lesson,
|
||||
decisionsByLesson.get(new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()))
|
||||
))
|
||||
.toList();
|
||||
return new TeacherAbsenceWizardDto(
|
||||
toDto(absence),
|
||||
affected,
|
||||
decisions.stream().map(this::toDecisionLog).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TeacherAbsenceWizardDto resolve(Long id, TeacherAbsenceResolutionRequest request) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
if (current.role() == Role.TEACHER) {
|
||||
throw forbidden("Преподаватель не может применять решения мастера замены");
|
||||
}
|
||||
TeacherAbsence absence = getForUpdate(id);
|
||||
ensureCanManage(absence, current);
|
||||
if (!"APPROVED".equals(absence.getStatus())) {
|
||||
throw new IllegalArgumentException("Мастер замены доступен только для согласованного отсутствия");
|
||||
}
|
||||
|
||||
List<TeacherAbsenceResolutionRequest.Decision> requested = request == null
|
||||
|| request.decisions() == null
|
||||
? List.of()
|
||||
: request.decisions();
|
||||
if (requested.size() > MAX_DECISIONS) {
|
||||
throw new IllegalArgumentException("За один раз можно обработать не более 100 занятий");
|
||||
}
|
||||
|
||||
Map<LessonKey, RenderedLessonDto> affectedByKey = affectedLessons(absence).stream()
|
||||
.collect(Collectors.toMap(
|
||||
lesson -> new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()),
|
||||
lesson -> lesson,
|
||||
(first, second) -> first,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
Set<LessonKey> processedKeys = decisionRepository.findByAbsenceIdWithDetails(id).stream()
|
||||
.map(decision -> new LessonKey(
|
||||
decision.getBaseRuleSlot().getId(),
|
||||
decision.getLessonDate()
|
||||
))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (affectedByKey.isEmpty() && requested.isEmpty()) {
|
||||
absence.setStatus("RESOLVED");
|
||||
absence.setUpdatedAt(businessTime.now());
|
||||
absenceRepository.saveAndFlush(absence);
|
||||
return getWizard(id);
|
||||
}
|
||||
if (requested.isEmpty()) {
|
||||
throw new IllegalArgumentException("Выберите хотя бы одно решение");
|
||||
}
|
||||
|
||||
Set<LessonKey> requestKeys = new LinkedHashSet<>();
|
||||
for (TeacherAbsenceResolutionRequest.Decision item : requested) {
|
||||
validateDecisionKey(item);
|
||||
LessonKey key = new LessonKey(item.baseRuleSlotId(), item.lessonDate());
|
||||
if (!requestKeys.add(key)) {
|
||||
throw new IllegalArgumentException("Одно занятие нельзя обработать дважды");
|
||||
}
|
||||
RenderedLessonDto lesson = affectedByKey.get(key);
|
||||
if (lesson == null) {
|
||||
throw new IllegalArgumentException("Занятие не относится к выбранному отсутствию");
|
||||
}
|
||||
if (processedKeys.contains(key)) {
|
||||
throw new ScheduleConflictException("Для занятия решение уже зафиксировано в журнале");
|
||||
}
|
||||
applyDecision(absence, lesson, item, current.id());
|
||||
processedKeys.add(key);
|
||||
}
|
||||
|
||||
if (affectedByKey.keySet().stream().allMatch(processedKeys::contains)) {
|
||||
absence.setStatus("RESOLVED");
|
||||
}
|
||||
absence.setUpdatedAt(businessTime.now());
|
||||
absenceRepository.saveAndFlush(absence);
|
||||
log.info(
|
||||
"Для отсутствия {} обработано {} занятий, итоговый статус {}",
|
||||
id,
|
||||
requested.size(),
|
||||
absence.getStatus()
|
||||
);
|
||||
return getWizard(id);
|
||||
}
|
||||
|
||||
private void applyDecision(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson,
|
||||
TeacherAbsenceResolutionRequest.Decision request,
|
||||
Long userId
|
||||
) {
|
||||
String resolution = cleanUpper(request.resolution());
|
||||
if (resolution == null || !RESOLUTIONS.contains(resolution)) {
|
||||
throw new IllegalArgumentException("Неизвестный способ разрешения занятия");
|
||||
}
|
||||
if (lesson.scheduleOverrideId() != null && !"REJECT".equals(resolution)) {
|
||||
throw new ScheduleConflictException(
|
||||
"У занятия уже есть точечное изменение; откройте его в реестре или отклоните предложение"
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleOverride savedOverride = null;
|
||||
if (!"REJECT".equals(resolution)) {
|
||||
ScheduleOverrideDto overrideRequest = buildOverrideRequest(absence, lesson, request, resolution);
|
||||
validateQualifiedReplacement(absence, lesson, overrideRequest, resolution);
|
||||
ScheduleOverrideDto saved = scheduleOverrideService.create(overrideRequest);
|
||||
savedOverride = scheduleOverrideRepository.findById(saved.id())
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Созданное точечное изменение не найдено для записи в журнал"
|
||||
));
|
||||
}
|
||||
|
||||
ScheduleRuleSlot baseSlot = scheduleRuleSlotRepository.findById(request.baseRuleSlotId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
|
||||
TeacherAbsenceDecision decision = new TeacherAbsenceDecision();
|
||||
decision.setAbsence(absence);
|
||||
decision.setBaseRuleSlot(baseSlot);
|
||||
decision.setLessonDate(request.lessonDate());
|
||||
decision.setResolution(resolution);
|
||||
decision.setDecisionStatus("REJECT".equals(resolution) ? "REJECTED" : "APPLIED");
|
||||
decision.setScheduleOverride(savedOverride);
|
||||
decision.setComment(clean(request.comment()));
|
||||
decision.setDecidedBy(userId);
|
||||
decision.setDecidedAt(businessTime.now());
|
||||
decisionRepository.saveAndFlush(decision);
|
||||
}
|
||||
|
||||
private ScheduleOverrideDto buildOverrideRequest(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson,
|
||||
TeacherAbsenceResolutionRequest.Decision request,
|
||||
String resolution
|
||||
) {
|
||||
String action;
|
||||
LocalDate targetDate = null;
|
||||
Long timeSlotId = null;
|
||||
Long classroomId = null;
|
||||
Long teacherId = null;
|
||||
switch (resolution) {
|
||||
case "REPLACE_TEACHER" -> {
|
||||
action = "REPLACE";
|
||||
teacherId = required(request.newTeacherId(), "Выберите преподавателя для замены");
|
||||
classroomId = request.newClassroomId();
|
||||
}
|
||||
case "CHANGE_CLASSROOM" -> {
|
||||
action = "REPLACE";
|
||||
classroomId = required(request.newClassroomId(), "Выберите новую аудиторию");
|
||||
}
|
||||
case "MOVE_TIME" -> {
|
||||
action = "MOVE";
|
||||
timeSlotId = required(request.newTimeSlotId(), "Выберите новое время занятия");
|
||||
targetDate = request.targetLessonDate();
|
||||
classroomId = request.newClassroomId();
|
||||
if (targetDate == null
|
||||
|| (!targetDate.isBefore(absence.getStartDate())
|
||||
&& !targetDate.isAfter(absence.getEndDate()))) {
|
||||
throw new IllegalArgumentException(
|
||||
"Перенесите занятие на дату вне периода отсутствия преподавателя"
|
||||
);
|
||||
}
|
||||
}
|
||||
case "CANCEL" -> action = "CANCEL";
|
||||
default -> throw new IllegalArgumentException("Неизвестный способ разрешения занятия");
|
||||
}
|
||||
return new ScheduleOverrideDto(
|
||||
null,
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
targetDate,
|
||||
action,
|
||||
timeSlotId,
|
||||
classroomId,
|
||||
teacherId,
|
||||
null,
|
||||
"Отсутствие №" + absence.getId() + ": " + absence.getReason(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void validateQualifiedReplacement(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson,
|
||||
ScheduleOverrideDto override,
|
||||
String resolution
|
||||
) {
|
||||
if (!"REPLACE_TEACHER".equals(resolution)) {
|
||||
return;
|
||||
}
|
||||
Long teacherId = override.newTeacherId();
|
||||
List<Long> qualifiedIds = qualifiedTeacherIds(lesson);
|
||||
if (!qualifiedIds.contains(teacherId)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Выбранный преподаватель не подтверждён для дисциплины и типа занятия"
|
||||
);
|
||||
}
|
||||
User replacement = userRepository.findById(teacherId)
|
||||
.filter(user -> user.getRole() == Role.TEACHER)
|
||||
.filter(user -> user.isActiveOn(lesson.date()))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Активный преподаватель для замены не найден"));
|
||||
if (Objects.equals(replacement.getId(), absence.getTeacher().getId())
|
||||
|| absenceRepository.isTeacherAbsentOn(replacement.getId(), lesson.date())) {
|
||||
throw new IllegalArgumentException("Выбранный преподаватель отсутствует в дату занятия");
|
||||
}
|
||||
}
|
||||
|
||||
private TeacherAbsenceWizardDto.AffectedLesson toAffectedLesson(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson,
|
||||
TeacherAbsenceDecision decision
|
||||
) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
boolean canSuggest = "APPROVED".equals(absence.getStatus())
|
||||
&& current.role() != Role.TEACHER
|
||||
&& decision == null
|
||||
&& lesson.scheduleOverrideId() == null;
|
||||
return new TeacherAbsenceWizardDto.AffectedLesson(
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
lesson.startTime(),
|
||||
lesson.endTime(),
|
||||
lesson.subjectId(),
|
||||
lesson.subjectName(),
|
||||
lesson.lessonTypeId(),
|
||||
lesson.lessonTypeName(),
|
||||
lesson.classroomId(),
|
||||
lesson.classroomName(),
|
||||
lesson.groupNames() == null ? List.of() : lesson.groupNames(),
|
||||
lesson.scheduleOverrideId(),
|
||||
decision == null ? null : decision.getDecisionStatus(),
|
||||
canSuggest ? teacherCandidates(absence, lesson) : List.of(),
|
||||
canSuggest ? moveCandidates(absence, lesson) : List.of(),
|
||||
canSuggest ? classroomCandidates(absence, lesson) : List.of(),
|
||||
decision == null
|
||||
);
|
||||
}
|
||||
|
||||
private List<TeacherAbsenceWizardDto.Candidate> teacherCandidates(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson
|
||||
) {
|
||||
List<Long> ids = qualifiedTeacherIds(lesson);
|
||||
if (ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return userRepository.findAllById(ids).stream()
|
||||
.filter(user -> user.getRole() == Role.TEACHER)
|
||||
.filter(user -> user.isActiveOn(lesson.date()))
|
||||
.filter(user -> !Objects.equals(user.getId(), absence.getTeacher().getId()))
|
||||
.filter(user -> !absenceRepository.isTeacherAbsentOn(user.getId(), lesson.date()))
|
||||
.sorted(Comparator.comparing(User::getFullName, Comparator.nullsLast(String::compareTo)))
|
||||
.filter(user -> candidateIsValid(new ScheduleOverrideDto(
|
||||
null,
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
"REPLACE",
|
||||
null,
|
||||
null,
|
||||
user.getId(),
|
||||
null,
|
||||
"Предпросмотр замены преподавателя",
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
.map(user -> new TeacherAbsenceWizardDto.Candidate(
|
||||
user.getId(),
|
||||
displayName(user),
|
||||
user.getJobTitle()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Long> qualifiedTeacherIds(RenderedLessonDto lesson) {
|
||||
if (lesson.subjectId() == null || lesson.lessonTypeId() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return teacherSubjectRepository.findQualifiedTeacherIds(
|
||||
lesson.subjectId(),
|
||||
lesson.lessonTypeId()
|
||||
);
|
||||
}
|
||||
|
||||
private List<TeacherAbsenceWizardDto.MoveCandidate> moveCandidates(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson
|
||||
) {
|
||||
List<LocalDate> dates;
|
||||
try {
|
||||
dates = scheduleOverrideService.getAvailability(
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date()
|
||||
).availableDates();
|
||||
} catch (RuntimeException exception) {
|
||||
log.debug(
|
||||
"Не удалось построить даты переноса для слота {}: {}",
|
||||
lesson.scheduleRuleSlotId(),
|
||||
exception.getMessage()
|
||||
);
|
||||
return List.of();
|
||||
}
|
||||
List<LocalDate> nearestDates = dates.stream()
|
||||
.filter(date -> date.isBefore(absence.getStartDate()) || date.isAfter(absence.getEndDate()))
|
||||
.sorted(Comparator
|
||||
.comparingLong((LocalDate date) -> Math.abs(ChronoUnit.DAYS.between(lesson.date(), date)))
|
||||
.thenComparing(LocalDate::compareTo))
|
||||
.limit(7)
|
||||
.toList();
|
||||
List<TimeSlot> slots = timeSlotRepository.findAllForSchedule();
|
||||
List<TeacherAbsenceWizardDto.MoveCandidate> result = new ArrayList<>();
|
||||
for (LocalDate date : nearestDates) {
|
||||
for (TimeSlot slot : slots) {
|
||||
if (candidateIsValid(new ScheduleOverrideDto(
|
||||
null,
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
date,
|
||||
"MOVE",
|
||||
slot.getId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Предпросмотр переноса из-за отсутствия преподавателя",
|
||||
null,
|
||||
null
|
||||
))) {
|
||||
result.add(new TeacherAbsenceWizardDto.MoveCandidate(
|
||||
date,
|
||||
slot.getId(),
|
||||
slot.getOrderNumber(),
|
||||
slot.getStartTime(),
|
||||
slot.getEndTime(),
|
||||
"Ближайшее допустимое время"
|
||||
));
|
||||
if (result.size() >= CANDIDATE_LIMIT) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<TeacherAbsenceWizardDto.Candidate> classroomCandidates(
|
||||
TeacherAbsence absence,
|
||||
RenderedLessonDto lesson
|
||||
) {
|
||||
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
|
||||
.filter(classroom -> !Objects.equals(classroom.getId(), lesson.classroomId()))
|
||||
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
|
||||
.filter(classroom -> classroom.isActiveOn(lesson.date()))
|
||||
.sorted(Comparator.comparing(Classroom::getName, Comparator.nullsLast(String::compareTo)))
|
||||
.filter(classroom -> candidateIsValid(new ScheduleOverrideDto(
|
||||
null,
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
"REPLACE",
|
||||
null,
|
||||
classroom.getId(),
|
||||
null,
|
||||
null,
|
||||
"Предпросмотр замены аудитории",
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
.map(classroom -> new TeacherAbsenceWizardDto.Candidate(
|
||||
classroom.getId(),
|
||||
classroom.getName(),
|
||||
classroom.getBuilding()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean candidateIsValid(ScheduleOverrideDto request) {
|
||||
try {
|
||||
scheduleOverrideService.validateCandidate(request);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> affectedLessons(TeacherAbsence absence) {
|
||||
return scheduleQueryService.search(
|
||||
null,
|
||||
absence.getTeacher().getId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
absence.getStartDate(),
|
||||
absence.getEndDate()
|
||||
).stream()
|
||||
.filter(lesson -> lesson.scheduleRuleSlotId() != null)
|
||||
.sorted(Comparator
|
||||
.comparing(RenderedLessonDto::date)
|
||||
.thenComparing(
|
||||
RenderedLessonDto::startTime,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())
|
||||
)
|
||||
.thenComparing(
|
||||
RenderedLessonDto::subjectName,
|
||||
Comparator.nullsLast(String::compareTo)
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private TeacherAbsence getVisible(Long id) {
|
||||
TeacherAbsence absence = absenceRepository.findByIdWithTeacher(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
if (!canView(absence)) {
|
||||
throw forbidden("Недостаточно прав для просмотра отсутствия");
|
||||
}
|
||||
return absence;
|
||||
}
|
||||
|
||||
private TeacherAbsence getForUpdate(Long id) {
|
||||
return absenceRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> notFound(id));
|
||||
}
|
||||
|
||||
private boolean canView(TeacherAbsence absence) {
|
||||
AuthenticatedUser current = currentUser();
|
||||
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) {
|
||||
return true;
|
||||
}
|
||||
if (current.role() == Role.TEACHER) {
|
||||
return Objects.equals(current.id(), absence.getTeacher().getId());
|
||||
}
|
||||
return current.role() == Role.DEPARTMENT
|
||||
&& belongsToDepartment(absence, current.departmentId());
|
||||
}
|
||||
|
||||
private void ensureCanManage(TeacherAbsence absence, AuthenticatedUser current) {
|
||||
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) {
|
||||
return;
|
||||
}
|
||||
if (current.role() == Role.DEPARTMENT && belongsToDepartment(absence, current.departmentId())) {
|
||||
return;
|
||||
}
|
||||
throw forbidden("Недостаточно прав для изменения отсутствия");
|
||||
}
|
||||
|
||||
private void ensureDepartmentScope(
|
||||
User teacher,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
AuthenticatedUser current
|
||||
) {
|
||||
if (current.role() != Role.DEPARTMENT) {
|
||||
return;
|
||||
}
|
||||
if (current.departmentId() == null
|
||||
|| assignmentRepository.findForTeachersBetween(
|
||||
Set.of(teacher.getId()),
|
||||
startDate,
|
||||
endDate
|
||||
).stream().noneMatch(assignment -> Objects.equals(
|
||||
assignment.getDepartment().getId(),
|
||||
current.departmentId()
|
||||
))) {
|
||||
throw forbidden("Кафедра может регистрировать отсутствие только своего преподавателя");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean belongsToDepartment(TeacherAbsence absence, Long departmentId) {
|
||||
return departmentId != null
|
||||
&& assignmentRepository.findForTeachersBetween(
|
||||
Set.of(absence.getTeacher().getId()),
|
||||
absence.getStartDate(),
|
||||
absence.getEndDate()
|
||||
).stream().anyMatch(assignment -> Objects.equals(
|
||||
assignment.getDepartment().getId(),
|
||||
departmentId
|
||||
));
|
||||
}
|
||||
|
||||
private TeacherAbsenceDto toDto(TeacherAbsence absence) {
|
||||
User teacher = absence.getTeacher();
|
||||
return new TeacherAbsenceDto(
|
||||
absence.getId(),
|
||||
teacher == null ? null : teacher.getId(),
|
||||
teacher == null ? null : displayName(teacher),
|
||||
teacher == null ? null : teacher.getDepartmentId(),
|
||||
absence.getStartDate(),
|
||||
absence.getEndDate(),
|
||||
absence.getReason(),
|
||||
absence.getStatus(),
|
||||
absence.getRequestedBy(),
|
||||
absence.getReviewedBy(),
|
||||
absence.getReviewComment(),
|
||||
absence.getReviewedAt(),
|
||||
absence.getCreatedAt(),
|
||||
absence.getUpdatedAt(),
|
||||
absence.getId() == null ? 0 : decisionRepository.countByAbsenceId(absence.getId())
|
||||
);
|
||||
}
|
||||
|
||||
private TeacherAbsenceWizardDto.DecisionLog toDecisionLog(TeacherAbsenceDecision decision) {
|
||||
return new TeacherAbsenceWizardDto.DecisionLog(
|
||||
decision.getId(),
|
||||
decision.getBaseRuleSlot().getId(),
|
||||
decision.getLessonDate(),
|
||||
decision.getResolution(),
|
||||
decision.getDecisionStatus(),
|
||||
decision.getScheduleOverride() == null ? null : decision.getScheduleOverride().getId(),
|
||||
decision.getComment(),
|
||||
decision.getDecidedBy(),
|
||||
decision.getDecidedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalArgumentException("Дата начала и дата окончания отсутствия обязательны");
|
||||
}
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания отсутствия не может быть раньше даты начала");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Период одного отсутствия не может превышать 120 дней");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDecisionKey(TeacherAbsenceResolutionRequest.Decision decision) {
|
||||
if (decision == null || decision.baseRuleSlotId() == null || decision.lessonDate() == null) {
|
||||
throw new IllegalArgumentException("Для решения укажите занятие и исходную дату");
|
||||
}
|
||||
}
|
||||
|
||||
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 NoSuchElementException notFound(Long id) {
|
||||
return new NoSuchElementException("Отсутствие преподавателя с идентификатором " + id + " не найдено");
|
||||
}
|
||||
|
||||
private <T> T required(T value, String message) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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 String displayName(User user) {
|
||||
if (user == null) {
|
||||
return "Преподаватель не указан";
|
||||
}
|
||||
if (user.getFullName() != null && !user.getFullName().isBlank()) {
|
||||
return user.getFullName();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private record LessonKey(Long baseRuleSlotId, LocalDate lessonDate) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Реестр временных отсутствий преподавателей и журнал решений мастера замены.
|
||||
|
||||
CREATE TABLE teacher_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
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_absences_period CHECK (end_date >= start_date),
|
||||
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
|
||||
CONSTRAINT chk_teacher_absences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_teacher_period
|
||||
ON teacher_absences(teacher_id, start_date, end_date);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_status
|
||||
ON teacher_absences(status);
|
||||
|
||||
CREATE TABLE teacher_absence_decisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
resolution VARCHAR(30) NOT NULL,
|
||||
decision_status VARCHAR(20) NOT NULL,
|
||||
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
comment TEXT,
|
||||
decided_by BIGINT REFERENCES users(id),
|
||||
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
|
||||
absence_id,
|
||||
base_rule_slot_id,
|
||||
lesson_date
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
|
||||
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_status CHECK (
|
||||
decision_status IN ('APPLIED', 'REJECTED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absence_decisions_absence
|
||||
ON teacher_absence_decisions(absence_id, lesson_date);
|
||||
|
||||
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
|
||||
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
|
||||
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
|
||||
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.TeacherAbsenceDto;
|
||||
import com.magistr.app.dto.TeacherAbsenceWizardDto;
|
||||
import com.magistr.app.service.TeacherAbsenceService;
|
||||
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.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 TeacherAbsenceControllerTest {
|
||||
|
||||
private TeacherAbsenceService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(TeacherAbsenceService.class);
|
||||
mockMvc = standaloneSetup(new TeacherAbsenceController(service))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsAbsenceWithRussianReason() throws Exception {
|
||||
when(service.create(any())).thenReturn(absence("APPROVED"));
|
||||
|
||||
mockMvc.perform(post("/api/teacher-absences")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"teacherId": 11,
|
||||
"startDate": "2026-09-10",
|
||||
"endDate": "2026-09-12",
|
||||
"reason": "Командировка"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.teacherName").value("Иванов Иван Иванович"))
|
||||
.andExpect(jsonPath("$.status").value("APPROVED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsWizardWithAffectedLessonsAndJournal() throws Exception {
|
||||
TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto(
|
||||
absence("APPROVED"),
|
||||
List.of(new TeacherAbsenceWizardDto.AffectedLesson(
|
||||
501L,
|
||||
LocalDate.of(2026, 9, 10),
|
||||
java.time.LocalTime.of(9, 0),
|
||||
java.time.LocalTime.of(10, 30),
|
||||
40L,
|
||||
"Базы данных",
|
||||
3L,
|
||||
"Лекция",
|
||||
8L,
|
||||
"101",
|
||||
List.of("ИВТ-21"),
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
true
|
||||
)),
|
||||
List.of()
|
||||
);
|
||||
when(service.getWizard(101L)).thenReturn(wizard);
|
||||
|
||||
mockMvc.perform(get("/api/teacher-absences/101/wizard"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lessons[0].subjectName").value("Базы данных"))
|
||||
.andExpect(jsonPath("$.lessons[0].cancellationAvailable").value(true));
|
||||
|
||||
verify(service).getWizard(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesSelectedDecisionsAsBatch() throws Exception {
|
||||
TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto(
|
||||
absence("RESOLVED"),
|
||||
List.of(),
|
||||
List.of()
|
||||
);
|
||||
when(service.resolve(eq(101L), any())).thenReturn(wizard);
|
||||
|
||||
mockMvc.perform(post("/api/teacher-absences/101/resolve")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"decisions": [{
|
||||
"baseRuleSlotId": 501,
|
||||
"lessonDate": "2026-09-10",
|
||||
"resolution": "CANCEL"
|
||||
}]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.absence.status").value("RESOLVED"));
|
||||
}
|
||||
|
||||
private TeacherAbsenceDto absence(String status) {
|
||||
return new TeacherAbsenceDto(
|
||||
101L,
|
||||
11L,
|
||||
"Иванов Иван Иванович",
|
||||
7L,
|
||||
LocalDate.of(2026, 9, 10),
|
||||
LocalDate.of(2026, 9, 12),
|
||||
"Командировка",
|
||||
status,
|
||||
1L,
|
||||
1L,
|
||||
null,
|
||||
Instant.parse("2026-08-06T10:00:00Z"),
|
||||
Instant.parse("2026-08-06T10:00:00Z"),
|
||||
Instant.parse("2026-08-06T10:00:00Z"),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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.TeacherAbsenceDto;
|
||||
import com.magistr.app.dto.TeacherAbsenceRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.TeacherAbsence;
|
||||
import com.magistr.app.model.TeacherAbsenceDecision;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.TeacherAbsenceDecisionRepository;
|
||||
import com.magistr.app.repository.TeacherAbsenceRepository;
|
||||
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
|
||||
import com.magistr.app.repository.TeacherSubjectRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TeacherAbsenceServiceTest {
|
||||
|
||||
private static final LocalDate START = LocalDate.of(2026, 9, 10);
|
||||
private static final LocalDate END = LocalDate.of(2026, 9, 12);
|
||||
private static final Instant NOW = Instant.parse("2026-08-06T10:00:00Z");
|
||||
|
||||
@Mock
|
||||
private TeacherAbsenceRepository absenceRepository;
|
||||
@Mock
|
||||
private TeacherAbsenceDecisionRepository decisionRepository;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private TeacherSubjectRepository teacherSubjectRepository;
|
||||
@Mock
|
||||
private TeacherDepartmentAssignmentRepository assignmentRepository;
|
||||
@Mock
|
||||
private ClassroomRepository classroomRepository;
|
||||
@Mock
|
||||
private TimeSlotRepository timeSlotRepository;
|
||||
@Mock
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
@Mock
|
||||
private ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
@Mock
|
||||
private ScheduleQueryService scheduleQueryService;
|
||||
@Mock
|
||||
private ScheduleOverrideService scheduleOverrideService;
|
||||
|
||||
private TeacherAbsenceService service;
|
||||
private User teacher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
teacher = new User();
|
||||
teacher.setId(11L);
|
||||
teacher.setUsername("teacher");
|
||||
teacher.setFullName("Иванов Иван Иванович");
|
||||
teacher.setRole(Role.TEACHER);
|
||||
teacher.setDepartmentId(7L);
|
||||
|
||||
BusinessTimeService time = new BusinessTimeService(
|
||||
Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
BusinessTimeService.DEFAULT_ZONE
|
||||
);
|
||||
service = new TeacherAbsenceService(
|
||||
absenceRepository,
|
||||
decisionRepository,
|
||||
userRepository,
|
||||
teacherSubjectRepository,
|
||||
assignmentRepository,
|
||||
classroomRepository,
|
||||
timeSlotRepository,
|
||||
scheduleRuleSlotRepository,
|
||||
scheduleOverrideRepository,
|
||||
scheduleQueryService,
|
||||
scheduleOverrideService,
|
||||
time
|
||||
);
|
||||
when(userRepository.findByIdForUpdate(teacher.getId())).thenReturn(Optional.of(teacher));
|
||||
when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(false);
|
||||
when(absenceRepository.saveAndFlush(any(TeacherAbsence.class))).thenAnswer(invocation -> {
|
||||
TeacherAbsence absence = invocation.getArgument(0);
|
||||
ReflectionTestUtils.setField(absence, "id", 101L);
|
||||
return absence;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherCreatesPendingRequestOnlyForSelf() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L));
|
||||
|
||||
TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest(
|
||||
null,
|
||||
START,
|
||||
END,
|
||||
" Болезнь "
|
||||
));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PENDING");
|
||||
assertThat(result.teacherId()).isEqualTo(11L);
|
||||
assertThat(result.reason()).isEqualTo("Болезнь");
|
||||
assertThat(result.requestedBy()).isEqualTo(11L);
|
||||
verify(userRepository).findByIdForUpdate(11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void responsibleEmployeeCreatesApprovedAbsence() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
|
||||
TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest(
|
||||
11L,
|
||||
START,
|
||||
END,
|
||||
"Командировка"
|
||||
));
|
||||
|
||||
assertThat(result.status()).isEqualTo("APPROVED");
|
||||
assertThat(result.reviewedBy()).isEqualTo(1L);
|
||||
assertThat(result.reviewedAt()).isEqualTo(NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOverlappingActiveAbsence() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(new TeacherAbsenceRequest(
|
||||
11L,
|
||||
START,
|
||||
END,
|
||||
"Командировка"
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessageContaining("пересекающееся");
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherCannotResolveReplacementWizard() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L));
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(
|
||||
101L,
|
||||
new TeacherAbsenceResolutionRequest(List.of())
|
||||
))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("не может применять решения");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absenceWithoutAffectedLessonsCanBeCompleted() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
TeacherAbsence absence = absence("APPROVED");
|
||||
when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence));
|
||||
when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence));
|
||||
when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of());
|
||||
when(scheduleQueryService.search(
|
||||
any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(List.of());
|
||||
|
||||
var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of()));
|
||||
|
||||
assertThat(result.absence().status()).isEqualTo("RESOLVED");
|
||||
verify(absenceRepository).saveAndFlush(absence);
|
||||
}
|
||||
|
||||
@Test
|
||||
void previousRejectedDecisionCountsWhenRemainingLessonsAreResolved() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
TeacherAbsence absence = absence("APPROVED");
|
||||
ScheduleRuleSlot rejectedSlot = new ScheduleRuleSlot();
|
||||
rejectedSlot.setId(201L);
|
||||
TeacherAbsenceDecision rejected = new TeacherAbsenceDecision();
|
||||
rejected.setBaseRuleSlot(rejectedSlot);
|
||||
rejected.setLessonDate(START);
|
||||
rejected.setResolution("REJECT");
|
||||
rejected.setDecisionStatus("REJECTED");
|
||||
rejected.setDecidedAt(NOW);
|
||||
ScheduleRuleSlot remainingSlot = new ScheduleRuleSlot();
|
||||
remainingSlot.setId(202L);
|
||||
|
||||
when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence));
|
||||
when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence));
|
||||
when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of(rejected));
|
||||
when(scheduleRuleSlotRepository.findById(202L)).thenReturn(Optional.of(remainingSlot));
|
||||
when(scheduleQueryService.search(
|
||||
any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(List.of(lesson(201L, START), lesson(202L, END)));
|
||||
|
||||
var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of(
|
||||
new TeacherAbsenceResolutionRequest.Decision(
|
||||
202L,
|
||||
END,
|
||||
"REJECT",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Решение будет принято вручную"
|
||||
)
|
||||
)));
|
||||
|
||||
assertThat(result.absence().status()).isEqualTo("RESOLVED");
|
||||
verify(decisionRepository).saveAndFlush(any(TeacherAbsenceDecision.class));
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(Long slotId, LocalDate date) {
|
||||
return new RenderedLessonDto(
|
||||
301L,
|
||||
slotId,
|
||||
date,
|
||||
date.getDayOfWeek().getValue(),
|
||||
"День недели",
|
||||
2,
|
||||
ScheduleParity.BOTH,
|
||||
401L,
|
||||
1,
|
||||
LocalTime.of(9, 0),
|
||||
LocalTime.of(10, 30),
|
||||
501L,
|
||||
"Математика",
|
||||
teacher.getId(),
|
||||
teacher.getFullName(),
|
||||
601L,
|
||||
"101",
|
||||
701L,
|
||||
"Лекция",
|
||||
"Очно",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(801L),
|
||||
List.of("ИВТ-101"),
|
||||
"EDUCATION",
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
|
||||
private TeacherAbsence absence(String status) {
|
||||
TeacherAbsence absence = new TeacherAbsence();
|
||||
ReflectionTestUtils.setField(absence, "id", 101L);
|
||||
absence.setTeacher(teacher);
|
||||
absence.setStartDate(START);
|
||||
absence.setEndDate(END);
|
||||
absence.setReason("Болезнь");
|
||||
absence.setStatus(status);
|
||||
absence.setRequestedBy(1L);
|
||||
absence.setCreatedAt(NOW);
|
||||
absence.setUpdatedAt(NOW);
|
||||
return absence;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user