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

View File

@@ -841,6 +841,77 @@ API возвращает `409 Conflict`; соседние интервалы и
}
```
### Отсутствия преподавателей и мастер замены
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/teacher-absences?status=` | Доступный текущей роли реестр отсутствий |
| `POST` | `/api/teacher-absences` | Зарегистрировать отсутствие или отправить преподавательскую заявку |
| `POST` | `/api/teacher-absences/{id}/review` | Подтвердить или отклонить заявку преподавателя |
| `DELETE` | `/api/teacher-absences/{id}` | Отменить ожидающее или ещё не обработанное отсутствие |
| `GET` | `/api/teacher-absences/{id}/wizard` | Затронутые занятия, проверенные кандидаты и журнал решений |
| `POST` | `/api/teacher-absences/{id}/resolve` | Атомарно применить выбранные решения |
Доступ имеют `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и `TEACHER`. Преподаватель видит и
создаёт только собственные заявки; они получают статус `PENDING`. Кафедра работает только с
преподавателями, назначенными ей на период отсутствия. Ответственный сотрудник создаёт
сразу согласованную запись `APPROVED`, а кафедра, учебный отдел или администратор могут
перевести преподавательскую заявку в `APPROVED` либо `REJECTED`.
```json
{
"teacherId": 15,
"startDate": "2026-09-10",
"endDate": "2026-09-12",
"reason": "Командировка"
}
```
Для роли `TEACHER` поле `teacherId` игнорируется в пользу ID текущего пользователя. Период
включительный, не длиннее 120 дней и не может пересекаться с другой активной записью того
же преподавателя.
`GET /{id}/wizard` ищет фактические занятия через `ScheduleQueryService`. Кандидат на замену
должен иметь связь с дисциплиной; если для этой связи настроены `teacher_lesson_types`, тип
занятия также обязан совпасть. Из списка исключаются архивные, занятые и отсутствующие
преподаватели. Варианты переноса выбираются среди ближайших учебных дат вне периода
отсутствия, аудитории — среди активных и свободных. Каждый кандидат заранее проходит тот же
валидатор `ScheduleOverrideService`, что и обычная разовая правка.
Пример группового подтверждения:
```json
{
"decisions": [
{
"baseRuleSlotId": 31,
"lessonDate": "2026-09-10",
"resolution": "REPLACE_TEACHER",
"newTeacherId": 28,
"comment": "Согласовано кафедрой"
},
{
"baseRuleSlotId": 44,
"lessonDate": "2026-09-11",
"resolution": "MOVE_TIME",
"targetLessonDate": "2026-09-14",
"newTimeSlotId": 3
},
{
"baseRuleSlotId": 52,
"lessonDate": "2026-09-12",
"resolution": "REJECT"
}
]
}
```
Допустимые решения: `REPLACE_TEACHER`, `MOVE_TIME`, `CHANGE_CLASSROOM`, `CANCEL`, `REJECT`.
Первые четыре создают обычный `schedule_override`; `REJECT` фиксирует осознанное отклонение
без изменения расписания. Весь пакет выполняется в одной транзакции: конфликт любого
выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не
меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `RESOLVED`.
## Загруженность
| Метод | URL | Назначение |

View File

@@ -312,6 +312,35 @@ constraint. При чтении API разворачивает периоды о
для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
дату и одинаковые ресурсы проверяются последовательно.
### Отсутствия преподавателей и мастер замены
Отсутствие хранится отдельно от базовых правил расписания и проходит жизненный цикл
`PENDING → APPROVED → RESOLVED`; отклонённые и отменённые записи получают соответственно
`REJECTED` и `CANCELLED`. Преподаватель создаёт заявку только для себя со статусом
`PENDING`. Администратор, учебный отдел и кафедра могут регистрировать согласованное
отсутствие сразу. Кафедра видит и подтверждает только преподавателей, назначенных ей на
выбранный период через `teacher_department_assignments`.
Для согласованного отсутствия `ScheduleQueryService` строит фактические занятия
преподавателя в пределах периода. Мастер предлагает не более пяти проверенных вариантов
каждого типа:
- преподавателей со связью по дисциплине и допустимому типу занятия;
- ближайшие учебные даты вне периода отсутствия и эффективные временные слоты;
- активные свободные аудитории;
- отмену занятия или явное отклонение предложений.
Если у связи преподавателя с дисциплиной нет настроенных строк `teacher_lesson_types`, она
считается разрешающей все типы; при наличии настроек требуется точное совпадение типа.
Кандидаты-замены исключаются, если архивированы, заняты или сами отсутствуют в дату пары.
Каждый вариант до показа проходит read-only проверку `ScheduleOverrideService`, а выбранный
пакет повторно валидируется и сохраняется в одной транзакции. Поэтому время, аудитория,
группы и подгруппы проверяются тем же механизмом, что и ручные точечные изменения.
Применённые действия создают обычные `schedule_overrides`; базовое правило семестра не
изменяется. Таблица `teacher_absence_decisions` фиксирует как применённые, так и отклонённые
решения. Незаполненные строки мастер не меняет, и к ним можно вернуться позже.
---
## Привязка преподаватель ↔ дисциплина
@@ -335,6 +364,9 @@ constraint. При чтении API разворачивает периоды о
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
### Управление инцидентами
- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
- Автоматическая подсветка конфликтующих пар (Red Zone)
- Resolution Wizard: предложение замены преподавателя или переноса занятия
Регистрация отсутствий и Resolution Wizard реализованы. Дальнейшее развитие этого контура:
- автоматическая рассылка уведомлений группам и преподавателям;
- пакетная обработка нескольких одновременных отсутствий;
- ранжирование вариантов по окнам и дополнительной нагрузке.

View File

@@ -334,6 +334,34 @@ erDiagram
TEXT comment
}
teacher_absences {
BIGSERIAL id PK
BIGINT teacher_id FK
DATE start_date
DATE end_date
TEXT reason
VARCHAR status
BIGINT requested_by FK
BIGINT reviewed_by FK
TEXT review_comment
TIMESTAMPTZ reviewed_at
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
teacher_absence_decisions {
BIGSERIAL id PK
BIGINT absence_id FK
BIGINT base_rule_slot_id FK
DATE lesson_date
VARCHAR resolution
VARCHAR decision_status
BIGINT schedule_override_id FK
TEXT comment
BIGINT decided_by FK
TIMESTAMPTZ decided_at
}
schedule_rule_slot_subgroups {
BIGINT schedule_rule_slot_id FK,PK
BIGINT subgroup_id FK,PK
@@ -385,6 +413,11 @@ erDiagram
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
classrooms ||--o{ schedule_overrides : "new_classroom_id"
users ||--o{ schedule_overrides : "new_teacher_id"
users ||--o{ teacher_absences : "teacher_id/requested_by/reviewed_by"
teacher_absences ||--o{ teacher_absence_decisions : "absence_id"
schedule_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id"
schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id"
users ||--o{ teacher_absence_decisions : "decided_by"
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
@@ -905,6 +938,44 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
конфликты проверяются транзакционным сервисом, а не SQL CHECK.
#### `teacher_absences` — Отсутствия преподавателей
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID отсутствия |
| `teacher_id` | BIGINT FK → users | Отсутствующий преподаватель |
| `start_date`, `end_date` | DATE | Включительные границы периода |
| `reason` | TEXT | Причина длиной 1–2000 символов |
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `RESOLVED`, `CANCELLED` |
| `requested_by` | BIGINT FK → users | Автор регистрации или заявки |
| `reviewed_by` | BIGINT FK → users | Подтвердивший или отклонивший сотрудник |
| `review_comment` | TEXT | Комментарий согласования |
| `reviewed_at` | TIMESTAMPTZ | Время согласования |
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
CHECK запрещает обратный период, пустую или слишком длинную причину и неизвестный статус.
Индекс `(teacher_id, start_date, end_date)` ускоряет поиск пересекающихся отсутствий и
проверку кандидатов мастера.
#### `teacher_absence_decisions` — Журнал мастера замены
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | BIGSERIAL PK | ID записи журнала |
| `absence_id` | BIGINT FK → teacher_absences | Инцидент отсутствия |
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия |
| `lesson_date` | DATE | Исходная дата занятия |
| `resolution` | VARCHAR(30) | Замена, перенос, аудитория, отмена или отклонение |
| `decision_status` | VARCHAR(20) | `APPLIED` либо `REJECTED` |
| `schedule_override_id` | BIGINT FK → schedule_overrides | Созданная разовая правка, если есть |
| `comment` | TEXT | Комментарий ответственного сотрудника |
| `decided_by` | BIGINT FK → users | Автор решения |
| `decided_at` | TIMESTAMPTZ | Время решения |
Уникальность `(absence_id, base_rule_slot_id, lesson_date)` не позволяет повторно обработать
одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись
сохраняется.
---
## Flyway миграции
@@ -924,12 +995,15 @@ lifecycle ресурсов, эффективная сетка целевого
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` |
### Этап разработки
По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline
`V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика
также входят непосредственно в V1.
Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были
объединены в baseline `V1`. После фиксации baseline нумерация начата заново: текущая `V2`
добавляет отсутствия и мастер замены, не изменяя контрольную сумму `V1`.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1.
Перед применением этой редакции требуется полностью пустая tenant-схема.
### Полный сброс БД (локально)

View File

@@ -53,6 +53,10 @@
## 1. Отсутствия преподавателей и мастер замены
**Статус: реализовано в MVP.** Реестр, согласование преподавательских заявок, поиск
затронутых занятий, проверенные варианты, групповое применение `schedule_overrides` и журнал
решений доступны в системе.
### Проблема и пользователи
Болезнь, командировка или другое временное отсутствие преподавателя затрагивает сразу
@@ -60,9 +64,8 @@
доступность ресурсов и создать каждое точечное изменение. Функция нужна учебному отделу,
кафедрам и преподавателям.
Регистрация отсутствий и мастер разрешения инцидентов уже обозначены как планируемые
бизнес-правила в [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md), но отдельной модели и рабочего
сценария пока нет. Существующая Red Zone уже выявляет конфликты сформированного расписания;
Регистрация отсутствий и мастер разрешения инцидентов реализуют правила из
[BUSINESS_LOGIC.md](BUSINESS_LOGIC.md). Существующая Red Zone уже выявляет конфликты сформированного расписания;
новизна этого направления заключается в реестре причин, массовом поиске затронутых занятий
и управляемом подборе решений.
@@ -504,4 +507,4 @@ Red Zone уже выявляет накладки и превышение вме
**Ожидаемый результат:** новый самостоятельный контур учебного процесса на основе уже
существующих справочников и календаря. **Сложность:** L. **Демонстрационный эффект:**
максимальный.
максимальный.

View File

@@ -34,6 +34,7 @@ frontend/
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
│ └── security-policy.test.mjs # Web Storage, XSS, пароли, язык, CSP и Dockerfile
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
@@ -49,7 +50,8 @@ frontend/
│ │ ├── components.css # Кнопки, таблицы, карточки, формы
│ │ ├── modals.css # Модальные окна
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
│ │ └── departments-data.css # Стили создания кафедры/специальности
│ │ ├── departments-data.css # Стили создания кафедры/специальности
│ │ └── teacher-absences.css # Реестр инцидентов и полноэкранный мастер замены
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
@@ -68,6 +70,7 @@ frontend/
│ │ ├── department-workspace.js # Кабинет кафедры в общей панели
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
│ │ ├── teacher-absences.js # Реестр, согласование и групповой мастер замены
│ │ ├── schedule.js # Конструктор правил расписания
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
@@ -83,6 +86,7 @@ frontend/
│ │ ├── university-structure.html
│ │ ├── department-workspace.html
│ │ ├── schedule-view.html
│ │ ├── teacher-absences.html
│ │ ├── schedule.html
│ │ ├── academic-calendar.html
│ │ └── auditorium-workload.html
@@ -142,8 +146,8 @@ frontend/
| Роль | Доступные вкладки |
|------|-------------------|
| `ADMIN` | Все вкладки |
| `EDUCATION_OFFICE` | Просмотр расписаний, конструктор расписания, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний |
| `EDUCATION_OFFICE` | Просмотр и конструктор расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей |
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
Пути `/department/` и `/edu-office/` оставлены как входные redirect-страницы в общую панель. Отдельные кабинеты не дублируют UI админ-панели.
@@ -166,6 +170,7 @@ frontend/
| `university-structure` | Кафедры, специальности и профили обучения; профили доступны отдельной внутренней вкладкой и через кнопку специальности | `/api/departments`, `/api/specialties`, `/api/specialties/{id}/profiles` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` |
| `teacher-absences` | Реестр отсутствий, подтверждение преподавательских заявок, проверенные варианты замен/переносов и журнал решений | `/api/teacher-absences`, `/api/users/teachers` |
| `schedule` | Конструктор правил динамического расписания с выезжающей визуальной матрицей групп по дням и времени | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` |
| `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` |
@@ -181,6 +186,11 @@ frontend/
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Вкладка `teacher-absences` доступна администратору, учебному отделу и кафедре. Верхний
командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и
причину, а реестр разделяет статусы согласования. Полноэкранный мастер выводит каждое
затронутое занятие отдельной строкой и предлагает только кандидатов, уже проверенных
backend. Пустая строка не отправляется; выбранные решения применяются одним пакетом.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
@@ -325,6 +335,9 @@ if (!session || !AUTHORIZED_ROLES.includes(session.role)) {
- выбор даты через `input[type="date"]`;
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, типа занятия, лабораторных подгрупп, аудитории и всех групп правила.
- форма собственной заявки на отсутствие через `POST /api/teacher-absences`;
- список статусов заявок и отмена ещё не подтверждённой записи через
`DELETE /api/teacher-absences/{id}`.
Если refresh-cookie недействительна или роль не `TEACHER`, страница возвращает пользователя на вход.

View File

@@ -0,0 +1,556 @@
.absence-command-card {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
gap: 2rem;
padding: 2rem;
border: 1px solid color-mix(in srgb, #f59e0b 30%, var(--bg-card-border));
border-radius: var(--radius-lg);
background:
linear-gradient(125deg, color-mix(in srgb, #f59e0b 13%, transparent), transparent 48%),
var(--bg-card);
box-shadow: 0 22px 70px color-mix(in srgb, #0f172a 22%, transparent);
}
.absence-command-card::after {
content: '';
position: absolute;
width: 280px;
height: 280px;
right: -120px;
top: -160px;
border: 42px solid color-mix(in srgb, #f59e0b 14%, transparent);
border-radius: 50%;
pointer-events: none;
}
.absence-command-copy {
position: relative;
z-index: 1;
}
.absence-kicker,
.absence-section-number {
color: #f59e0b;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.absence-command-copy h2 {
max-width: 720px;
margin: 0.45rem 0 0.65rem;
font-size: clamp(1.55rem, 3vw, 2.35rem);
line-height: 1.08;
letter-spacing: -0.04em;
}
.absence-command-copy p {
max-width: 680px;
color: var(--text-secondary);
line-height: 1.65;
}
.absence-command-stats {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
align-self: stretch;
}
.absence-command-stats article {
display: flex;
min-height: 118px;
flex-direction: column;
justify-content: flex-end;
padding: 1rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-input) 76%, transparent);
}
.absence-command-stats strong {
font-size: 2rem;
line-height: 1;
}
.absence-command-stats span {
margin-top: 0.5rem;
color: var(--text-secondary);
font-size: 0.75rem;
}
.absence-create-card,
.absence-registry-card {
border-radius: var(--radius-lg);
}
.absence-create-card .card-header-row > div:first-child,
.absence-registry-head > div:first-child {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.absence-form-grid {
display: grid;
grid-template-columns: minmax(220px, 1.25fr) repeat(2, minmax(160px, 0.7fr)) minmax(260px, 1.5fr) auto;
gap: 1rem;
align-items: end;
}
.absence-create-submit {
min-height: 44px;
white-space: nowrap;
}
.absence-registry-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.absence-registry-actions .custom-select-wrapper,
.absence-registry-actions > select {
min-width: 210px;
}
.absence-registry {
display: grid;
gap: 0.75rem;
}
.absence-registry-item {
display: grid;
grid-template-columns: 68px minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-input) 64%, transparent);
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.absence-registry-item:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, #f59e0b 36%, var(--bg-card-border));
background: color-mix(in srgb, #f59e0b 5%, var(--bg-input));
}
.absence-date-rail {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 0 0.3rem;
min-height: 68px;
padding-right: 0.85rem;
border-right: 1px solid var(--bg-card-border);
}
.absence-date-rail strong {
font-size: 1.15rem;
}
.absence-date-rail span {
color: var(--text-secondary);
font-size: 0.7rem;
text-transform: uppercase;
}
.absence-date-rail i {
grid-column: 1 / -1;
width: 100%;
height: 1px;
margin: 0.25rem 0;
background: linear-gradient(90deg, #f59e0b, transparent);
}
.absence-registry-title,
.absence-meta-row,
.absence-registry-buttons {
display: flex;
align-items: center;
}
.absence-registry-title {
justify-content: space-between;
gap: 1rem;
}
.absence-registry-title h3 {
margin: 0;
font-size: 1rem;
}
.absence-registry-title p {
margin: 0.25rem 0 0;
color: var(--text-secondary);
font-size: 0.86rem;
}
.absence-meta-row {
flex-wrap: wrap;
gap: 0.45rem 1rem;
margin-top: 0.65rem;
color: var(--text-secondary);
font-size: 0.76rem;
}
.absence-meta-row span:not(:last-child)::after {
content: '•';
margin-left: 1rem;
color: #f59e0b;
}
.absence-status {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 0.65rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.03em;
white-space: nowrap;
}
.absence-status.is-pending { color: #f59e0b; }
.absence-status.is-approved { color: #fb7185; }
.absence-status.is-resolved { color: #34d399; }
.absence-status.is-rejected,
.absence-status.is-cancelled { color: var(--text-secondary); }
.absence-registry-buttons {
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
max-width: 270px;
}
.absence-empty {
display: flex;
min-height: 150px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
color: var(--text-secondary);
text-align: center;
}
.absence-empty strong {
color: var(--text-primary);
}
body.absence-wizard-open {
overflow: hidden;
}
.absence-wizard-overlay {
position: fixed;
inset: 0;
z-index: 2400;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgba(3, 7, 18, 0.72);
backdrop-filter: blur(10px);
}
.absence-wizard-overlay[hidden] {
display: none;
}
.absence-wizard {
display: flex;
width: min(1180px, 100%);
max-height: min(900px, calc(100vh - 2.5rem));
flex-direction: column;
overflow: hidden;
border: 1px solid color-mix(in srgb, #f59e0b 35%, var(--bg-card-border));
border-radius: 24px;
background: color-mix(in srgb, var(--bg-card) 94%, #0f172a);
box-shadow: 0 35px 110px rgba(0, 0, 0, 0.48);
animation: absence-wizard-in 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes absence-wizard-in {
from { opacity: 0; transform: translateY(24px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.absence-wizard-header,
.absence-wizard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem 1.5rem;
}
.absence-wizard-header {
border-bottom: 1px solid var(--bg-card-border);
}
.absence-wizard-header h2 {
margin: 0.25rem 0;
}
.absence-wizard-header p {
margin: 0;
color: var(--text-secondary);
}
#absence-wizard-close {
font-size: 1.65rem;
}
.absence-wizard-progress {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1.5rem 0;
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.absence-wizard-progress span.done {
color: #f59e0b;
}
.absence-wizard-progress i {
width: 56px;
height: 1px;
margin: 0 0.75rem;
background: var(--bg-card-border);
}
#absence-wizard-alert {
margin: 0.9rem 1.5rem 0;
}
.absence-wizard-lessons {
display: grid;
gap: 0.65rem;
padding: 1.25rem 1.5rem;
overflow-y: auto;
}
.absence-lesson {
display: grid;
grid-template-columns: 100px minmax(0, 1fr) minmax(280px, 0.85fr);
gap: 1rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--bg-card-border);
border-radius: 14px;
background: color-mix(in srgb, var(--bg-input) 70%, transparent);
}
.absence-lesson-time {
display: flex;
flex-direction: column;
padding-right: 1rem;
border-right: 1px solid var(--bg-card-border);
}
.absence-lesson-time strong {
font-size: 1.15rem;
}
.absence-lesson-time span,
.absence-lesson-time small,
.absence-lesson-copy p {
color: var(--text-secondary);
}
.absence-lesson-time small {
margin-top: 0.4rem;
}
.absence-lesson-type {
color: #f59e0b;
font-size: 0.68rem;
font-weight: 800;
text-transform: uppercase;
}
.absence-lesson-copy h3 {
margin: 0.2rem 0;
font-size: 1rem;
}
.absence-lesson-copy p {
margin: 0;
font-size: 0.8rem;
}
.absence-warning {
display: block;
margin-top: 0.45rem;
color: #fb7185;
}
.absence-lesson-decision label {
display: block;
margin-bottom: 0.35rem;
color: var(--text-secondary);
font-size: 0.72rem;
}
.absence-decision-fixed {
display: inline-block;
margin-top: 0.45rem;
color: #34d399;
font-size: 0.75rem;
font-weight: 700;
}
.absence-journal {
padding: 0 1.5rem 1.25rem;
}
.absence-journal h3 {
font-size: 0.95rem;
}
.absence-journal-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.5rem;
max-height: 150px;
overflow-y: auto;
}
.absence-journal-list article {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: center;
padding: 0.7rem;
border: 1px solid var(--bg-card-border);
border-radius: 10px;
}
.absence-journal-list article div {
display: flex;
flex-direction: column;
}
.absence-journal-list small,
.absence-journal-list article > span:last-child {
color: var(--text-secondary);
font-size: 0.7rem;
}
.absence-journal-mark {
width: 9px;
height: 9px;
border-radius: 50%;
}
.absence-journal-mark.applied { background: #34d399; box-shadow: 0 0 12px rgba(52, 211, 153, 0.6); }
.absence-journal-mark.rejected { background: #94a3b8; }
.absence-wizard-footer {
margin-top: auto;
border-top: 1px solid var(--bg-card-border);
}
.absence-wizard-footer > div {
display: flex;
gap: 0.65rem;
}
@media (max-width: 1100px) {
.absence-command-card,
.absence-form-grid {
grid-template-columns: 1fr 1fr;
}
.absence-command-stats,
.absence-reason-field {
grid-column: 1 / -1;
}
.absence-create-submit {
justify-self: start;
}
.absence-registry-item {
grid-template-columns: 68px minmax(0, 1fr);
}
.absence-registry-buttons {
grid-column: 2;
justify-content: flex-start;
max-width: none;
}
}
@media (max-width: 760px) {
.absence-command-card,
.absence-form-grid,
.absence-registry-item,
.absence-lesson {
grid-template-columns: 1fr;
}
.absence-command-stats {
grid-template-columns: repeat(3, 1fr);
}
.absence-registry-head,
.absence-registry-actions,
.absence-wizard-header,
.absence-wizard-footer {
align-items: stretch;
flex-direction: column;
}
.absence-date-rail {
grid-template-columns: auto auto 1fr auto auto;
min-height: auto;
padding: 0 0 0.75rem;
border-right: 0;
border-bottom: 1px solid var(--bg-card-border);
}
.absence-date-rail i {
grid-column: auto;
width: auto;
margin: 0 0.5rem;
}
.absence-registry-buttons {
grid-column: auto;
}
.absence-lesson-time {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 0.4rem;
padding: 0 0 0.65rem;
border-right: 0;
border-bottom: 1px solid var(--bg-card-border);
}
.absence-wizard-overlay {
padding: 0;
}
.absence-wizard {
width: 100%;
max-height: 100vh;
min-height: 100vh;
border-radius: 0;
}
}

View File

@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
<link rel="stylesheet" href="css/teacher-absences.css">
</head>
<body>
@@ -112,6 +113,16 @@
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="teacher-absences">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
<path d="M18 8l3 3" />
<path d="M21 8l-3 3" />
</svg>
<span>Отсутствия и замены</span>
</a>
<!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div>

View File

@@ -117,6 +117,14 @@ const ROUTES = {
await module.initAuditoriumWorkload();
}
},
'teacher-absences': {
title: 'Отсутствия и мастер замены',
file: '/admin/views/teacher-absences.html',
init: async () => {
const module = await import('./views/teacher-absences.js');
await module.initTeacherAbsences();
}
},
};
let currentTab = null;

View File

@@ -1,19 +1,19 @@
const capabilities = {
ADMIN: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
defaultSettingsTab: 'general',
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
},
EDUCATION_OFFICE: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'],
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'],
defaultSettingsTab: 'time-slots',
settingsTabs: ['time-slots', 'edu-forms']
},
DEPARTMENT: {
defaultAdminTab: 'department-workspace',
adminTabs: ['department-workspace', 'schedule-view'],
adminTabs: ['department-workspace', 'schedule-view', 'teacher-absences'],
defaultSettingsTab: null,
settingsTabs: []
},

View File

@@ -0,0 +1,367 @@
import { api, getSession } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
const MANAGER_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT']);
export async function initTeacherAbsences() {
const session = getSession();
const role = session?.role;
if (!MANAGER_ROLES.has(role)) return;
const form = document.getElementById('teacher-absence-form');
const teacherSelect = document.getElementById('teacher-absence-teacher');
const startInput = document.getElementById('teacher-absence-start');
const endInput = document.getElementById('teacher-absence-end');
const reasonInput = document.getElementById('teacher-absence-reason');
const statusFilter = document.getElementById('teacher-absence-status-filter');
const registry = document.getElementById('teacher-absence-registry');
const overlay = document.getElementById('absence-wizard-overlay');
const wizardLessons = document.getElementById('absence-wizard-lessons');
let absences = [];
let currentWizard = null;
setDefaultPeriod(startInput, endInput);
form?.addEventListener('submit', createAbsence);
statusFilter?.addEventListener('change', loadAbsences);
document.getElementById('teacher-absence-refresh')?.addEventListener('click', loadAbsences);
document.getElementById('absence-wizard-close')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-cancel')?.addEventListener('click', closeWizard);
document.getElementById('absence-wizard-apply')?.addEventListener('click', applyWizard);
overlay?.addEventListener('click', event => {
if (event.target === overlay) closeWizard();
});
registry?.addEventListener('click', handleRegistryAction);
await Promise.all([loadTeachers(), loadAbsences()]);
async function loadTeachers() {
try {
let teachers = await api.get('/api/users/teachers');
if (role === 'DEPARTMENT' && session?.departmentId) {
teachers = teachers.filter(item => String(item.departmentId) === String(session.departmentId));
}
teacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' + teachers.map(item => `
<option value="${escapeHtml(item.id)}">${escapeHtml(item.fullName || item.username || `ID ${item.id}`)}</option>
`).join('');
} catch (error) {
teacherSelect.innerHTML = `<option value="">${escapeHtml(error.message || 'Не удалось загрузить преподавателей')}</option>`;
}
}
async function loadAbsences() {
registry.innerHTML = '<div class="loading-row">Загрузка...</div>';
try {
const params = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
absences = await api.get(`/api/teacher-absences${params}`);
renderRegistry();
updateStats(absences);
} catch (error) {
registry.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Не удалось загрузить отсутствия')}</div>`;
}
}
async function createAbsence(event) {
event.preventDefault();
hideAlert('teacher-absence-form-alert');
if (!teacherSelect.value || !startInput.value || !endInput.value || !reasonInput.value.trim()) {
showAlert('teacher-absence-form-alert', 'Заполните преподавателя, период и причину', 'error');
return;
}
if (endInput.value < startInput.value) {
showAlert('teacher-absence-form-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
try {
await api.post('/api/teacher-absences', {
teacherId: Number(teacherSelect.value),
startDate: startInput.value,
endDate: endInput.value,
reason: reasonInput.value.trim()
});
showAlert('teacher-absence-form-alert', 'Отсутствие зарегистрировано', 'success');
reasonInput.value = '';
await loadAbsences();
} catch (error) {
showAlert('teacher-absence-form-alert', error.message || 'Не удалось зарегистрировать отсутствие', 'error');
}
}
function renderRegistry() {
if (!absences.length) {
registry.innerHTML = '<div class="absence-empty"><strong>Инцидентов не найдено</strong><span>Измените фильтр или зарегистрируйте отсутствие.</span></div>';
return;
}
registry.innerHTML = absences.map(item => `
<article class="absence-registry-item" data-status="${escapeHtml(item.status)}">
<div class="absence-date-rail">
<strong>${escapeHtml(dayOfMonth(item.startDate))}</strong>
<span>${escapeHtml(monthShort(item.startDate))}</span>
<i></i>
<strong>${escapeHtml(dayOfMonth(item.endDate))}</strong>
<span>${escapeHtml(monthShort(item.endDate))}</span>
</div>
<div class="absence-registry-main">
<div class="absence-registry-title">
<div>
<h3>${escapeHtml(item.teacherName || 'Преподаватель не указан')}</h3>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="absence-status ${statusClass(item.status)}">${escapeHtml(statusLabel(item.status))}</span>
</div>
<div class="absence-meta-row">
<span>${escapeHtml(formatDate(item.startDate))} — ${escapeHtml(formatDate(item.endDate))}</span>
<span>${escapeHtml(decisionCountLabel(item.decisionCount || 0))}</span>
${item.reviewComment ? `<span>${escapeHtml(item.reviewComment)}</span>` : ''}
</div>
</div>
<div class="absence-registry-buttons">
${item.status === 'PENDING' ? `
<button type="button" class="btn btn-sm btn-primary" data-action="approve" data-id="${item.id}">Подтвердить</button>
<button type="button" class="btn btn-sm btn-danger-subtle" data-action="reject" data-id="${item.id}">Отклонить</button>
` : ''}
${['APPROVED', 'RESOLVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-secondary" data-action="wizard" data-id="${item.id}">
${item.status === 'APPROVED' ? 'Открыть мастер' : 'Открыть журнал'}
</button>
` : ''}
${['PENDING', 'APPROVED'].includes(item.status) ? `
<button type="button" class="btn btn-sm btn-ghost" data-action="cancel" data-id="${item.id}">Отменить</button>
` : ''}
</div>
</article>
`).join('');
}
async function handleRegistryAction(event) {
const button = event.target.closest('[data-action][data-id]');
if (!button) return;
const id = Number(button.dataset.id);
button.disabled = true;
try {
if (button.dataset.action === 'approve' || button.dataset.action === 'reject') {
await api.post(`/api/teacher-absences/${id}/review`, {
approved: button.dataset.action === 'approve',
comment: button.dataset.action === 'approve'
? 'Подтверждено кафедрой или ответственным сотрудником'
: 'Заявка отклонена ответственным сотрудником'
});
await loadAbsences();
} else if (button.dataset.action === 'cancel') {
await api.delete(`/api/teacher-absences/${id}`);
await loadAbsences();
} else if (button.dataset.action === 'wizard') {
await openWizard(id);
}
} catch (error) {
window.alert(error.message || 'Не удалось выполнить действие');
} finally {
button.disabled = false;
}
}
async function openWizard(id) {
overlay.hidden = false;
document.body.classList.add('absence-wizard-open');
wizardLessons.innerHTML = '<div class="loading-row">Подбираем свободные ресурсы...</div>';
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.get(`/api/teacher-absences/${id}/wizard`);
renderWizard();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось открыть мастер замены', 'error');
}
}
function closeWizard() {
overlay.hidden = true;
document.body.classList.remove('absence-wizard-open');
currentWizard = null;
}
function renderWizard() {
const absence = currentWizard.absence;
document.getElementById('absence-wizard-title').textContent = absence.teacherName || 'Мастер замены';
document.getElementById('absence-wizard-summary').textContent =
`${formatDate(absence.startDate)} — ${formatDate(absence.endDate)} · ${absence.reason}`;
const applyButton = document.getElementById('absence-wizard-apply');
applyButton.hidden = absence.status !== 'APPROVED';
wizardLessons.innerHTML = currentWizard.lessons.length
? currentWizard.lessons.map((lesson, index) => renderLessonDecision(lesson, index, absence.status)).join('')
: '<div class="absence-empty"><strong>Затронутых занятий нет</strong><span>Инцидент можно завершить без точечных изменений.</span></div>';
renderJournal(currentWizard.journal || []);
}
function renderLessonDecision(lesson, index, absenceStatus) {
const options = decisionOptions(lesson);
const locked = absenceStatus !== 'APPROVED' || Boolean(lesson.decisionStatus);
return `
<article class="absence-lesson" data-index="${index}" data-slot-id="${lesson.baseRuleSlotId}" data-date="${lesson.lessonDate}">
<div class="absence-lesson-time">
<strong>${escapeHtml(trimTime(lesson.startTime))}</strong>
<span>${escapeHtml(trimTime(lesson.endTime))}</span>
<small>${escapeHtml(formatDate(lesson.lessonDate))}</small>
</div>
<div class="absence-lesson-copy">
<span class="absence-lesson-type">${escapeHtml(lesson.lessonTypeName || 'Занятие')}</span>
<h3>${escapeHtml(lesson.subjectName || 'Дисциплина не указана')}</h3>
<p>${escapeHtml((lesson.groupNames || []).join(', ') || 'Группы не указаны')} · ${escapeHtml(lesson.classroomName || 'Без аудитории')}</p>
${lesson.existingOverrideId ? '<small class="absence-warning">У пары уже есть точечное изменение — доступна только запись отклонения.</small>' : ''}
</div>
<div class="absence-lesson-decision">
<label for="absence-decision-${index}">Решение</label>
<select id="absence-decision-${index}" class="absence-decision-select" ${locked ? 'disabled' : ''}>
<option value="">Оставить без изменений</option>
${options.map(option => `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`).join('')}
</select>
${lesson.decisionStatus ? `<span class="absence-decision-fixed">${escapeHtml(decisionStatusLabel(lesson.decisionStatus))}</span>` : ''}
</div>
</article>
`;
}
async function applyWizard() {
if (!currentWizard) return;
const decisions = Array.from(wizardLessons.querySelectorAll('.absence-lesson')).map(card => {
const value = card.querySelector('.absence-decision-select')?.value;
if (!value) return null;
return {
baseRuleSlotId: Number(card.dataset.slotId),
lessonDate: card.dataset.date,
...parseDecisionOption(value)
};
}).filter(Boolean);
if (!decisions.length && currentWizard.lessons.length) {
showAlert('absence-wizard-alert', 'Выберите хотя бы одно решение', 'error');
return;
}
const button = document.getElementById('absence-wizard-apply');
button.disabled = true;
hideAlert('absence-wizard-alert');
try {
currentWizard = await api.post(`/api/teacher-absences/${currentWizard.absence.id}/resolve`, { decisions });
renderWizard();
showAlert('absence-wizard-alert', 'Выбранные решения применены к расписанию', 'success');
await loadAbsences();
} catch (error) {
showAlert('absence-wizard-alert', error.message || 'Не удалось применить решения', 'error');
} finally {
button.disabled = false;
}
}
}
export function decisionOptions(lesson) {
const options = [];
(lesson.teacherCandidates || []).forEach(candidate => options.push({
value: `REPLACE_TEACHER|${candidate.id}`,
label: `Заменить: ${candidate.name}`
}));
(lesson.moveCandidates || []).forEach(candidate => options.push({
value: `MOVE_TIME|${candidate.targetDate}|${candidate.timeSlotId}`,
label: `Перенести: ${formatDate(candidate.targetDate)}, ${trimTime(candidate.startTime)}–${trimTime(candidate.endTime)}`
}));
(lesson.classroomCandidates || []).forEach(candidate => options.push({
value: `CHANGE_CLASSROOM|${candidate.id}`,
label: `Другая аудитория: ${candidate.name}`
}));
if (lesson.cancellationAvailable) {
options.push({ value: 'CANCEL', label: 'Отменить занятие' });
}
options.push({ value: 'REJECT', label: 'Отклонить предложения' });
return options;
}
export function parseDecisionOption(value) {
const [resolution, first, second] = String(value || '').split('|');
if (resolution === 'REPLACE_TEACHER') return { resolution, newTeacherId: Number(first) };
if (resolution === 'MOVE_TIME') {
return { resolution, targetLessonDate: first, newTimeSlotId: Number(second) };
}
if (resolution === 'CHANGE_CLASSROOM') return { resolution, newClassroomId: Number(first) };
if (resolution === 'CANCEL' || resolution === 'REJECT') return { resolution };
throw new Error('Неизвестное решение мастера замены');
}
function renderJournal(journal) {
document.getElementById('absence-journal-count').textContent = decisionCountLabel(journal.length);
document.getElementById('absence-journal-list').innerHTML = journal.length ? journal.map(item => `
<article>
<span class="absence-journal-mark ${item.status === 'APPLIED' ? 'applied' : 'rejected'}"></span>
<div><strong>${escapeHtml(resolutionLabel(item.resolution))}</strong><small>${escapeHtml(formatDate(item.lessonDate))}</small></div>
<span>${escapeHtml(decisionStatusLabel(item.status))}${item.scheduleOverrideId ? ` · override №${escapeHtml(item.scheduleOverrideId)}` : ''}</span>
</article>
`).join('') : '<div class="loading-row">Решений пока нет</div>';
}
function updateStats(items) {
document.getElementById('absence-stat-active').textContent = items.filter(item => item.status === 'APPROVED').length;
document.getElementById('absence-stat-pending').textContent = items.filter(item => item.status === 'PENDING').length;
document.getElementById('absence-stat-resolved').textContent = items.filter(item => item.status === 'RESOLVED').length;
}
function setDefaultPeriod(startInput, endInput) {
const today = new Date();
startInput.value = isoDate(today);
endInput.value = isoDate(today);
}
function isoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function dayOfMonth(value) {
return String(value || '').split('-')[2] || '—';
}
function monthShort(value) {
const month = Number(String(value || '').split('-')[1]);
return ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'][month - 1] || '';
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function statusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Требует решения',
REJECTED: 'Отклонено',
RESOLVED: 'Разрешено',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function statusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function resolutionLabel(resolution) {
return {
REPLACE_TEACHER: 'Замена преподавателя',
MOVE_TIME: 'Перенос занятия',
CHANGE_CLASSROOM: 'Замена аудитории',
CANCEL: 'Отмена занятия',
REJECT: 'Предложение отклонено'
}[resolution] || resolution || 'Решение';
}
function decisionStatusLabel(status) {
return status === 'APPLIED' ? 'Применено' : status === 'REJECTED' ? 'Отклонено' : status || '—';
}
function decisionCountLabel(count) {
const value = Number(count) || 0;
const tail = value % 100;
if (tail >= 11 && tail <= 14) return `${value} решений`;
if (value % 10 === 1) return `${value} решение`;
if ([2, 3, 4].includes(value % 10)) return `${value} решения`;
return `${value} решений`;
}

View File

@@ -0,0 +1,102 @@
<section class="absence-command-card">
<div class="absence-command-copy">
<span class="absence-kicker">Оперативный контур расписания</span>
<h2>От регистрации инцидента до готовой замены</h2>
<p>Зафиксируйте период отсутствия, проверьте затронутые пары и примените только выбранные решения.</p>
</div>
<div class="absence-command-stats" aria-label="Сводка отсутствий">
<article><strong id="absence-stat-active">0</strong><span>требуют внимания</span></article>
<article><strong id="absence-stat-pending">0</strong><span>ждут подтверждения</span></article>
<article><strong id="absence-stat-resolved">0</strong><span>разрешены</span></article>
</div>
</section>
<section class="card absence-create-card">
<div class="card-header-row">
<div>
<span class="absence-section-number">01</span>
<h2>Зарегистрировать отсутствие</h2>
</div>
<span class="muted">Ответственный сотрудник создаёт согласованную запись сразу</span>
</div>
<form id="teacher-absence-form" novalidate>
<div class="absence-form-grid">
<div class="form-group absence-teacher-field">
<label for="teacher-absence-teacher">Преподаватель</label>
<select id="teacher-absence-teacher" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="teacher-absence-start">Начало</label>
<input type="date" id="teacher-absence-start" required>
</div>
<div class="form-group">
<label for="teacher-absence-end">Окончание</label>
<input type="date" id="teacher-absence-end" required>
</div>
<div class="form-group absence-reason-field">
<label for="teacher-absence-reason">Причина</label>
<input type="text" id="teacher-absence-reason" maxlength="2000"
placeholder="Болезнь, командировка или другая причина" required>
</div>
<button type="submit" class="btn btn-md btn-primary absence-create-submit">Зарегистрировать</button>
</div>
<div class="form-alert" id="teacher-absence-form-alert" role="alert"></div>
</form>
</section>
<section class="card absence-registry-card">
<div class="card-header-row absence-registry-head">
<div>
<span class="absence-section-number">02</span>
<h2>Реестр инцидентов</h2>
</div>
<div class="absence-registry-actions">
<select id="teacher-absence-status-filter" aria-label="Фильтр по статусу">
<option value="">Все статусы</option>
<option value="PENDING">На подтверждении</option>
<option value="APPROVED">Согласованы</option>
<option value="RESOLVED">Разрешены</option>
<option value="REJECTED">Отклонены</option>
<option value="CANCELLED">Отменены</option>
</select>
<button type="button" class="btn btn-md btn-secondary" id="teacher-absence-refresh">Обновить</button>
</div>
</div>
<div class="absence-registry" id="teacher-absence-registry" aria-live="polite">
<div class="loading-row">Загрузка...</div>
</div>
</section>
<div class="absence-wizard-overlay" id="absence-wizard-overlay" hidden>
<section class="absence-wizard" role="dialog" aria-modal="true" aria-labelledby="absence-wizard-title">
<header class="absence-wizard-header">
<div>
<span class="absence-kicker">Мастер замены</span>
<h2 id="absence-wizard-title">Затронутые занятия</h2>
<p id="absence-wizard-summary">Загрузка...</p>
</div>
<button type="button" class="btn btn-icon-md btn-ghost" id="absence-wizard-close" aria-label="Закрыть">×</button>
</header>
<div class="absence-wizard-progress" aria-hidden="true">
<span class="done">Инцидент</span><i></i><span class="done">Занятия</span><i></i><span>Решения</span>
</div>
<div class="form-alert" id="absence-wizard-alert" role="alert"></div>
<div class="absence-wizard-lessons" id="absence-wizard-lessons"></div>
<section class="absence-journal">
<div class="card-header-row">
<h3>Журнал решений</h3>
<span class="muted" id="absence-journal-count">0 записей</span>
</div>
<div id="absence-journal-list" class="absence-journal-list"></div>
</section>
<footer class="absence-wizard-footer">
<span class="muted">Пустые строки останутся без изменений</span>
<div>
<button type="button" class="btn btn-md btn-ghost" id="absence-wizard-cancel">Закрыть</button>
<button type="button" class="btn btn-md btn-primary" id="absence-wizard-apply">Применить выбранные</button>
</div>
</footer>
</section>
</div>

View File

@@ -9,7 +9,7 @@
"build": "npm run build:vendor",
"build:vendor": "node scripts/build-vendor.mjs",
"test": "node --test tests/*.test.mjs",
"check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs"
"check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/views/teacher-absences.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs"
},
"devDependencies": {
"@opentelemetry/api": "1.9.0",

View File

@@ -16,6 +16,12 @@ import {
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
const absenceForm = document.getElementById('teacher-absence-form');
const absenceList = document.getElementById('teacher-absence-list');
const absenceStatus = document.getElementById('teacher-absence-status');
const absenceStart = document.getElementById('teacher-absence-start');
const absenceEnd = document.getElementById('teacher-absence-end');
const absenceReason = document.getElementById('teacher-absence-reason');
let weekStart = startOfWeek(new Date());
document.getElementById('logout-link').addEventListener('click', async (event) => {
@@ -44,7 +50,92 @@ import {
}
});
document.getElementById('absence-form-toggle').addEventListener('click', event => {
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
event.currentTarget.textContent = expanded ? 'Новая заявка' : 'Скрыть форму';
absenceForm.hidden = expanded;
});
absenceForm.addEventListener('submit', submitAbsence);
absenceList.addEventListener('click', cancelAbsence);
const todayIso = toIsoDate(new Date());
absenceStart.value = todayIso;
absenceEnd.value = todayIso;
loadSchedule();
loadAbsences();
async function loadAbsences() {
absenceList.innerHTML = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
try {
const items = await fetchJson('/api/teacher-absences');
absenceList.innerHTML = items.length ? items.map(item => `
<article class="teacher-absence-item">
<span class="teacher-absence-marker ${absenceStatusClass(item.status)}"></span>
<div>
<strong>${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))}</strong>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="teacher-absence-badge ${absenceStatusClass(item.status)}">${escapeHtml(absenceStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `<button class="btn btn-sm btn-danger-subtle" type="button" data-cancel-absence="${item.id}">Отменить</button>` : ''}
</article>
`).join('') : '<div class="teacher-absence-empty">Заявок об отсутствии пока нет</div>';
} catch (error) {
absenceList.innerHTML = `<div class="teacher-absence-empty error">${escapeHtml(error.message)}</div>`;
}
}
async function submitAbsence(event) {
event.preventDefault();
showAbsenceStatus('', false);
if (!absenceStart.value || !absenceEnd.value || !absenceReason.value.trim()) {
showAbsenceStatus('Заполните период и причину отсутствия', true);
return;
}
if (absenceEnd.value < absenceStart.value) {
showAbsenceStatus('Дата окончания не может быть раньше даты начала', true);
return;
}
try {
await fetchJson('/api/teacher-absences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate: absenceStart.value,
endDate: absenceEnd.value,
reason: absenceReason.value.trim()
})
});
absenceReason.value = '';
showAbsenceStatus('Заявка отправлена кафедре', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
}
}
async function cancelAbsence(event) {
const button = event.target.closest('[data-cancel-absence]');
if (!button) return;
button.disabled = true;
try {
await fetchJson(`/api/teacher-absences/${encodeURIComponent(button.dataset.cancelAbsence)}`, {
method: 'DELETE'
});
showAbsenceStatus('Заявка отменена', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
button.disabled = false;
}
}
function showAbsenceStatus(message, isError) {
absenceStatus.textContent = message || '';
absenceStatus.classList.toggle('error', Boolean(isError));
absenceStatus.classList.toggle('success', Boolean(message) && !isError);
}
async function loadSchedule() {
const startDate = toIsoDate(weekStart);
@@ -185,8 +276,8 @@ import {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetchWithAuth(url);
async function fetchJson(url, options = {}) {
const response = await fetchWithAuth(url, options);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
@@ -225,6 +316,25 @@ import {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatIsoDate(value) {
const [year, month, day] = String(value || '').split('-');
return year && month && day ? `${day}.${month}.${year}` : '—';
}
function absenceStatusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
RESOLVED: 'Замены выполнены',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function absenceStatusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);

View File

@@ -41,6 +41,35 @@
</section>
<div class="status" id="status-line"></div>
<section class="teacher-absence-panel">
<div class="teacher-absence-heading">
<div>
<span>Изменение планов</span>
<h2>Сообщить об отсутствии</h2>
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
</div>
<button class="btn btn-md btn-secondary" id="absence-form-toggle" type="button" aria-expanded="false">
Новая заявка
</button>
</div>
<form class="teacher-absence-form" id="teacher-absence-form" hidden>
<label>
<span>Начало</span>
<input class="date-input" id="teacher-absence-start" type="date" required>
</label>
<label>
<span>Окончание</span>
<input class="date-input" id="teacher-absence-end" type="date" required>
</label>
<label class="teacher-absence-reason">
<span>Причина</span>
<input id="teacher-absence-reason" type="text" maxlength="2000" placeholder="Например, командировка" required>
</label>
<button class="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
</form>
<div class="teacher-absence-status" id="teacher-absence-status"></div>
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
</section>
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>

View File

@@ -2,6 +2,178 @@
box-sizing: border-box;
}
/* Реестр собственных отсутствий преподавателя */
.teacher-absence-panel {
margin-bottom: 1.25rem;
padding: 1.25rem;
border: 1px solid color-mix(in srgb, #f59e0b 28%, var(--border));
border-radius: 18px;
background: linear-gradient(125deg, color-mix(in srgb, #f59e0b 9%, transparent), transparent 52%), var(--card);
}
.teacher-absence-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.teacher-absence-heading span {
color: #d97706;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.teacher-absence-heading h2 {
margin: 0.25rem 0;
font-size: 1.2rem;
}
.teacher-absence-heading p {
max-width: 720px;
margin: 0;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
.teacher-absence-form {
display: grid;
grid-template-columns: 170px 170px minmax(260px, 1fr) auto;
gap: 0.75rem;
align-items: end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.teacher-absence-form[hidden] {
display: none;
}
.teacher-absence-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
}
.teacher-absence-form input {
min-height: 42px;
padding: 0 0.75rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
color: var(--text);
font: inherit;
}
.teacher-absence-status {
min-height: 1.2rem;
margin-top: 0.7rem;
font-size: 0.8rem;
}
.teacher-absence-status.error { color: #ef4444; }
.teacher-absence-status.success { color: #10b981; }
.teacher-absence-list {
display: grid;
gap: 0.55rem;
margin-top: 0.35rem;
}
.teacher-absence-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 0.7rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--card) 82%, transparent);
}
.teacher-absence-item p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.teacher-absence-marker {
width: 8px;
height: 38px;
border-radius: 999px;
background: #94a3b8;
}
.teacher-absence-marker.is-pending { background: #f59e0b; }
.teacher-absence-marker.is-approved { background: #fb7185; }
.teacher-absence-marker.is-resolved { background: #10b981; }
.teacher-absence-badge {
padding: 0.3rem 0.6rem;
border: 1px solid currentColor;
border-radius: 999px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.teacher-absence-badge.is-pending { color: #d97706; }
.teacher-absence-badge.is-approved { color: #e11d48; }
.teacher-absence-badge.is-resolved { color: #059669; }
.teacher-absence-empty {
padding: 0.75rem;
color: var(--muted);
text-align: center;
font-size: 0.82rem;
}
.teacher-absence-empty.error { color: #ef4444; }
@media (max-width: 900px) {
.teacher-absence-form {
grid-template-columns: 1fr 1fr;
}
.teacher-absence-reason {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) {
.teacher-absence-heading,
.teacher-absence-form {
grid-template-columns: 1fr;
}
.teacher-absence-heading {
align-items: stretch;
flex-direction: column;
}
.teacher-absence-reason {
grid-column: auto;
}
.teacher-absence-item {
grid-template-columns: auto minmax(0, 1fr);
}
.teacher-absence-badge,
.teacher-absence-item button {
grid-column: 2;
justify-self: start;
}
}
:root {
--bg: #f5f6f8;
--panel: #ffffff;

View File

@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
decisionOptions,
parseDecisionOption
} from '../admin/js/views/teacher-absences.js';
test('значение мастера преобразуется в payload точечного изменения', () => {
assert.deepEqual(parseDecisionOption('REPLACE_TEACHER|17'), {
resolution: 'REPLACE_TEACHER',
newTeacherId: 17
});
assert.deepEqual(parseDecisionOption('MOVE_TIME|2026-09-21|4'), {
resolution: 'MOVE_TIME',
targetLessonDate: '2026-09-21',
newTimeSlotId: 4
});
assert.deepEqual(parseDecisionOption('CANCEL'), { resolution: 'CANCEL' });
});
test('мастер показывает только реально полученные кандидаты и явное отклонение', () => {
const options = decisionOptions({
teacherCandidates: [{ id: 7, name: 'Петров П. П.' }],
moveCandidates: [{
targetDate: '2026-09-22',
timeSlotId: 3,
startTime: '10:40:00',
endTime: '12:10:00'
}],
classroomCandidates: [{ id: 9, name: 'А-201' }],
cancellationAvailable: true
});
assert.deepEqual(options.map(item => item.value), [
'REPLACE_TEACHER|7',
'MOVE_TIME|2026-09-22|3',
'CHANGE_CLASSROOM|9',
'CANCEL',
'REJECT'
]);
});
test('вкладка отсутствий доступна ответственным ролям и подключена к SPA', async () => {
const [capabilities, main, index] = await Promise.all([
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8')
]);
assert.match(capabilities, /ADMIN:[\s\S]*teacher-absences/);
assert.match(capabilities, /EDUCATION_OFFICE:[\s\S]*teacher-absences/);
assert.match(capabilities, /DEPARTMENT:[\s\S]*teacher-absences/);
assert.match(main, /'teacher-absences':[\s\S]*teacher-absences\.js/);
assert.match(index, /data-tab="teacher-absences"/);
});