задачи 2 и 8

This commit is contained in:
Zuev
2026-08-11 18:18:08 +03:00
parent 519864b962
commit 18a97b293a
59 changed files with 6237 additions and 276 deletions

View File

@@ -60,6 +60,8 @@ final class DatabaseConstraintViolationMapper {
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_schedule_versions_number", "Версия с таким номером уже существует в семестре"),
conflict("uq_schedule_versions_published_semester", "Для семестра уже опубликована другая версия расписания"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),

View File

@@ -0,0 +1,44 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
import com.magistr.app.dto.ScheduleQualityRecommendationsDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQualityService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/edu-office/schedule/quality")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleQualityController {
private final ScheduleQualityService scheduleQualityService;
public ScheduleQualityController(ScheduleQualityService scheduleQualityService) {
this.scheduleQualityService = scheduleQualityService;
}
@GetMapping
public ScheduleQualityAnalysisDto analyze(
@RequestParam Long semesterId,
@RequestParam(name = "versionId", required = false) Long versionId
) {
return scheduleQualityService.analyze(semesterId, versionId);
}
@GetMapping("/recommendations")
public ScheduleQualityRecommendationsDto recommendations(
@RequestParam Long semesterId,
@RequestParam(name = "versionId", required = false) Long versionId,
@RequestParam Long scheduleRuleSlotId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate lessonDate
) {
return scheduleQualityService.recommendations(semesterId, versionId, scheduleRuleSlotId, lessonDate);
}
}

View File

@@ -34,8 +34,9 @@ public class ScheduleRuleAdminController {
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
@RequestParam(name = "groupId", required = false) Long groupId) {
return scheduleRuleService.getAll(semesterId, groupId);
@RequestParam(name = "groupId", required = false) Long groupId,
@RequestParam(name = "versionId", required = false) Long versionId) {
return scheduleRuleService.getAll(semesterId, groupId, versionId);
}
@GetMapping("/{id}")
@@ -46,6 +47,7 @@ public class ScheduleRuleAdminController {
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
try {
requireDraftVersion(request);
return ResponseEntity.ok(scheduleRuleService.create(request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
@@ -55,6 +57,7 @@ public class ScheduleRuleAdminController {
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
try {
requireDraftVersion(request);
return ResponseEntity.ok(scheduleRuleService.update(id, request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
@@ -77,4 +80,10 @@ public class ScheduleRuleAdminController {
body.put("conflictReasons", exception.getConflictReasons());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
private void requireDraftVersion(ScheduleRuleDto request) {
if (request == null || request.scheduleVersionId() == null) {
throw new IllegalArgumentException("Выберите черновик расписания");
}
}
}

View File

@@ -0,0 +1,82 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleVersionActionDto;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.dto.ScheduleVersionDiffDto;
import com.magistr.app.dto.ScheduleVersionDto;
import com.magistr.app.dto.ScheduleVersionHistoryDto;
import com.magistr.app.dto.ScheduleVersionValidationDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleVersionService;
import org.springframework.http.ResponseEntity;
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/edu-office/schedule/versions")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleVersionController {
private final ScheduleVersionService service;
public ScheduleVersionController(ScheduleVersionService service) {
this.service = service;
}
@GetMapping
public List<ScheduleVersionDto> getVersions(@RequestParam("semesterId") Long semesterId) {
return service.getVersions(semesterId);
}
@GetMapping("/{id}")
public ScheduleVersionDto getVersion(@PathVariable("id") Long id) {
return service.getVersion(id);
}
@PostMapping
public ResponseEntity<ScheduleVersionDto> createDraft(@RequestBody ScheduleVersionCreateDto request) {
return ResponseEntity.ok(service.createDraft(request));
}
@GetMapping("/{id}/validate")
public ScheduleVersionValidationDto validate(@PathVariable("id") Long id) {
return service.validate(id);
}
@GetMapping("/{id}/diff")
public ScheduleVersionDiffDto diff(
@PathVariable("id") Long id,
@RequestParam(name = "baseVersionId", required = false) Long baseVersionId
) {
return service.diff(id, baseVersionId);
}
@PostMapping("/{id}/publish")
public ScheduleVersionDto publish(
@PathVariable("id") Long id,
@RequestBody ScheduleVersionActionDto request
) {
return service.publish(id, request == null ? null : request.reason());
}
@PostMapping("/{id}/restore")
public ScheduleVersionDto restore(
@PathVariable("id") Long id,
@RequestBody ScheduleVersionActionDto request
) {
return service.restore(id, request == null ? null : request.reason());
}
@GetMapping("/history")
public List<ScheduleVersionHistoryDto> history(@RequestParam("semesterId") Long semesterId) {
return service.getHistory(semesterId);
}
}

View File

@@ -0,0 +1,52 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
import com.magistr.app.model.ScheduleVersionStatus;
public record ScheduleQualityAnalysisDto(
Long semesterId,
String semesterLabel,
LocalDate startDate,
LocalDate endDate,
int score,
int rawPenalty,
int lessonCount,
int totalProblemCount,
boolean problemsTruncated,
List<Metric> metrics,
List<Problem> problems,
Long scheduleVersionId,
Integer scheduleVersionNumber,
String scheduleVersionName,
ScheduleVersionStatus scheduleVersionStatus
) {
public record Metric(
String code,
String label,
double value,
String unit,
int penalty,
String explanation
) {
}
public record Problem(
String id,
String type,
String severity,
String title,
String description,
int penalty,
Long scheduleRuleSlotId,
LocalDate lessonDate,
Integer timeSlotOrder,
String subjectName,
Long entityId,
String entityName,
Integer requiredCapacity,
Integer classroomCapacity,
boolean optimizable
) {
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record ScheduleQualityRecommendationsDto(
Long scheduleRuleSlotId,
LocalDate lessonDate,
String subjectName,
int baselineScore,
List<Candidate> candidates
) {
public record Candidate(
String id,
String action,
String label,
String description,
int scoreDelta,
int penaltyDelta,
int resultingScore,
List<String> improvements,
List<String> tradeoffs,
ScheduleOverrideDto override
) {
}
}

View File

@@ -19,6 +19,7 @@ public record ScheduleRuleDto(
Integer practiceStartWeek,
List<Long> groupIds,
List<String> groupNames,
List<ScheduleRuleSlotDto> slots
List<ScheduleRuleSlotDto> slots,
Long scheduleVersionId
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record ScheduleVersionActionDto(String reason) {
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.dto;
public record ScheduleVersionCreateDto(
Long semesterId,
String name,
Long basedOnVersionId
) {
}

View File

@@ -0,0 +1,30 @@
package com.magistr.app.dto;
import java.util.List;
public record ScheduleVersionDiffDto(
Long baseVersionId,
String baseVersionLabel,
Long targetVersionId,
String targetVersionLabel,
int addedRuleCount,
int removedRuleCount,
int changedRuleCount,
int unchangedRuleCount,
int addedLessonCount,
int removedLessonCount,
int changedLessonCount,
List<RuleChange> ruleChanges
) {
public record RuleChange(
String type,
Long versionGroupId,
Long beforeRuleId,
Long afterRuleId,
String subjectName,
List<String> groupNames,
String beforeSummary,
String afterSummary
) {
}
}

View File

@@ -0,0 +1,27 @@
package com.magistr.app.dto;
import com.magistr.app.model.ScheduleVersionStatus;
import java.time.Instant;
public record ScheduleVersionDto(
Long id,
Long semesterId,
String semesterLabel,
Integer versionNumber,
String name,
ScheduleVersionStatus status,
Long basedOnVersionId,
Long restoredFromVersionId,
String changeReason,
Long createdById,
String createdByName,
Instant createdAt,
Long publishedById,
String publishedByName,
Instant publishedAt,
Instant archivedAt,
long ruleCount,
long slotCount
) {
}

View File

@@ -0,0 +1,16 @@
package com.magistr.app.dto;
import java.time.Instant;
public record ScheduleVersionHistoryDto(
Long id,
Long versionId,
Integer versionNumber,
String versionName,
String action,
Long actorId,
String actorName,
String reason,
Instant createdAt
) {
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
import java.util.List;
public record ScheduleVersionValidationDto(
Long versionId,
boolean valid,
long ruleCount,
long slotCount,
List<String> issues
) {
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
@@ -23,6 +24,23 @@ public class ScheduleRule {
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@ManyToOne(optional = false)
@JoinColumn(name = "schedule_version_id", nullable = false)
private ScheduleVersion scheduleVersion;
@Column(name = "version_group_id")
private Long versionGroupId;
@Column(name = "change_reason")
private String changeReason;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false, length = 20)
private String status = LifecycleEntity.STATUS_ACTIVE;
@@ -85,6 +103,42 @@ public class ScheduleRule {
this.semester = semester;
}
public ScheduleVersion getScheduleVersion() {
return scheduleVersion;
}
public void setScheduleVersion(ScheduleVersion scheduleVersion) {
this.scheduleVersion = scheduleVersion;
}
public Long getVersionGroupId() {
return versionGroupId;
}
public void setVersionGroupId(Long versionGroupId) {
this.versionGroupId = versionGroupId;
}
public String getChangeReason() {
return changeReason;
}
public void setChangeReason(String changeReason) {
this.changeReason = changeReason;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public Instant getCreatedAt() {
return createdAt;
}
public String getStatus() {
return status;
}

View File

@@ -0,0 +1,166 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "schedule_versions")
public class ScheduleVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(name = "version_number", nullable = false)
private Integer versionNumber;
@Column(nullable = false, length = 160)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ScheduleVersionStatus status = ScheduleVersionStatus.DRAFT;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "based_on_version_id")
private ScheduleVersion basedOnVersion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "restored_from_version_id")
private ScheduleVersion restoredFromVersion;
@Column(name = "change_reason")
private String changeReason;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "published_by")
private User publishedBy;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "archived_at")
private Instant archivedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Semester getSemester() {
return semester;
}
public void setSemester(Semester semester) {
this.semester = semester;
}
public Integer getVersionNumber() {
return versionNumber;
}
public void setVersionNumber(Integer versionNumber) {
this.versionNumber = versionNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ScheduleVersionStatus getStatus() {
return status;
}
public void setStatus(ScheduleVersionStatus status) {
this.status = status;
}
public ScheduleVersion getBasedOnVersion() {
return basedOnVersion;
}
public void setBasedOnVersion(ScheduleVersion basedOnVersion) {
this.basedOnVersion = basedOnVersion;
}
public ScheduleVersion getRestoredFromVersion() {
return restoredFromVersion;
}
public void setRestoredFromVersion(ScheduleVersion restoredFromVersion) {
this.restoredFromVersion = restoredFromVersion;
}
public String getChangeReason() {
return changeReason;
}
public void setChangeReason(String changeReason) {
this.changeReason = changeReason;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public Instant getCreatedAt() {
return createdAt;
}
public User getPublishedBy() {
return publishedBy;
}
public void setPublishedBy(User publishedBy) {
this.publishedBy = publishedBy;
}
public Instant getPublishedAt() {
return publishedAt;
}
public void setPublishedAt(Instant publishedAt) {
this.publishedAt = publishedAt;
}
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}
}

View File

@@ -0,0 +1,79 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "schedule_version_history")
public class ScheduleVersionHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "version_id", nullable = false)
private ScheduleVersion version;
@Column(nullable = false, length = 30)
private String action;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "actor_id")
private User actor;
@Column
private String reason;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
public Long getId() {
return id;
}
public ScheduleVersion getVersion() {
return version;
}
public void setVersion(ScheduleVersion version) {
this.version = version;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public User getActor() {
return actor;
}
public void setActor(User actor) {
this.actor = actor;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum ScheduleVersionStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}

View File

@@ -19,12 +19,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
join rule.scheduleVersion version
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
where (o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate)
and version.status = 'PUBLISHED'
""")
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate,
@@ -38,6 +40,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.scheduleVersion version
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
@@ -48,6 +51,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where version.status = 'PUBLISHED'
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findAllWithRegistryDetails();
@@ -57,6 +61,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.scheduleVersion version
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
@@ -67,8 +72,9 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
where (o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate)
and version.status = 'PUBLISHED'
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findForRegistry(
@@ -81,12 +87,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
join rule.scheduleVersion version
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate in :dates
or o.targetLessonDate in :dates
where (o.lessonDate in :dates
or o.targetLessonDate in :dates)
and version.status = 'PUBLISHED'
""")
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);

View File

@@ -17,9 +17,11 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.groups g
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.scheduleVersion
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
@@ -30,6 +32,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where g.id = :groupId
and sem.id = :semesterId
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdAndSemesterId(
@@ -41,6 +44,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.slots teacherSlot
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -54,6 +58,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id = :semesterId
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterId(
@@ -65,6 +70,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.groups matchingGroup
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -78,6 +84,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where matchingGroup.id in :groupIds
and sem.id in :semesterIds
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdsAndSemesterIds(
@@ -89,6 +96,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.slots teacherSlot
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -102,6 +110,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id in :semesterIds
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterIds(
@@ -112,6 +121,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Query("""
select distinct r
from ScheduleRule r
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -124,6 +134,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.classroom
left join fetch slots.lessonType
where r.status <> 'ARCHIVED'
and version.status = 'PUBLISHED'
order by r.id desc
""")
List<ScheduleRule> findAllWithDetails();
@@ -156,12 +167,38 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Param("excludeRuleId") Long excludeRuleId
);
@Query("""
select distinct rule
from ScheduleRule rule
left join fetch rule.subject
left join fetch rule.semester semester
left join fetch semester.academicYear
left join fetch rule.scheduleVersion version
left join fetch rule.groups groups
left join fetch rule.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where version.id = :versionId
and rule.status <> 'ARCHIVED'
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
order by rule.id
""")
List<ScheduleRule> findActiveByVersionIdWithDetails(
@Param("versionId") Long versionId,
@Param("excludeRuleId") Long excludeRuleId
);
@Query("""
select distinct r
from ScheduleRule r
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.scheduleVersion
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot

View File

@@ -0,0 +1,21 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleVersionHistory;
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 ScheduleVersionHistoryRepository extends JpaRepository<ScheduleVersionHistory, Long> {
@Query("""
select history
from ScheduleVersionHistory history
left join fetch history.version version
left join fetch history.actor
where version.semester.id = :semesterId
order by history.createdAt desc, history.id desc
""")
List<ScheduleVersionHistory> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
}

View File

@@ -0,0 +1,55 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
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.util.List;
import java.util.Optional;
public interface ScheduleVersionRepository extends JpaRepository<ScheduleVersion, Long> {
@Query("""
select version
from ScheduleVersion version
left join fetch version.semester semester
left join fetch semester.academicYear
left join fetch version.createdBy
left join fetch version.publishedBy
left join fetch version.basedOnVersion
left join fetch version.restoredFromVersion
where semester.id = :semesterId
order by version.versionNumber desc
""")
List<ScheduleVersion> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
@Query("""
select version
from ScheduleVersion version
left join fetch version.semester semester
left join fetch semester.academicYear
left join fetch version.createdBy
left join fetch version.publishedBy
left join fetch version.basedOnVersion
left join fetch version.restoredFromVersion
where version.id = :id
""")
Optional<ScheduleVersion> findByIdWithDetails(@Param("id") Long id);
Optional<ScheduleVersion> findFirstBySemesterIdAndStatus(Long semesterId, ScheduleVersionStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select version from ScheduleVersion version where version.id = :id")
Optional<ScheduleVersion> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select version from ScheduleVersion version where version.semester.id = :semesterId order by version.id")
List<ScheduleVersion> findBySemesterIdForUpdate(@Param("semesterId") Long semesterId);
@Query("select coalesce(max(version.versionNumber), 0) from ScheduleVersion version where version.semester.id = :semesterId")
int findMaxVersionNumber(@Param("semesterId") Long semesterId);
}

View File

@@ -67,6 +67,20 @@ public interface TeacherPreferenceRepository extends JpaRepository<TeacherPrefer
@Param("semesterId") Long semesterId
);
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.semester.id = :semesterId
and preference.status = 'APPROVED'
order by preference.teacher.id, preference.preferenceType,
preference.preferenceDate, preference.dayOfWeek, slot.orderNumber
""")
List<TeacherPreference> findApprovedForSemester(@Param("semesterId") Long semesterId);
@Query("""
select count(preference) > 0
from TeacherPreference preference

View File

@@ -95,6 +95,30 @@ public class ScheduleGeneratorService {
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
}
public List<RenderedLessonDto> buildScheduleForVersion(Long versionId,
LocalDate startDate,
LocalDate endDate) {
validateRange(startDate, endDate);
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
List<ScheduleRule> rules = scheduleRuleRepository.findActiveByVersionIdWithDetails(versionId, null);
List<StudentGroup> groups = rules.stream()
.flatMap(rule -> rule.getGroups().stream())
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, ignored) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
@@ -113,6 +137,24 @@ public class ScheduleGeneratorService {
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate,
List<ScheduleRule> rules,
List<Semester> semesters) {
if (groups.isEmpty()) {
return List.of();
}
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> groupIds = groups.stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
@@ -10,6 +11,8 @@ import com.magistr.app.model.ScheduleLessonCategory;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
@@ -20,6 +23,7 @@ import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
@@ -72,6 +76,7 @@ public class ScheduleRuleService {
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
private ScheduleVersionRepository scheduleVersionRepository;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
@@ -100,9 +105,22 @@ public class ScheduleRuleService {
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
}
@Autowired(required = false)
void setScheduleVersionRepository(ScheduleVersionRepository scheduleVersionRepository) {
this.scheduleVersionRepository = scheduleVersionRepository;
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
return getAll(semesterId, groupId, null);
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId, Long scheduleVersionId) {
List<ScheduleRule> source = scheduleVersionId == null
? scheduleRuleRepository.findAllWithDetails()
: scheduleRuleRepository.findActiveByVersionIdWithDetails(scheduleVersionId, null);
return source.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream()
@@ -127,11 +145,17 @@ public class ScheduleRuleService {
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleRule candidate = buildCandidate(request, semester);
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId());
ScheduleRule candidate = buildCandidate(request, semester, version);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, null);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
if (saved.getVersionGroupId() == null && saved.getId() != null) {
saved.setVersionGroupId(saved.getId());
saved = scheduleRuleRepository.saveAndFlush(saved);
}
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@@ -144,6 +168,7 @@ public class ScheduleRuleService {
// определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureDraftRule(managedRule);
Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
@@ -154,7 +179,18 @@ public class ScheduleRuleService {
throw new IllegalArgumentException("Семестр не найден");
}
ScheduleRule candidate = buildCandidate(request, targetSemester);
if (managedRule.getScheduleVersion() != null
&& request.scheduleVersionId() != null
&& !Objects.equals(managedRule.getScheduleVersion().getId(), request.scheduleVersionId())) {
throw new IllegalArgumentException("Нельзя переносить правило между версиями расписания");
}
if (managedRule.getScheduleVersion() != null
&& !Objects.equals(managedRule.getScheduleVersion().getSemester().getId(), targetSemester.getId())) {
throw new IllegalArgumentException("Нельзя переносить правило в другой семестр внутри версии");
}
ScheduleRule candidate = buildCandidate(request, targetSemester, managedRule.getScheduleVersion());
candidate.setVersionGroupId(managedRule.getVersionGroupId());
validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId());
@@ -168,6 +204,7 @@ public class ScheduleRuleService {
public void archive(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureDraftRule(rule);
Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
@@ -274,7 +311,7 @@ public class ScheduleRuleService {
normalizeLessonFormat(slot.lessonFormat());
}
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester, ScheduleVersion version) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
@@ -293,6 +330,11 @@ public class ScheduleRuleService {
ScheduleRule candidate = new ScheduleRule();
candidate.setSubject(subject);
candidate.setSemester(semester);
candidate.setScheduleVersion(version);
Long currentUserId = AuthContext.currentUserId();
if (currentUserId != null) {
userRepository.findById(currentUserId).ifPresent(candidate::setCreatedBy);
}
candidate.setLectureAcademicHours(request.lectureAcademicHours());
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
@@ -505,8 +547,12 @@ public class ScheduleRuleService {
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
ScheduleRuleConflict conflict = scheduleRuleRepository
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
List<ScheduleRule> savedRules = candidate.getScheduleVersion() == null
? scheduleRuleRepository.findActiveBySemesterIdWithDetails(
candidate.getSemester().getId(), excludeRuleId)
: scheduleRuleRepository.findActiveByVersionIdWithDetails(
candidate.getScheduleVersion().getId(), excludeRuleId);
ScheduleRuleConflict conflict = savedRules
.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
@@ -805,10 +851,72 @@ public class ScheduleRuleService {
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
slots,
rule.getScheduleVersion() == null ? null : rule.getScheduleVersion().getId()
);
}
@Transactional(readOnly = true)
public List<String> validateVersion(Long scheduleVersionId) {
if (scheduleVersionId == null) {
return List.of("Версия расписания не выбрана");
}
List<ScheduleRule> rules = scheduleRuleRepository
.findActiveByVersionIdWithDetails(scheduleVersionId, null);
if (rules.isEmpty()) {
return List.of("В черновике нет ни одного правила расписания");
}
LinkedHashSet<String> issues = new LinkedHashSet<>();
for (ScheduleRule rule : rules) {
if (rule.getSlots().isEmpty()) {
issues.add("Правило «" + rule.getSubject().getName() + "» не содержит слотов");
continue;
}
try {
validateCandidate(rule);
validateAgainstSavedRules(rule, rule.getId());
} catch (IllegalArgumentException | ScheduleRuleConflictException exception) {
issues.add("Правило «" + rule.getSubject().getName() + "»: " + exception.getMessage());
}
}
return List.copyOf(issues);
}
private ScheduleVersion resolveDraftVersion(Long versionId, Long semesterId) {
if (versionId == null) {
return null;
}
if (scheduleVersionRepository == null) {
throw new IllegalStateException("Хранилище версий расписания недоступно");
}
ScheduleVersion version = scheduleVersionRepository.findByIdForUpdate(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Версия относится к другому семестру");
}
if (version.getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Изменять правила можно только в черновике");
}
return version;
}
private void ensureDraftRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null
&& rule.getScheduleVersion().getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Изменять правила можно только в черновике");
}
}
private void lockAndEnsureDraftRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
ScheduleVersion lockedVersion = scheduleVersionRepository
.findByIdForUpdate(rule.getScheduleVersion().getId())
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
rule.setScheduleVersion(lockedVersion);
}
ensureDraftRule(rule);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);

View File

@@ -0,0 +1,657 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.dto.ScheduleVersionDiffDto;
import com.magistr.app.dto.ScheduleVersionDto;
import com.magistr.app.dto.ScheduleVersionHistoryDto;
import com.magistr.app.dto.ScheduleVersionValidationDto;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.model.User;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ScheduleVersionService {
private static final Logger log = LoggerFactory.getLogger(ScheduleVersionService.class);
private static final int MAX_RANGE_DAYS = 120;
private static final int MAX_REASON_LENGTH = 2000;
private final ScheduleVersionRepository versionRepository;
private final ScheduleVersionHistoryRepository historyRepository;
private final ScheduleRuleRepository ruleRepository;
private final SemesterRepository semesterRepository;
private final UserRepository userRepository;
private final ScheduleRuleService ruleService;
private final ScheduleGeneratorService generatorService;
public ScheduleVersionService(
ScheduleVersionRepository versionRepository,
ScheduleVersionHistoryRepository historyRepository,
ScheduleRuleRepository ruleRepository,
SemesterRepository semesterRepository,
UserRepository userRepository,
ScheduleRuleService ruleService,
ScheduleGeneratorService generatorService
) {
this.versionRepository = versionRepository;
this.historyRepository = historyRepository;
this.ruleRepository = ruleRepository;
this.semesterRepository = semesterRepository;
this.userRepository = userRepository;
this.ruleService = ruleService;
this.generatorService = generatorService;
}
@Transactional(readOnly = true)
public List<ScheduleVersionDto> getVersions(Long semesterId) {
requireSemesterId(semesterId);
return versionRepository.findBySemesterIdWithDetails(semesterId).stream()
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public ScheduleVersionDto getVersion(Long versionId) {
return toDto(findVersion(versionId));
}
@Transactional(readOnly = true)
public List<ScheduleVersionHistoryDto> getHistory(Long semesterId) {
requireSemesterId(semesterId);
return historyRepository.findBySemesterIdWithDetails(semesterId).stream()
.map(this::toHistoryDto)
.toList();
}
@Transactional
public ScheduleVersionDto createDraft(ScheduleVersionCreateDto request) {
if (request == null || request.semesterId() == null) {
throw new IllegalArgumentException("Семестр для черновика обязателен");
}
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
List<ScheduleVersion> lockedVersions = versionRepository.findBySemesterIdForUpdate(semester.getId());
ScheduleVersion base = resolveBaseVersion(request.basedOnVersionId(), semester.getId(), lockedVersions);
int nextNumber = lockedVersions.stream()
.map(ScheduleVersion::getVersionNumber)
.filter(Objects::nonNull)
.max(Integer::compareTo)
.orElse(0) + 1;
ScheduleVersion draft = new ScheduleVersion();
draft.setSemester(semester);
draft.setVersionNumber(nextNumber);
draft.setName(normalizeName(request.name(), "Черновик версии " + nextNumber));
draft.setStatus(ScheduleVersionStatus.DRAFT);
draft.setBasedOnVersion(base);
draft.setCreatedBy(currentUser());
ScheduleVersion saved = versionRepository.saveAndFlush(draft);
if (base != null) {
cloneRules(base, saved);
}
addHistory(saved, "CREATED", currentUser(), base == null
? "Создан пустой черновик"
: "Создан черновик на основе версии " + base.getVersionNumber());
log.info("Создан черновик расписания {} для семестра {} на основе версии {}",
saved.getId(), semester.getId(), base == null ? "без основы" : base.getId());
return toDto(findVersion(saved.getId()));
}
@Transactional(readOnly = true)
public ScheduleVersionValidationDto validate(Long versionId) {
ScheduleVersion version = findVersion(versionId);
List<ScheduleRule> rules = activeRules(versionId);
List<String> issues = ruleService.validateVersion(versionId);
long slotCount = rules.stream().mapToLong(rule -> rule.getSlots().size()).sum();
return new ScheduleVersionValidationDto(
version.getId(),
issues.isEmpty(),
rules.size(),
slotCount,
issues
);
}
@Transactional
public ScheduleVersionDto publish(Long versionId, String reason) {
ScheduleVersion target = lockVersion(versionId);
if (target.getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Опубликовать можно только черновик");
}
String normalizedReason = requireReason(reason, "Для публикации укажите причину изменений");
ScheduleVersionValidationDto validation = validate(versionId);
if (!validation.valid()) {
throw new IllegalArgumentException(
"Черновик не прошёл проверку: " + String.join("; ", validation.issues())
);
}
List<ScheduleVersion> versions = versionRepository
.findBySemesterIdForUpdate(target.getSemester().getId());
User actor = currentUser();
Instant now = Instant.now();
versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.filter(version -> !Objects.equals(version.getId(), target.getId()))
.forEach(version -> archivePublished(version, actor, normalizedReason, now));
versionRepository.flush();
target.setStatus(ScheduleVersionStatus.PUBLISHED);
target.setChangeReason(normalizedReason);
target.setPublishedBy(actor);
target.setPublishedAt(now);
target.setArchivedAt(null);
versionRepository.saveAndFlush(target);
addHistory(target, "PUBLISHED", actor, normalizedReason);
generatorService.clearCache();
log.info("Опубликована версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
return toDto(findVersion(target.getId()));
}
@Transactional
public ScheduleVersionDto restore(Long versionId, String reason) {
ScheduleVersion target = lockVersion(versionId);
if (target.getStatus() != ScheduleVersionStatus.ARCHIVED || target.getPublishedAt() == null) {
throw new IllegalArgumentException("Восстановить можно только ранее опубликованную архивную версию");
}
String normalizedReason = requireReason(reason, "Для восстановления укажите причину");
List<ScheduleVersion> versions = versionRepository
.findBySemesterIdForUpdate(target.getSemester().getId());
ScheduleVersion current = versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.findFirst()
.orElse(null);
User actor = currentUser();
Instant now = Instant.now();
if (current != null && !Objects.equals(current.getId(), target.getId())) {
archivePublished(current, actor, normalizedReason, now);
versionRepository.flush();
}
target.setStatus(ScheduleVersionStatus.PUBLISHED);
target.setChangeReason(normalizedReason);
target.setPublishedBy(actor);
target.setPublishedAt(now);
target.setArchivedAt(null);
target.setRestoredFromVersion(current);
versionRepository.saveAndFlush(target);
addHistory(target, "RESTORED", actor, normalizedReason);
generatorService.clearCache();
log.info("Восстановлена версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
return toDto(findVersion(target.getId()));
}
@Transactional(readOnly = true)
public ScheduleVersionDiffDto diff(Long targetVersionId, Long requestedBaseVersionId) {
ScheduleVersion target = findVersion(targetVersionId);
ScheduleVersion base = resolveDiffBase(target, requestedBaseVersionId);
List<ScheduleRule> beforeRules = activeRules(base.getId());
List<ScheduleRule> afterRules = activeRules(target.getId());
Map<Long, ScheduleRule> beforeByGroup = indexRulesByVersionGroup(beforeRules);
Map<Long, ScheduleRule> afterByGroup = indexRulesByVersionGroup(afterRules);
LinkedHashSet<Long> allGroups = new LinkedHashSet<>();
allGroups.addAll(beforeByGroup.keySet());
allGroups.addAll(afterByGroup.keySet());
int added = 0;
int removed = 0;
int changed = 0;
int unchanged = 0;
List<ScheduleVersionDiffDto.RuleChange> changes = new ArrayList<>();
for (Long groupId : allGroups) {
ScheduleRule before = beforeByGroup.get(groupId);
ScheduleRule after = afterByGroup.get(groupId);
String type;
if (before == null) {
type = "ADDED";
added++;
} else if (after == null) {
type = "REMOVED";
removed++;
} else if (!Objects.equals(ruleFingerprint(before), ruleFingerprint(after))) {
type = "CHANGED";
changed++;
} else {
unchanged++;
continue;
}
ScheduleRule representative = after == null ? before : after;
changes.add(new ScheduleVersionDiffDto.RuleChange(
type,
groupId,
before == null ? null : before.getId(),
after == null ? null : after.getId(),
representative.getSubject().getName(),
representative.getGroups().stream().map(StudentGroup::getName).sorted().toList(),
before == null ? null : ruleSummary(before),
after == null ? null : ruleSummary(after)
));
}
LessonDifference lessons = compareLessons(base, target, beforeRules, afterRules);
return new ScheduleVersionDiffDto(
base.getId(),
versionLabel(base),
target.getId(),
versionLabel(target),
added,
removed,
changed,
unchanged,
lessons.added(),
lessons.removed(),
lessons.changed(),
changes.stream()
.sorted(Comparator.comparing(ScheduleVersionDiffDto.RuleChange::subjectName))
.toList()
);
}
private void cloneRules(ScheduleVersion base, ScheduleVersion target) {
List<ScheduleRule> sourceRules = activeRules(base.getId());
List<ScheduleRule> copies = new ArrayList<>();
for (ScheduleRule source : sourceRules) {
ScheduleRule copy = new ScheduleRule();
copy.setSubject(source.getSubject());
copy.setSemester(source.getSemester());
copy.setScheduleVersion(target);
copy.setVersionGroupId(source.getVersionGroupId() == null ? source.getId() : source.getVersionGroupId());
copy.setChangeReason(source.getChangeReason());
copy.setCreatedBy(currentUser());
copy.setStatus(source.getStatus());
copy.setValidFrom(source.getValidFrom());
copy.setValidTo(source.getValidTo());
copy.setLectureAcademicHours(source.getLectureAcademicHours());
copy.setLaboratoryAcademicHours(source.getLaboratoryAcademicHours());
copy.setPracticeAcademicHours(source.getPracticeAcademicHours());
copy.setLectureStartWeek(source.getLectureStartWeek());
copy.setLaboratoryStartWeek(source.getLaboratoryStartWeek());
copy.setPracticeStartWeek(source.getPracticeStartWeek());
copy.setGroups(new LinkedHashSet<>(source.getGroups()));
LinkedHashSet<ScheduleRuleSlot> copiedSlots = new LinkedHashSet<>();
for (ScheduleRuleSlot sourceSlot : source.getSlots()) {
ScheduleRuleSlot copySlot = new ScheduleRuleSlot();
copySlot.setScheduleRule(copy);
copySlot.setDayOfWeek(sourceSlot.getDayOfWeek());
copySlot.setParity(sourceSlot.getParity());
copySlot.setTimeSlot(sourceSlot.getTimeSlot());
copySlot.setSubgroups(new LinkedHashSet<>(sourceSlot.getSubgroups()));
copySlot.setTeacher(sourceSlot.getTeacher());
copySlot.setClassroom(sourceSlot.getClassroom());
copySlot.setLessonType(sourceSlot.getLessonType());
copySlot.setLessonFormat(sourceSlot.getLessonFormat());
copiedSlots.add(copySlot);
}
copy.setSlots(copiedSlots);
copies.add(copy);
}
ruleRepository.saveAllAndFlush(copies);
}
private void archivePublished(ScheduleVersion version, User actor, String reason, Instant archivedAt) {
version.setStatus(ScheduleVersionStatus.ARCHIVED);
version.setArchivedAt(archivedAt);
versionRepository.save(version);
addHistory(version, "ARCHIVED", actor, reason);
}
private void addHistory(ScheduleVersion version, String action, User actor, String reason) {
ScheduleVersionHistory history = new ScheduleVersionHistory();
history.setVersion(version);
history.setAction(action);
history.setActor(actor);
history.setReason(reason);
historyRepository.save(history);
}
private ScheduleVersion resolveBaseVersion(Long baseVersionId,
Long semesterId,
List<ScheduleVersion> versions) {
ScheduleVersion base = null;
if (baseVersionId != null) {
base = versions.stream()
.filter(version -> Objects.equals(version.getId(), baseVersionId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Базовая версия относится к другому семестру"));
} else {
base = versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.findFirst()
.orElse(null);
}
if (base != null && !Objects.equals(base.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Базовая версия относится к другому семестру");
}
return base;
}
private ScheduleVersion resolveDiffBase(ScheduleVersion target, Long requestedBaseVersionId) {
if (requestedBaseVersionId != null) {
ScheduleVersion base = findVersion(requestedBaseVersionId);
if (!Objects.equals(base.getSemester().getId(), target.getSemester().getId())) {
throw new IllegalArgumentException("Сравнивать можно только версии одного семестра");
}
return base;
}
if (target.getBasedOnVersion() != null) {
return findVersion(target.getBasedOnVersion().getId());
}
return versionRepository.findBySemesterIdWithDetails(target.getSemester().getId()).stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.filter(version -> !Objects.equals(version.getId(), target.getId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Для этой версии нет базы сравнения"));
}
private LessonDifference compareLessons(ScheduleVersion base,
ScheduleVersion target,
List<ScheduleRule> beforeRules,
List<ScheduleRule> afterRules) {
List<RenderedLessonDto> beforeLessons = loadVersionLessons(base);
List<RenderedLessonDto> afterLessons = loadVersionLessons(target);
Map<Long, Long> beforeGroups = beforeRules.stream().collect(Collectors.toMap(
ScheduleRule::getId,
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
));
Map<Long, Long> afterGroups = afterRules.stream().collect(Collectors.toMap(
ScheduleRule::getId,
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
));
Map<LessonIdentity, List<String>> before = indexLessons(beforeLessons, beforeGroups);
Map<LessonIdentity, List<String>> after = indexLessons(afterLessons, afterGroups);
LinkedHashSet<LessonIdentity> keys = new LinkedHashSet<>();
keys.addAll(before.keySet());
keys.addAll(after.keySet());
int added = 0;
int removed = 0;
int changed = 0;
for (LessonIdentity key : keys) {
List<String> oldValues = new ArrayList<>(before.getOrDefault(key, List.of()));
List<String> newValues = new ArrayList<>(after.getOrDefault(key, List.of()));
for (int index = oldValues.size() - 1; index >= 0; index--) {
String value = oldValues.get(index);
int match = newValues.indexOf(value);
if (match >= 0) {
oldValues.remove(index);
newValues.remove(match);
}
}
int changedHere = Math.min(oldValues.size(), newValues.size());
changed += changedHere;
removed += oldValues.size() - changedHere;
added += newValues.size() - changedHere;
}
return new LessonDifference(added, removed, changed);
}
private Map<LessonIdentity, List<String>> indexLessons(List<RenderedLessonDto> lessons,
Map<Long, Long> versionGroups) {
Map<String, RenderedLessonDto> unique = lessons.stream().collect(Collectors.toMap(
this::renderedLessonIdentity,
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
Map<LessonIdentity, List<String>> result = new LinkedHashMap<>();
for (RenderedLessonDto lesson : unique.values()) {
LessonIdentity identity = new LessonIdentity(
versionGroups.getOrDefault(lesson.scheduleRuleId(), lesson.scheduleRuleId()),
lesson.date(),
lesson.lessonTypeId(),
sortedIds(lesson.groupIds()),
sortedIds(lesson.subgroupIds())
);
String value = List.of(
Objects.toString(lesson.timeSlotId(), ""),
Objects.toString(lesson.teacherId(), ""),
Objects.toString(lesson.classroomId(), ""),
Objects.toString(lesson.lessonFormat(), "")
).toString();
result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(value);
}
result.values().forEach(values -> values.sort(String::compareTo));
return result;
}
private List<RenderedLessonDto> loadVersionLessons(ScheduleVersion version) {
List<RenderedLessonDto> lessons = new ArrayList<>();
LocalDate cursor = version.getSemester().getStartDate();
LocalDate semesterEnd = version.getSemester().getEndDate();
while (!cursor.isAfter(semesterEnd)) {
LocalDate chunkEnd = cursor.plusDays(MAX_RANGE_DAYS - 1L);
if (chunkEnd.isAfter(semesterEnd)) {
chunkEnd = semesterEnd;
}
lessons.addAll(generatorService.buildScheduleForVersion(version.getId(), cursor, chunkEnd));
cursor = chunkEnd.plusDays(1);
}
return lessons;
}
private String renderedLessonIdentity(RenderedLessonDto lesson) {
return List.of(
Objects.toString(lesson.scheduleRuleId(), ""),
Objects.toString(lesson.scheduleRuleSlotId(), ""),
Objects.toString(lesson.date(), ""),
sortedIds(lesson.groupIds()).toString(),
sortedIds(lesson.subgroupIds()).toString()
).toString();
}
private Map<Long, ScheduleRule> indexRulesByVersionGroup(List<ScheduleRule> rules) {
return rules.stream().collect(Collectors.toMap(
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId(),
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
}
private String ruleFingerprint(ScheduleRule rule) {
List<String> slots = rule.getSlots().stream().map(this::slotFingerprint).sorted().toList();
return List.of(
rule.getSubject().getId(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
rule.getGroups().stream().map(StudentGroup::getId).sorted().toList(),
slots
).toString();
}
private String slotFingerprint(ScheduleRuleSlot slot) {
return List.of(
slot.getDayOfWeek(),
slot.getParity(),
slot.getTimeSlot().getId(),
slot.getTeacher().getId(),
slot.getClassroom().getId(),
slot.getLessonType().getId(),
slot.getLessonFormat(),
slot.getSubgroups().stream().map(Subgroup::getId).sorted().toList()
).toString();
}
private String ruleSummary(ScheduleRule rule) {
String load = "лекции " + rule.getLectureAcademicHours()
+ " ч., лабораторные " + rule.getLaboratoryAcademicHours()
+ " ч., практики " + rule.getPracticeAcademicHours() + " ч.";
String slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber()))
.limit(4)
.map(slot -> ScheduleGeneratorService.dayName(DayOfWeek.of(slot.getDayOfWeek()))
+ ", " + slot.getTimeSlot().getOrderNumber() + " пара"
+ ", " + ScheduleGeneratorService.displayUserName(slot.getTeacher()))
.collect(Collectors.joining("; "));
return load + " · " + rule.getSlots().size() + " слотов" + (slots.isBlank() ? "" : ": " + slots);
}
private ScheduleVersionDto toDto(ScheduleVersion version) {
List<ScheduleRule> rules = activeRules(version.getId());
return new ScheduleVersionDto(
version.getId(),
version.getSemester().getId(),
semesterLabel(version.getSemester()),
version.getVersionNumber(),
version.getName(),
version.getStatus(),
idOf(version.getBasedOnVersion()),
idOf(version.getRestoredFromVersion()),
version.getChangeReason(),
idOf(version.getCreatedBy()),
nameOf(version.getCreatedBy()),
version.getCreatedAt(),
idOf(version.getPublishedBy()),
nameOf(version.getPublishedBy()),
version.getPublishedAt(),
version.getArchivedAt(),
rules.size(),
rules.stream().mapToLong(rule -> rule.getSlots().size()).sum()
);
}
private ScheduleVersionHistoryDto toHistoryDto(ScheduleVersionHistory history) {
return new ScheduleVersionHistoryDto(
history.getId(),
history.getVersion().getId(),
history.getVersion().getVersionNumber(),
history.getVersion().getName(),
history.getAction(),
idOf(history.getActor()),
nameOf(history.getActor()),
history.getReason(),
history.getCreatedAt()
);
}
private ScheduleVersion findVersion(Long versionId) {
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
return versionRepository.findByIdWithDetails(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
}
private ScheduleVersion lockVersion(Long versionId) {
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
return versionRepository.findByIdForUpdate(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
}
private List<ScheduleRule> activeRules(Long versionId) {
return ruleRepository.findActiveByVersionIdWithDetails(versionId, null);
}
private User currentUser() {
Long currentUserId = AuthContext.currentUserId();
return currentUserId == null ? null : userRepository.findById(currentUserId).orElse(null);
}
private String normalizeName(String name, String fallback) {
String normalized = name == null ? "" : name.trim();
if (normalized.isEmpty()) {
return fallback;
}
if (normalized.length() > 160) {
throw new IllegalArgumentException("Название версии не может быть длиннее 160 символов");
}
return normalized;
}
private String requireReason(String reason, String message) {
String normalized = reason == null ? "" : reason.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(message);
}
if (normalized.length() > MAX_REASON_LENGTH) {
throw new IllegalArgumentException("Причина не может быть длиннее 2000 символов");
}
return normalized;
}
private void requireSemesterId(Long semesterId) {
if (semesterId == null) {
throw new IllegalArgumentException("Семестр обязателен");
}
}
private String semesterLabel(Semester semester) {
return semester.getAcademicYear().getTitle() + " · "
+ (semester.getSemesterType().name().equals("autumn") ? "осенний" : "весенний") + " семестр";
}
private String versionLabel(ScheduleVersion version) {
return "Версия " + version.getVersionNumber() + " · " + version.getName();
}
private Long idOf(ScheduleVersion version) {
return version == null ? null : version.getId();
}
private Long idOf(User user) {
return user == null ? null : user.getId();
}
private String nameOf(User user) {
return user == null ? null : ScheduleGeneratorService.displayUserName(user);
}
private List<Long> sortedIds(List<Long> ids) {
return ids == null ? List.of() : ids.stream().filter(Objects::nonNull).sorted().toList();
}
private record LessonIdentity(
Long versionGroupId,
LocalDate date,
Long lessonTypeId,
List<Long> groupIds,
List<Long> subgroupIds
) {
}
private record LessonDifference(int added, int removed, int changed) {
}
}

View File

@@ -2182,3 +2182,323 @@ ALTER TABLE teacher_department_assignments
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
ON teacher_department_assignments
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
-- ==========================================
-- Отсутствия преподавателей и мастер замены
-- ==========================================
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 'Выбранный способ разрешения затронутого занятия';
-- ==========================================
-- Пожелания и заявки преподавателей
-- ==========================================
CREATE TABLE teacher_preferences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
semester_id BIGINT NOT NULL REFERENCES semesters(id),
preference_type VARCHAR(30) NOT NULL,
day_of_week SMALLINT,
time_slot_id BIGINT REFERENCES time_slots(id),
preference_date DATE,
comment TEXT,
status VARCHAR(20) NOT NULL,
requested_by BIGINT REFERENCES users(id),
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_preferences_type CHECK (
preference_type IN (
'HARD_UNAVAILABLE',
'SOFT_PREFERRED',
'SOFT_UNWANTED',
'CONSECUTIVE',
'NO_GAPS'
)
),
CONSTRAINT chk_teacher_preferences_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_preferences_day CHECK (
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
),
CONSTRAINT chk_teacher_preferences_payload CHECK (
(
preference_type = 'HARD_UNAVAILABLE'
AND (
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
OR
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
)
)
OR
(
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
AND preference_date IS NULL
AND day_of_week IS NOT NULL
AND time_slot_id IS NOT NULL
)
OR
(
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
AND preference_date IS NULL
AND day_of_week IS NULL
AND time_slot_id IS NULL
)
),
CONSTRAINT chk_teacher_preferences_comment CHECK (
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
)
);
CREATE INDEX idx_teacher_preferences_teacher_semester
ON teacher_preferences(teacher_id, semester_id, status);
CREATE INDEX idx_teacher_preferences_slot
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
WHERE status = 'APPROVED';
CREATE INDEX idx_teacher_preferences_date
ON teacher_preferences(teacher_id, preference_date)
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
CREATE TABLE teacher_change_requests (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
request_type VARCHAR(30) NOT NULL,
target_lesson_date DATE,
requested_time_slot_id BIGINT REFERENCES time_slots(id),
requested_classroom_id BIGINT REFERENCES classrooms(id),
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_requests_type CHECK (
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
),
CONSTRAINT chk_teacher_change_requests_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_change_requests_reason CHECK (
length(trim(reason)) BETWEEN 1 AND 2000
),
CONSTRAINT chk_teacher_change_requests_payload CHECK (
(
request_type = 'MOVE'
AND requested_time_slot_id IS NOT NULL
AND requested_classroom_id IS NULL
)
OR
(
request_type = 'CHANGE_CLASSROOM'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NOT NULL
)
OR
(
request_type = 'CANCEL'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NULL
)
)
);
CREATE INDEX idx_teacher_change_requests_teacher
ON teacher_change_requests(teacher_id, created_at DESC);
CREATE INDEX idx_teacher_change_requests_status
ON teacher_change_requests(status, created_at);
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
ON teacher_change_requests(base_rule_slot_id, lesson_date)
WHERE status = 'PENDING';
CREATE TABLE teacher_change_request_history (
id BIGSERIAL PRIMARY KEY,
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
comment TEXT,
actor_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_request_history_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
)
);
CREATE INDEX idx_teacher_change_request_history_request
ON teacher_change_request_history(request_id, created_at, id);
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';
-- ==========================================
-- Черновики, версии, публикация и аудит расписания
-- ==========================================
CREATE TABLE schedule_versions (
id BIGSERIAL PRIMARY KEY,
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
version_number INT NOT NULL,
name VARCHAR(160) NOT NULL,
status VARCHAR(20) NOT NULL,
based_on_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
restored_from_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
change_reason TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_by BIGINT REFERENCES users(id),
published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
CONSTRAINT uq_schedule_versions_number UNIQUE (semester_id, version_number),
CONSTRAINT chk_schedule_versions_number CHECK (version_number > 0),
CONSTRAINT chk_schedule_versions_name CHECK (length(trim(name)) BETWEEN 1 AND 160),
CONSTRAINT chk_schedule_versions_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
CONSTRAINT chk_schedule_versions_publish_fields CHECK (
status <> 'PUBLISHED' OR published_at IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_schedule_versions_published_semester
ON schedule_versions(semester_id)
WHERE status = 'PUBLISHED';
CREATE INDEX idx_schedule_versions_semester_history
ON schedule_versions(semester_id, version_number DESC);
INSERT INTO schedule_versions (
semester_id,
version_number,
name,
status,
created_at,
published_at
)
SELECT
semester.id,
1,
'Опубликованная версия 1',
'PUBLISHED',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM semesters semester;
ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT;
UPDATE schedule_rules rule
SET schedule_version_id = version.id,
version_group_id = COALESCE(rule.version_group_id, rule.id)
FROM schedule_versions version
WHERE version.semester_id = rule.semester_id
AND version.status = 'PUBLISHED';
ALTER TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version
FOREIGN KEY (schedule_version_id) REFERENCES schedule_versions(id) ON DELETE CASCADE;
CREATE INDEX idx_schedule_rules_version
ON schedule_rules(schedule_version_id, status);
CREATE UNIQUE INDEX uq_schedule_rules_version_group
ON schedule_rules(schedule_version_id, version_group_id)
WHERE version_group_id IS NOT NULL;
CREATE TABLE schedule_version_history (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES schedule_versions(id) ON DELETE CASCADE,
action VARCHAR(30) NOT NULL,
actor_id BIGINT REFERENCES users(id),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_version_history_action CHECK (
action IN ('CREATED', 'PUBLISHED', 'ARCHIVED', 'RESTORED')
),
CONSTRAINT chk_schedule_version_history_reason CHECK (
reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 2000
)
);
CREATE INDEX idx_schedule_version_history_version
ON schedule_version_history(version_id, created_at DESC, id DESC);
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
FROM schedule_versions;
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
FROM schedule_versions;
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
COMMENT ON COLUMN schedule_versions.restored_from_version_id IS 'Предыдущая опубликованная версия при восстановлении';
COMMENT ON COLUMN schedule_rules.schedule_version_id IS 'Версия расписания, которой принадлежит правило';
COMMENT ON COLUMN schedule_rules.version_group_id IS 'Стабильный идентификатор правила между версиями';
COMMENT ON TABLE schedule_version_history IS 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';

View File

@@ -1,59 +0,0 @@
-- Реестр временных отсутствий преподавателей и журнал решений мастера замены.
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

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

View File

@@ -0,0 +1,71 @@
package com.magistr.app.controller;
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.service.ScheduleQualityService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.List;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class ScheduleQualityControllerTest {
private ScheduleQualityService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(ScheduleQualityService.class);
mockMvc = standaloneSetup(new ScheduleQualityController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsExplainableSemesterScore() throws Exception {
when(service.analyze(7L, 12L)).thenReturn(new ScheduleQualityAnalysisDto(
7L,
"2026/2027 · осенний семестр",
LocalDate.of(2026, 9, 1),
LocalDate.of(2026, 12, 31),
82,
18,
420,
3,
false,
List.of(new ScheduleQualityAnalysisDto.Metric(
"GROUP_GAPS",
"Окна у групп",
3,
"окон",
6,
"Свободные пары между занятиями"
)),
List.of(),
12L,
2,
"Черновик новой сетки",
ScheduleVersionStatus.DRAFT
));
mockMvc.perform(get("/api/edu-office/schedule/quality")
.param("semesterId", "7")
.param("versionId", "12"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(82))
.andExpect(jsonPath("$.scheduleVersionStatus").value("DRAFT"))
.andExpect(jsonPath("$.metrics[0].code").value("GROUP_GAPS"));
verify(service).analyze(7L, 12L);
}
}

View File

@@ -118,7 +118,8 @@ class ScheduleRuleAdminControllerTest {
1,
List.of(3L),
List.of("Группа"),
List.of()
List.of(),
9L
);
}
@@ -127,6 +128,7 @@ class ScheduleRuleAdminControllerTest {
{
"subjectId": 1,
"semesterId": 2,
"scheduleVersionId": 9,
"lectureAcademicHours": 2,
"laboratoryAcademicHours": 0,
"practiceAcademicHours": 0,

View File

@@ -44,6 +44,13 @@ class ScheduleRuleHoursMigrationIntegrationTest {
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules WHERE schedule_version_id IS NULL"))
.as("Все исходные правила должны принадлежать опубликованной версии")
.isZero();
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_versions WHERE status = 'PUBLISHED'"))
.as("V1 должна создать по одной опубликованной версии на семестр")
.isEqualTo(queryLong(connection, "SELECT count(*) FROM semesters"));
assertSecondPublishedVersionRejected(connection, seedIds.semesterId());
insertScheduleRule(connection, seedIds, 2, 4, 6);
@@ -83,7 +90,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
private SeedIds loadSeedIds(Connection connection) throws SQLException {
return new SeedIds(
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM schedule_versions ORDER BY id LIMIT 1")
);
}
@@ -98,19 +106,21 @@ class ScheduleRuleHoursMigrationIntegrationTest {
INSERT INTO schedule_rules (
subject_id,
semester_id,
schedule_version_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 1)
""")) {
statement.setLong(1, seedIds.subjectId());
statement.setLong(2, seedIds.semesterId());
statement.setInt(3, lectureHours);
statement.setInt(4, laboratoryHours);
statement.setInt(5, practiceHours);
statement.setLong(3, seedIds.scheduleVersionId());
statement.setInt(4, lectureHours);
statement.setInt(5, laboratoryHours);
statement.setInt(6, practiceHours);
statement.executeUpdate();
}
}
@@ -143,6 +153,23 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.contains(HOURS_CONSTRAINT);
}
private void assertSecondPublishedVersionRejected(Connection connection, long semesterId) {
Throwable failure = catchThrowable(() -> {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_versions (
semester_id, version_number, name, status, published_at
) VALUES (?, 99, 'Недопустимая вторая публикация', 'PUBLISHED', CURRENT_TIMESTAMP)
""")) {
statement.setLong(1, semesterId);
statement.executeUpdate();
}
});
assertThat(failure)
.as("В одном семестре не может быть двух опубликованных версий")
.isInstanceOf(SQLException.class);
assertThat(((SQLException) failure).getSQLState()).isEqualTo("23505");
}
private void assertExactSlotDuplicateRejected(Connection connection) {
Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));
@@ -201,6 +228,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
private record SeedIds(long subjectId, long semesterId) {
private record SeedIds(long subjectId, long semesterId, long scheduleVersionId) {
}
}

View File

@@ -45,13 +45,16 @@ class TeacherDepartmentInvariantIntegrationTest {
long teacherId = seedTeacherId(connection);
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
LocalDate initialStart = openPrimaryStart(connection, teacherId);
LocalDate initialEnd = initialStart.plusDays(30);
LocalDate transferStart = initialEnd.plusDays(1);
updateOpenPrimaryEnd(connection, teacherId, initialEnd);
insertAssignment(
connection,
teacherId,
secondDepartmentId,
LocalDate.of(2026, 8, 1),
transferStart,
null,
true
);
@@ -59,7 +62,7 @@ class TeacherDepartmentInvariantIntegrationTest {
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 1),
transferStart,
null,
false
);
@@ -69,8 +72,8 @@ class TeacherDepartmentInvariantIntegrationTest {
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 1),
transferStart.plusDays(14),
transferStart.plusDays(31),
true
),
"23P01",
@@ -94,11 +97,14 @@ class TeacherDepartmentInvariantIntegrationTest {
long teacherId;
long firstDepartmentId;
long secondDepartmentId;
LocalDate firstCandidateStart;
try (Connection connection = POSTGRES.createConnection("")) {
teacherId = seedTeacherId(connection);
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
LocalDate initialStart = openPrimaryStart(connection, teacherId);
updateOpenPrimaryEnd(connection, teacherId, initialStart.plusDays(30));
firstCandidateStart = initialStart.plusDays(31);
}
ExecutorService executor = Executors.newFixedThreadPool(2);
@@ -109,16 +115,16 @@ class TeacherDepartmentInvariantIntegrationTest {
executor.submit(() -> concurrentInsert(
teacherId,
firstDepartmentId,
LocalDate.of(2090, 1, 1),
LocalDate.of(2090, 12, 31),
firstCandidateStart,
firstCandidateStart.plusDays(364),
ready,
start
)),
executor.submit(() -> concurrentInsert(
teacherId,
secondDepartmentId,
LocalDate.of(2090, 6, 1),
LocalDate.of(2091, 5, 31),
firstCandidateStart.plusDays(151),
firstCandidateStart.plusDays(515),
ready,
start
))
@@ -186,6 +192,22 @@ class TeacherDepartmentInvariantIntegrationTest {
);
}
private LocalDate openPrimaryStart(Connection connection, long teacherId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT valid_from
FROM teacher_department_assignments
WHERE teacher_id = ?
AND is_primary = TRUE
AND valid_to IS NULL
""")) {
statement.setLong(1, teacherId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getDate(1).toLocalDate();
}
}
}
private void updateOpenPrimaryEnd(Connection connection,
long teacherId,
LocalDate validTo) throws SQLException {

View File

@@ -18,36 +18,38 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Миграция пожеланий и заявок преподавателей")
@DisplayName("Преподавательские процессы в базовой схеме")
class TeacherPreferencesMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
@Test
@DisplayName("V3 создаёт три таблицы и отклоняет противоречивое пожелание")
@DisplayName("V1 содержит таблицы прежних V2/V3 и отклоняет противоречивое пожелание")
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("3"))
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("3");
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'teacher_absences',
'teacher_absence_decisions',
'teacher_preferences',
'teacher_change_requests',
'teacher_change_request_history'
)
""")).isEqualTo(3L);
""")).isEqualTo(5L);
long teacherId = queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1");
long semesterId = queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1");

View File

@@ -0,0 +1,276 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TeacherPreferenceRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ScheduleQualityServiceTest {
private ScheduleQueryService scheduleQueryService;
private SemesterRepository semesterRepository;
private GroupRepository groupRepository;
private SubgroupRepository subgroupRepository;
private ClassroomRepository classroomRepository;
private TeacherPreferenceRepository preferenceRepository;
private EffectiveTimeSlotService effectiveTimeSlotService;
private ScheduleOverrideService scheduleOverrideService;
private ScheduleQualityService service;
@BeforeEach
void setUp() {
scheduleQueryService = mock(ScheduleQueryService.class);
semesterRepository = mock(SemesterRepository.class);
groupRepository = mock(GroupRepository.class);
subgroupRepository = mock(SubgroupRepository.class);
classroomRepository = mock(ClassroomRepository.class);
preferenceRepository = mock(TeacherPreferenceRepository.class);
effectiveTimeSlotService = mock(EffectiveTimeSlotService.class);
scheduleOverrideService = mock(ScheduleOverrideService.class);
service = new ScheduleQualityService(
scheduleQueryService,
semesterRepository,
groupRepository,
subgroupRepository,
classroomRepository,
preferenceRepository,
effectiveTimeSlotService,
scheduleOverrideService
);
}
@Test
void analysisExplainsGroupGapAndCapacityShortage() {
Semester semester = semester();
StudentGroup group = group(1L, 30L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate date = LocalDate.of(2026, 9, 7);
List<RenderedLessonDto> lessons = List.of(
lesson(11L, date, 1, 1L, room, group),
lesson(12L, date, 3, 3L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.lessonCount()).isEqualTo(2);
assertThat(result.score()).isLessThan(100);
assertThat(result.problems())
.extracting(problem -> problem.type())
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
assertThat(result.metrics())
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
.singleElement()
.satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0));
}
@Test
void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate monday = LocalDate.of(2026, 9, 7);
LocalDate tuesday = monday.plusDays(1);
List<RenderedLessonDto> lessons = List.of(
lesson(11L, monday, 1, 1L, room, group),
lesson(12L, tuesday, 1, 1L, room, group),
lesson(13L, tuesday, 2, 2L, room, group),
lesson(14L, tuesday, 3, 3L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.metrics())
.filteredOn(metric -> "DISTRIBUTION_BALANCE".equals(metric.code()))
.singleElement()
.satisfies(metric -> {
assertThat(metric.value()).isEqualTo(50.0);
assertThat(metric.penalty()).isZero();
});
}
@Test
void recommendationsOfferOnlyValidatedTighterClassroom() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom oversized = classroom(1L, "Актовый зал", 120);
Classroom compact = classroom(2L, "А-204", 24);
LocalDate date = LocalDate.of(2026, 9, 7);
RenderedLessonDto source = lesson(11L, date, 1, 1L, oversized, group);
stubContext(semester, group, List.of(oversized, compact), List.of(source));
when(effectiveTimeSlotService.findEffective(date)).thenReturn(List.of(timeSlot(1L, 1)));
var result = service.recommendations(semester.getId(), 11L, date);
assertThat(result.candidates())
.filteredOn(candidate -> "REPLACE".equals(candidate.action()))
.singleElement()
.satisfies(candidate -> {
assertThat(candidate.label()).contains("А-204", "24 мест");
assertThat(candidate.penaltyDelta()).isPositive();
assertThat(candidate.override().newClassroomId()).isEqualTo(2L);
});
verify(scheduleOverrideService).validateCandidate(any());
}
@Test
void draftQualityUsesItsOwnRulesInsteadOfPublishedSchedule() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom room = classroom(1L, "А-101", 20);
ScheduleVersion draft = new ScheduleVersion();
draft.setId(12L);
draft.setSemester(semester);
draft.setVersionNumber(2);
draft.setName("Черновик новой сетки");
draft.setStatus(ScheduleVersionStatus.DRAFT);
ScheduleVersionRepository versionRepository = mock(ScheduleVersionRepository.class);
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
service.setVersionDependencies(versionRepository, generatorService);
when(versionRepository.findByIdWithDetails(12L)).thenReturn(Optional.of(draft));
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
when(generatorService.buildScheduleForVersion(12L, semester.getStartDate(), semester.getEndDate()))
.thenReturn(List.of(lesson(11L, LocalDate.of(2026, 9, 7), 1, 1L, room, group)));
when(groupRepository.findAll()).thenReturn(List.of(group));
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
when(classroomRepository.findAll()).thenReturn(List.of(room));
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
var result = service.analyze(semester.getId(), 12L);
assertThat(result.scheduleVersionStatus()).isEqualTo(ScheduleVersionStatus.DRAFT);
assertThat(result.scheduleVersionId()).isEqualTo(12L);
assertThat(result.lessonCount()).isOne();
verify(scheduleQueryService, never()).searchForAggregation(any(), any(), any(), any());
}
private void stubContext(
Semester semester,
StudentGroup group,
List<Classroom> classrooms,
List<RenderedLessonDto> lessons
) {
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
when(scheduleQueryService.searchForAggregation(
null,
null,
semester.getStartDate(),
semester.getEndDate()
)).thenReturn(lessons);
when(groupRepository.findAll()).thenReturn(List.of(group));
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
when(classroomRepository.findAll()).thenReturn(classrooms);
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
}
private Semester semester() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(1L);
academicYear.setTitle("2026/2027");
academicYear.setStartDate(LocalDate.of(2026, 9, 1));
academicYear.setEndDate(LocalDate.of(2027, 8, 31));
Semester semester = new Semester();
semester.setId(7L);
semester.setAcademicYear(academicYear);
semester.setSemesterType(SemesterType.autumn);
semester.setStartDate(LocalDate.of(2026, 9, 1));
semester.setEndDate(LocalDate.of(2026, 9, 30));
return semester;
}
private StudentGroup group(Long id, Long size) {
StudentGroup group = new StudentGroup();
group.setId(id);
group.setName("ИБ-101");
group.setGroupSize(size);
return group;
}
private Classroom classroom(Long id, String name, int capacity) {
Classroom classroom = new Classroom();
classroom.setId(id);
classroom.setName(name);
classroom.setCapacity(capacity);
classroom.setIsAvailable(true);
return classroom;
}
private TimeSlot timeSlot(Long id, int order) {
TimeSlot slot = new TimeSlot();
slot.setId(id);
slot.setOrderNumber(order);
slot.setStartTime(LocalTime.of(8 + order, 0));
slot.setEndTime(LocalTime.of(9 + order, 30));
return slot;
}
private RenderedLessonDto lesson(
Long ruleSlotId,
LocalDate date,
int order,
Long timeSlotId,
Classroom classroom,
StudentGroup group
) {
return new RenderedLessonDto(
100L + ruleSlotId,
ruleSlotId,
date,
date.getDayOfWeek().getValue(),
"Понедельник",
1,
ScheduleParity.BOTH,
timeSlotId,
order,
LocalTime.of(8 + order, 0),
LocalTime.of(9 + order, 30),
1L,
"Архитектура систем",
5L,
"Петров Пётр Петрович",
classroom.getId(),
classroom.getName(),
1L,
"Лекция",
"Очно",
null,
null,
List.of(),
List.of(),
List.of(group.getId()),
List.of(group.getName()),
"Т",
36,
0,
34,
ScheduleParity.BOTH
);
}
}

View File

@@ -267,9 +267,15 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
RETURNING id
""", Long.class, academicYearId);
Long scheduleVersionId = jdbcTemplate.queryForObject("""
INSERT INTO schedule_versions (semester_id, version_number, name, status)
VALUES (?, 1, 'Конкурентный черновик', 'DRAFT')
RETURNING id
""", Long.class, semesterId);
return new SeedIds(
semesterId,
scheduleVersionId,
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
queryId("""
@@ -332,7 +338,8 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
1,
List.of(ids.groupId()),
List.of(),
List.of(slot)
List.of(slot),
ids.scheduleVersionId()
);
}
@@ -355,6 +362,7 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
private record SeedIds(
long semesterId,
long scheduleVersionId,
long subjectId,
long groupId,
long timeSlotId,

View File

@@ -432,7 +432,8 @@ class ScheduleRuleServiceTest {
1,
List.of(GROUP_ID),
null,
List.of(slots)
List.of(slots),
null
);
}

View File

@@ -0,0 +1,163 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ScheduleVersionServiceTest {
private ScheduleVersionRepository versionRepository;
private ScheduleVersionHistoryRepository historyRepository;
private ScheduleRuleRepository ruleRepository;
private SemesterRepository semesterRepository;
private ScheduleRuleService ruleService;
private ScheduleGeneratorService generatorService;
private ScheduleVersionService service;
@BeforeEach
void setUp() {
versionRepository = mock(ScheduleVersionRepository.class);
historyRepository = mock(ScheduleVersionHistoryRepository.class);
ruleRepository = mock(ScheduleRuleRepository.class);
semesterRepository = mock(SemesterRepository.class);
ruleService = mock(ScheduleRuleService.class);
generatorService = mock(ScheduleGeneratorService.class);
service = new ScheduleVersionService(
versionRepository,
historyRepository,
ruleRepository,
semesterRepository,
mock(UserRepository.class),
ruleService,
generatorService
);
}
@Test
void draftClonesRulesWithoutChangingPublishedVersion() {
Semester semester = semester();
ScheduleVersion published = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
ScheduleRule source = rule(21L, 100L, published);
when(semesterRepository.findByIdForUpdate(semester.getId())).thenReturn(Optional.of(semester));
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(published));
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> {
ScheduleVersion draft = invocation.getArgument(0);
draft.setId(2L);
return draft;
});
when(ruleRepository.findActiveByVersionIdWithDetails(1L, null)).thenReturn(List.of(source));
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of());
when(versionRepository.findByIdWithDetails(2L)).thenAnswer(invocation -> Optional.of(
version(2L, 2, ScheduleVersionStatus.DRAFT, semester)
));
service.createDraft(new ScheduleVersionCreateDto(semester.getId(), "Рабочая копия", 1L));
ArgumentCaptor<List<ScheduleRule>> copies = ArgumentCaptor.forClass(List.class);
verify(ruleRepository).saveAllAndFlush(copies.capture());
assertThat(copies.getValue()).singleElement().satisfies(copy -> {
assertThat(copy.getId()).isNull();
assertThat(copy.getVersionGroupId()).isEqualTo(100L);
assertThat(copy.getScheduleVersion().getId()).isEqualTo(2L);
assertThat(copy.getSubject()).isSameAs(source.getSubject());
});
assertThat(published.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
}
@Test
void publicationArchivesCurrentVersionAndActivatesWholeDraft() {
Semester semester = semester();
ScheduleVersion current = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
ScheduleVersion draft = version(2L, 2, ScheduleVersionStatus.DRAFT, semester);
ScheduleRule draftRule = rule(22L, 100L, draft);
when(versionRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(draft));
when(versionRepository.findByIdWithDetails(2L)).thenReturn(Optional.of(draft));
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(current, draft));
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of(draftRule));
when(ruleService.validateVersion(2L)).thenReturn(List.of());
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> invocation.getArgument(0));
service.publish(2L, "Утверждена новая сетка");
assertThat(current.getStatus()).isEqualTo(ScheduleVersionStatus.ARCHIVED);
assertThat(draft.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
assertThat(draft.getChangeReason()).isEqualTo("Утверждена новая сетка");
assertThat(draft.getPublishedAt()).isNotNull();
verify(generatorService).clearCache();
ArgumentCaptor<ScheduleVersionHistory> history = ArgumentCaptor.forClass(ScheduleVersionHistory.class);
verify(historyRepository, org.mockito.Mockito.times(2)).save(history.capture());
assertThat(history.getAllValues()).extracting(ScheduleVersionHistory::getAction)
.containsExactly("ARCHIVED", "PUBLISHED");
}
private Semester semester() {
AcademicYear year = new AcademicYear();
year.setId(5L);
year.setTitle("2026-2027");
Semester semester = new Semester();
semester.setId(7L);
semester.setAcademicYear(year);
semester.setSemesterType(SemesterType.autumn);
return semester;
}
private ScheduleVersion version(Long id,
int number,
ScheduleVersionStatus status,
Semester semester) {
ScheduleVersion version = new ScheduleVersion();
version.setId(id);
version.setSemester(semester);
version.setVersionNumber(number);
version.setName("Версия " + number);
version.setStatus(status);
return version;
}
private ScheduleRule rule(Long id, Long versionGroupId, ScheduleVersion version) {
Subject subject = new Subject();
subject.setId(11L);
subject.setName("Математика");
ScheduleRule rule = new ScheduleRule();
rule.setId(id);
rule.setSubject(subject);
rule.setSemester(version.getSemester());
rule.setScheduleVersion(version);
rule.setVersionGroupId(versionGroupId);
rule.setLectureAcademicHours(2);
rule.setLaboratoryAcademicHours(0);
rule.setPracticeAcademicHours(0);
rule.setLectureStartWeek(1);
rule.setLaboratoryStartWeek(1);
rule.setPracticeStartWeek(1);
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
rule.setSlots(new LinkedHashSet<>(List.of(slot)));
return rule;
}
}