1 Задача

This commit is contained in:
dipatrik10
2026-08-06 01:10:33 +03:00
parent 28268a38c0
commit 5276729a69
33 changed files with 3569 additions and 19 deletions

View File

@@ -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();
};

View File

@@ -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);
}
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherAbsenceRequest(
Long teacherId,
LocalDate startDate,
LocalDate endDate,
String reason
) {
}

View File

@@ -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
) {
}
}

View File

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

View File

@@ -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
) {
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
);
}

View File

@@ -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);

View File

@@ -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) {
}
}

View File

@@ -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 'Выбранный способ разрешения затронутого занятия';

View File

@@ -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
);
}
}

View File

@@ -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;
}
}