задачи 2 и 8
This commit is contained in:
@@ -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", "Дисциплина уже добавлена в этот семестр графика"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("Выберите черновик расписания");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public record ScheduleRuleDto(
|
||||
Integer practiceStartWeek,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
List<ScheduleRuleSlotDto> slots
|
||||
List<ScheduleRuleSlotDto> slots,
|
||||
Long scheduleVersionId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionActionDto(String reason) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionCreateDto(
|
||||
Long semesterId,
|
||||
String name,
|
||||
Long basedOnVersionId
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal file
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum ScheduleVersionStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
ARCHIVED
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';
|
||||
|
||||
@@ -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 'Выбранный способ разрешения затронутого занятия';
|
||||
@@ -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 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -432,7 +432,8 @@ class ScheduleRuleServiceTest {
|
||||
1,
|
||||
List.of(GROUP_ID),
|
||||
null,
|
||||
List.of(slots)
|
||||
List.of(slots),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user