diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java index 73f4338..8fd867b 100644 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java @@ -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", "Дисциплина уже добавлена в этот семестр графика"), diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleQualityController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleQualityController.java new file mode 100644 index 0000000..e8333a0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleQualityController.java @@ -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); + } +} diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 224a7ea..6958a21 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -34,8 +34,9 @@ public class ScheduleRuleAdminController { @GetMapping public List 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("Выберите черновик расписания"); + } + } } diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleVersionController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleVersionController.java new file mode 100644 index 0000000..5642483 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleVersionController.java @@ -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 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 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 history(@RequestParam("semesterId") Long semesterId) { + return service.getHistory(semesterId); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java new file mode 100644 index 0000000..3efcfd0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java @@ -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 metrics, + List 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 + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleQualityRecommendationsDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityRecommendationsDto.java new file mode 100644 index 0000000..5b810d8 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityRecommendationsDto.java @@ -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 candidates +) { + public record Candidate( + String id, + String action, + String label, + String description, + int scoreDelta, + int penaltyDelta, + int resultingScore, + List improvements, + List tradeoffs, + ScheduleOverrideDto override + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java index 95d5ad2..87c43b9 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -19,6 +19,7 @@ public record ScheduleRuleDto( Integer practiceStartWeek, List groupIds, List groupNames, - List slots + List slots, + Long scheduleVersionId ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionActionDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionActionDto.java new file mode 100644 index 0000000..13bebe7 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionActionDto.java @@ -0,0 +1,4 @@ +package com.magistr.app.dto; + +public record ScheduleVersionActionDto(String reason) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionCreateDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionCreateDto.java new file mode 100644 index 0000000..5e6531b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionCreateDto.java @@ -0,0 +1,8 @@ +package com.magistr.app.dto; + +public record ScheduleVersionCreateDto( + Long semesterId, + String name, + Long basedOnVersionId +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDiffDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDiffDto.java new file mode 100644 index 0000000..bc782ef --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDiffDto.java @@ -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 ruleChanges +) { + public record RuleChange( + String type, + Long versionGroupId, + Long beforeRuleId, + Long afterRuleId, + String subjectName, + List groupNames, + String beforeSummary, + String afterSummary + ) { + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDto.java new file mode 100644 index 0000000..930062a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionDto.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionHistoryDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionHistoryDto.java new file mode 100644 index 0000000..4e8fc14 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionHistoryDto.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleVersionValidationDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionValidationDto.java new file mode 100644 index 0000000..741ae06 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleVersionValidationDto.java @@ -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 issues +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index 95d49b8..0769583 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -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; } diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleVersion.java b/backend/src/main/java/com/magistr/app/model/ScheduleVersion.java new file mode 100644 index 0000000..13eab35 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleVersion.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleVersionHistory.java b/backend/src/main/java/com/magistr/app/model/ScheduleVersionHistory.java new file mode 100644 index 0000000..029308a --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleVersionHistory.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleVersionStatus.java b/backend/src/main/java/com/magistr/app/model/ScheduleVersionStatus.java new file mode 100644 index 0000000..ba7c40e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleVersionStatus.java @@ -0,0 +1,7 @@ +package com.magistr.app.model; + +public enum ScheduleVersionStatus { + DRAFT, + PUBLISHED, + ARCHIVED +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java index 0547467..d09daaf 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleOverrideRepository.java @@ -19,12 +19,14 @@ public interface ScheduleOverrideRepository extends JpaRepository findByLessonDateBetweenWithDetails( @Param("startDate") LocalDate startDate, @@ -38,6 +40,7 @@ public interface ScheduleOverrideRepository extends JpaRepository findAllWithRegistryDetails(); @@ -57,6 +61,7 @@ public interface ScheduleOverrideRepository extends JpaRepository findForRegistry( @@ -81,12 +87,14 @@ public interface ScheduleOverrideRepository extends JpaRepository findAffectingDatesWithDetails(@Param("dates") Collection dates); diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index 6cf7f28..09543ee 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -17,9 +17,11 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByGroupIdAndSemesterId( @@ -41,6 +44,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByTeacherIdAndSemesterId( @@ -65,6 +70,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByGroupIdsAndSemesterIds( @@ -89,6 +96,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' """) List findByTeacherIdAndSemesterIds( @@ -112,6 +121,7 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' + and version.status = 'PUBLISHED' order by r.id desc """) List findAllWithDetails(); @@ -156,12 +167,38 @@ public interface ScheduleRuleRepository extends JpaRepository 'ARCHIVED' + and (:excludeRuleId is null or rule.id <> :excludeRuleId) + order by rule.id + """) + List 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 diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleVersionHistoryRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleVersionHistoryRepository.java new file mode 100644 index 0000000..be5c5d0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleVersionHistoryRepository.java @@ -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 { + + @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 findBySemesterIdWithDetails(@Param("semesterId") Long semesterId); +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleVersionRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleVersionRepository.java new file mode 100644 index 0000000..17666d0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleVersionRepository.java @@ -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 { + + @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 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 findByIdWithDetails(@Param("id") Long id); + + Optional findFirstBySemesterIdAndStatus(Long semesterId, ScheduleVersionStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select version from ScheduleVersion version where version.id = :id") + Optional 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 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); +} diff --git a/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java b/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java index 8efd0d2..508b81d 100644 --- a/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/TeacherPreferenceRepository.java @@ -67,6 +67,20 @@ public interface TeacherPreferenceRepository extends JpaRepository findApprovedForSemester(@Param("semesterId") Long semesterId); + @Query(""" select count(preference) > 0 from TeacherPreference preference diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 8f12ec7..9e07d7d 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -95,6 +95,30 @@ public class ScheduleGeneratorService { return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate); } + public List buildScheduleForVersion(Long versionId, + LocalDate startDate, + LocalDate endDate) { + validateRange(startDate, endDate); + if (versionId == null) { + throw new IllegalArgumentException("Версия расписания обязательна"); + } + List rules = scheduleRuleRepository.findActiveByVersionIdWithDetails(versionId, null); + List 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 semesters = academicDateService.findSemesters(startDate, endDate); + return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters); + } + private List buildScheduleForGroupsInternal(List groups, LocalDate startDate, LocalDate endDate) { @@ -113,6 +137,24 @@ public class ScheduleGeneratorService { .map(StudentGroup::getId) .collect(Collectors.toCollection(LinkedHashSet::new)); List rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds); + return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters); + } + + private List buildScheduleForGroupsInternal(List groups, + LocalDate startDate, + LocalDate endDate, + List rules, + List semesters) { + if (groups.isEmpty()) { + return List.of(); + } + + if (semesters.isEmpty()) { + return List.of(); + } + Set 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); diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java new file mode 100644 index 0000000..b8c95bc --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java @@ -0,0 +1,1044 @@ +package com.magistr.app.service; + +import com.magistr.app.dto.RenderedLessonDto; +import com.magistr.app.dto.ScheduleOverrideDto; +import com.magistr.app.dto.ScheduleQualityAnalysisDto; +import com.magistr.app.dto.ScheduleQualityRecommendationsDto; +import com.magistr.app.model.Classroom; +import com.magistr.app.model.LifecycleEntity; +import com.magistr.app.model.Semester; +import com.magistr.app.model.ScheduleVersion; +import com.magistr.app.model.ScheduleVersionStatus; +import com.magistr.app.model.StudentGroup; +import com.magistr.app.model.Subgroup; +import com.magistr.app.model.TeacherPreference; +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 org.springframework.beans.factory.annotation.Autowired; +import com.magistr.app.repository.SubgroupRepository; +import com.magistr.app.repository.TeacherPreferenceRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ScheduleQualityService { + + private static final Logger log = LoggerFactory.getLogger(ScheduleQualityService.class); + private static final int MAX_RANGE_DAYS = 120; + private static final int MAX_DAILY_LESSONS = 4; + private static final int MAX_RETURNED_PROBLEMS = 300; + private static final int MAX_RECOMMENDATIONS = 10; + + private final ScheduleQueryService scheduleQueryService; + private final SemesterRepository semesterRepository; + private final GroupRepository groupRepository; + private final SubgroupRepository subgroupRepository; + private final ClassroomRepository classroomRepository; + private final TeacherPreferenceRepository preferenceRepository; + private final EffectiveTimeSlotService effectiveTimeSlotService; + private final ScheduleOverrideService scheduleOverrideService; + private ScheduleVersionRepository scheduleVersionRepository; + private ScheduleGeneratorService scheduleGeneratorService; + + public ScheduleQualityService( + ScheduleQueryService scheduleQueryService, + SemesterRepository semesterRepository, + GroupRepository groupRepository, + SubgroupRepository subgroupRepository, + ClassroomRepository classroomRepository, + TeacherPreferenceRepository preferenceRepository, + EffectiveTimeSlotService effectiveTimeSlotService, + ScheduleOverrideService scheduleOverrideService + ) { + this.scheduleQueryService = scheduleQueryService; + this.semesterRepository = semesterRepository; + this.groupRepository = groupRepository; + this.subgroupRepository = subgroupRepository; + this.classroomRepository = classroomRepository; + this.preferenceRepository = preferenceRepository; + this.effectiveTimeSlotService = effectiveTimeSlotService; + this.scheduleOverrideService = scheduleOverrideService; + } + + @Autowired(required = false) + void setVersionDependencies(ScheduleVersionRepository scheduleVersionRepository, + ScheduleGeneratorService scheduleGeneratorService) { + this.scheduleVersionRepository = scheduleVersionRepository; + this.scheduleGeneratorService = scheduleGeneratorService; + } + + @Transactional(readOnly = true) + public ScheduleQualityAnalysisDto analyze(Long semesterId) { + return analyze(semesterId, null); + } + + @Transactional(readOnly = true) + public ScheduleQualityAnalysisDto analyze(Long semesterId, Long scheduleVersionId) { + QualityContext context = loadContext(semesterId, scheduleVersionId); + Evaluation evaluation = evaluate(context, context.lessons()); + log.info("Анализ качества семестра {}, версия {} завершён: {} занятий, оценка {}, проблем {}", + semesterId, + context.version() == null ? "опубликованная" : context.version().getId(), + context.lessons().size(), evaluation.score(), evaluation.problems().size()); + return toDto(context.semester(), context.version(), evaluation); + } + + @Transactional(readOnly = true) + public ScheduleQualityRecommendationsDto recommendations( + Long semesterId, + Long scheduleRuleSlotId, + LocalDate lessonDate + ) { + return recommendations(semesterId, null, scheduleRuleSlotId, lessonDate); + } + + @Transactional(readOnly = true) + public ScheduleQualityRecommendationsDto recommendations( + Long semesterId, + Long scheduleVersionId, + Long scheduleRuleSlotId, + LocalDate lessonDate + ) { + if (scheduleRuleSlotId == null || lessonDate == null) { + throw new IllegalArgumentException("Для рекомендаций выберите занятие и его дату"); + } + QualityContext context = loadContext(semesterId, scheduleVersionId); + if (context.version() != null && context.version().getStatus() != ScheduleVersionStatus.PUBLISHED) { + throw new IllegalArgumentException( + "Для неопубликованной версии доступна только проверка качества; изменения внесите в правила черновика" + ); + } + List sourceRows = context.lessons().stream() + .filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), scheduleRuleSlotId)) + .filter(lesson -> Objects.equals(lesson.date(), lessonDate)) + .toList(); + if (sourceRows.isEmpty()) { + throw new NoSuchElementException("Занятие не найдено в выбранном семестре"); + } + if (sourceRows.stream().anyMatch(lesson -> lesson.scheduleOverrideId() != null)) { + throw new IllegalArgumentException( + "Для занятия уже действует ручное точечное изменение; анализатор не меняет закреплённые решения" + ); + } + + RenderedLessonDto source = sourceRows.get(0); + Evaluation baseline = evaluate(context, context.lessons()); + List candidates = new ArrayList<>(); + addTimeCandidates(context, source, baseline, candidates); + addClassroomCandidates(context, sourceRows, source, baseline, candidates); + + List ranked = candidates.stream() + .sorted(Comparator + .comparingInt(ScheduleQualityRecommendationsDto.Candidate::penaltyDelta).reversed() + .thenComparing( + ScheduleQualityRecommendationsDto.Candidate::scoreDelta, + Comparator.reverseOrder() + ) + .thenComparing(ScheduleQualityRecommendationsDto.Candidate::action) + .thenComparing(ScheduleQualityRecommendationsDto.Candidate::label)) + .limit(MAX_RECOMMENDATIONS) + .toList(); + return new ScheduleQualityRecommendationsDto( + scheduleRuleSlotId, + lessonDate, + source.subjectName(), + baseline.score(), + ranked + ); + } + + private QualityContext loadContext(Long semesterId, Long scheduleVersionId) { + if (semesterId == null) { + throw new IllegalArgumentException("Семестр для анализа обязателен"); + } + Semester semester = semesterRepository.findById(semesterId) + .orElseThrow(() -> new NoSuchElementException("Семестр не найден")); + ScheduleVersion version = resolveVersion(semesterId, scheduleVersionId); + List lessons = version == null || version.getStatus() == ScheduleVersionStatus.PUBLISHED + ? loadSemesterLessons(semester) + : loadVersionLessons(semester, version.getId()); + Map groups = groupRepository.findAll().stream() + .collect(Collectors.toMap(StudentGroup::getId, Function.identity())); + Map subgroups = subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream() + .collect(Collectors.toMap(Subgroup::getId, Function.identity())); + Map classrooms = classroomRepository.findAll().stream() + .collect(Collectors.toMap(Classroom::getId, Function.identity())); + Map> preferencesByTeacher = preferenceRepository + .findApprovedForSemester(semesterId).stream() + .collect(Collectors.groupingBy( + preference -> preference.getTeacher().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + return new QualityContext( + semester, + lessons, + groups, + subgroups, + classrooms, + preferencesByTeacher, + version + ); + } + + private ScheduleVersion resolveVersion(Long semesterId, Long scheduleVersionId) { + if (scheduleVersionRepository == null) { + if (scheduleVersionId != null) { + throw new IllegalStateException("Хранилище версий расписания недоступно"); + } + return null; + } + ScheduleVersion version = scheduleVersionId == null + ? scheduleVersionRepository + .findFirstBySemesterIdAndStatus(semesterId, ScheduleVersionStatus.PUBLISHED) + .orElse(null) + : scheduleVersionRepository.findByIdWithDetails(scheduleVersionId) + .orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена")); + if (version != null && !Objects.equals(version.getSemester().getId(), semesterId)) { + throw new IllegalArgumentException("Версия относится к другому семестру"); + } + return version; + } + + private List loadSemesterLessons(Semester semester) { + List lessons = new ArrayList<>(); + LocalDate chunkStart = semester.getStartDate(); + while (!chunkStart.isAfter(semester.getEndDate())) { + LocalDate chunkEnd = chunkStart.plusDays(MAX_RANGE_DAYS - 1L); + if (chunkEnd.isAfter(semester.getEndDate())) { + chunkEnd = semester.getEndDate(); + } + lessons.addAll(scheduleQueryService.searchForAggregation(null, null, chunkStart, chunkEnd)); + chunkStart = chunkEnd.plusDays(1); + } + return lessons.stream() + .filter(lesson -> lesson.date() != null) + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List loadVersionLessons(Semester semester, Long versionId) { + if (scheduleGeneratorService == null) { + throw new IllegalStateException("Генератор версий расписания недоступен"); + } + List lessons = new ArrayList<>(); + LocalDate chunkStart = semester.getStartDate(); + while (!chunkStart.isAfter(semester.getEndDate())) { + LocalDate chunkEnd = chunkStart.plusDays(MAX_RANGE_DAYS - 1L); + if (chunkEnd.isAfter(semester.getEndDate())) { + chunkEnd = semester.getEndDate(); + } + lessons.addAll(scheduleGeneratorService.buildScheduleForVersion(versionId, chunkStart, chunkEnd)); + chunkStart = chunkEnd.plusDays(1); + } + return lessons.stream() + .filter(lesson -> lesson.date() != null) + .collect(Collectors.toMap( + lesson -> List.of( + lesson.scheduleRuleSlotId(), + lesson.date(), + safeIds(lesson.groupIds()), + safeIds(lesson.subgroupIds()) + ).toString(), + Function.identity(), + (first, ignored) -> first, + LinkedHashMap::new + )) + .values().stream() + .sorted(Comparator + .comparing(RenderedLessonDto::date) + .thenComparing(RenderedLessonDto::timeSlotOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(RenderedLessonDto::subjectName, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private Evaluation evaluate(QualityContext context, List lessons) { + Accumulator accumulator = new Accumulator(); + Map> groupDays = indexEntityDays( + lessons, + lesson -> safeIds(lesson.groupIds()) + ); + Map> teacherDays = indexEntityDays( + lessons, + lesson -> lesson.teacherId() == null ? List.of() : List.of(lesson.teacherId()) + ); + + addDailyProblems(context, groupDays, true, accumulator); + addDailyProblems(context, teacherDays, false, accumulator); + addCapacityProblems(context, lessons, accumulator); + addPreferenceProblems(context, lessons, accumulator); + + int rawPenalty = accumulator.metricPenalties.values().stream().mapToInt(Integer::intValue).sum(); + int lessonCount = lessons.size(); + int score = Math.max(0, 100 - (int) Math.round( + rawPenalty * 100.0 / Math.max(lessonCount * 3, 1) + )); + accumulator.metricValues.put("DISTRIBUTION_BALANCE", distributionBalance(groupDays)); + + List metrics = List.of( + metric(accumulator, "GROUP_GAPS", "Окна у групп", "окон", + "Свободные пары между занятиями одной группы в течение дня"), + metric(accumulator, "TEACHER_GAPS", "Окна у преподавателей", "окон", + "Свободные пары между занятиями преподавателя; пожелание «без окон» усиливает штраф"), + metric(accumulator, "OVERLOADED_DAYS", "Перегруженные дни", "дней", + "Дни, в которых у группы или преподавателя больше четырёх пар"), + metric(accumulator, "ROOM_CAPACITY", "Проблемы вместимости", "занятий", + "Нехватка мест и аудитории с загрузкой менее 50% при запасе от 20 мест"), + metric(accumulator, "PREFERENCE_VIOLATIONS", "Нарушения пожеланий", "занятий", + "Строгая недоступность, нежелательные и пропущенные предпочтительные интервалы"), + metric(accumulator, "DISTRIBUTION_BALANCE", "Равномерность нагрузки", "%", 0, + "Насколько близко число пар в разные учебные дни каждой группы") + ); + + List problems = accumulator.problems.stream() + .sorted(problemComparator()) + .toList(); + return new Evaluation(score, rawPenalty, lessonCount, metrics, problems); + } + + private Map> indexEntityDays( + List lessons, + Function> entityIds + ) { + Map> result = new LinkedHashMap<>(); + for (RenderedLessonDto lesson : lessons) { + for (Long entityId : new LinkedHashSet<>(entityIds.apply(lesson))) { + if (entityId == null) continue; + result.computeIfAbsent(new EntityDayKey(entityId, lesson.date()), ignored -> new ArrayList<>()) + .add(lesson); + } + } + return result; + } + + private void addDailyProblems( + QualityContext context, + Map> days, + boolean group, + Accumulator accumulator + ) { + for (Map.Entry> entry : days.entrySet()) { + EntityDayKey key = entry.getKey(); + List uniqueLessons = uniqueLessonsByOrder(entry.getValue()); + List orders = uniqueLessons.stream() + .map(RenderedLessonDto::timeSlotOrder) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + if (orders.isEmpty()) continue; + + String entityName = group + ? context.groups().getOrDefault(key.entityId(), emptyGroup(key.entityId())).getName() + : uniqueLessons.stream().map(RenderedLessonDto::teacherName).filter(Objects::nonNull) + .findFirst().orElse("Преподаватель " + key.entityId()); + boolean compactPreference = !group && context.preferencesByTeacher() + .getOrDefault(key.entityId(), List.of()).stream() + .anyMatch(preference -> Set.of("NO_GAPS", "CONSECUTIVE") + .contains(preference.getPreferenceType())); + + for (int index = 1; index < orders.size(); index++) { + int gapCount = orders.get(index) - orders.get(index - 1) - 1; + if (gapCount <= 0) continue; + RenderedLessonDto representative = lessonAtOrder(uniqueLessons, orders.get(index)); + int penalty = gapCount * (group ? 2 : compactPreference ? 3 : 1); + String metricCode = group ? "GROUP_GAPS" : "TEACHER_GAPS"; + accumulator.increment(metricCode, gapCount, penalty); + if (compactPreference) { + accumulator.increment("PREFERENCE_VIOLATIONS", gapCount, 0); + } + accumulator.problems.add(problem( + group ? "GROUP_GAP" : compactPreference ? "TEACHER_COMPACTNESS" : "TEACHER_GAP", + penalty >= 4 ? "HIGH" : "MEDIUM", + group ? "Окно в расписании группы" : "Окно в расписании преподавателя", + entityName + ": между " + orders.get(index - 1) + " и " + orders.get(index) + + " парой свободно " + gapCount + " " + + plural(gapCount, "слот", "слота", "слотов"), + penalty, + representative, + key.entityId(), + entityName, + null, + null + )); + } + + int overload = Math.max(0, orders.size() - MAX_DAILY_LESSONS); + if (overload > 0) { + RenderedLessonDto representative = uniqueLessons.get(uniqueLessons.size() - 1); + int penalty = overload * 3; + accumulator.increment("OVERLOADED_DAYS", 1, penalty); + accumulator.problems.add(problem( + group ? "GROUP_DAILY_OVERLOAD" : "TEACHER_DAILY_OVERLOAD", + overload >= 2 ? "HIGH" : "MEDIUM", + group ? "Перегруженный день группы" : "Перегруженный день преподавателя", + entityName + ": " + orders.size() + " пар за " + formatDate(key.date()) + + ", рекомендуемый предел — " + MAX_DAILY_LESSONS, + penalty, + representative, + key.entityId(), + entityName, + null, + null + )); + } + } + } + + private void addCapacityProblems( + QualityContext context, + List lessons, + Accumulator accumulator + ) { + Map> occurrences = lessons.stream() + .filter(lesson -> lesson.classroomId() != null) + .collect(Collectors.groupingBy( + lesson -> new OccurrenceKey( + lesson.date(), + lesson.scheduleRuleSlotId(), + lesson.classroomId(), + lesson.timeSlotOrder() + ), + LinkedHashMap::new, + Collectors.toList() + )); + for (List rows : occurrences.values()) { + RenderedLessonDto lesson = rows.get(0); + Classroom classroom = context.classrooms().get(lesson.classroomId()); + if (classroom == null || classroom.getCapacity() == null) continue; + int required = requiredCapacity(context, rows); + int actual = classroom.getCapacity(); + if (required > actual) { + int missing = required - actual; + int penalty = 8 + (int) Math.ceil(missing / 10.0); + accumulator.increment("ROOM_CAPACITY", 1, penalty); + accumulator.problems.add(problem( + "ROOM_CAPACITY_SHORTAGE", + "CRITICAL", + "Аудитория меньше состава занятия", + classroom.getName() + ": требуется " + required + " мест, доступно " + actual + + " (не хватает " + missing + ")", + penalty, + lesson, + classroom.getId(), + classroom.getName(), + required, + actual + )); + } else if (required > 0 && actual >= required * 2 && actual - required >= 20) { + int penalty = 1; + accumulator.increment("ROOM_CAPACITY", 1, penalty); + accumulator.problems.add(problem( + "ROOM_OVERSIZED", + "LOW", + "Избыточно крупная аудитория", + classroom.getName() + ": занято около " + required + " из " + actual + + " мест; можно освободить более подходящую аудиторию", + penalty, + lesson, + classroom.getId(), + classroom.getName(), + required, + actual + )); + } + } + } + + private void addPreferenceProblems( + QualityContext context, + List lessons, + Accumulator accumulator + ) { + Map uniqueLessons = lessons.stream() + .filter(lesson -> lesson.teacherId() != null) + .collect(Collectors.toMap( + lesson -> lesson.teacherId() + ":" + lesson.date() + ":" + lesson.scheduleRuleSlotId(), + Function.identity(), + (first, second) -> first, + LinkedHashMap::new + )); + for (RenderedLessonDto lesson : uniqueLessons.values()) { + List preferences = context.preferencesByTeacher() + .getOrDefault(lesson.teacherId(), List.of()); + boolean hard = preferences.stream() + .filter(preference -> "HARD_UNAVAILABLE".equals(preference.getPreferenceType())) + .anyMatch(preference -> matchesPreference(preference, lesson)); + boolean unwanted = preferences.stream() + .filter(preference -> "SOFT_UNWANTED".equals(preference.getPreferenceType())) + .anyMatch(preference -> matchesPreference(preference, lesson)); + List preferredForDay = preferences.stream() + .filter(preference -> "SOFT_PREFERRED".equals(preference.getPreferenceType())) + .filter(preference -> Objects.equals(preference.getDayOfWeek(), lesson.dayOfWeek())) + .toList(); + boolean missesPreferred = !preferredForDay.isEmpty() + && preferredForDay.stream().noneMatch(preference -> matchesPreference(preference, lesson)); + + if (hard) { + addPreferenceProblem(accumulator, lesson, "TEACHER_HARD_UNAVAILABLE", "CRITICAL", + "Занятие нарушает строгую недоступность", + lesson.teacherName() + " недоступен " + formatDate(lesson.date()) + + " на " + lesson.timeSlotOrder() + " паре", + 10); + } else if (unwanted) { + addPreferenceProblem(accumulator, lesson, "TEACHER_SOFT_UNWANTED", "MEDIUM", + "Занятие попало в нежелательное время", + lesson.teacherName() + " отметил " + lesson.dayName().toLowerCase(Locale.ROOT) + + ", " + lesson.timeSlotOrder() + " пару как нежелательную", + 2); + } else if (missesPreferred) { + addPreferenceProblem(accumulator, lesson, "TEACHER_PREFERRED_MISSED", "LOW", + "Не использовано предпочтительное время", + lesson.teacherName() + " указал другое предпочтительное время на этот день недели", + 1); + } + } + } + + private void addPreferenceProblem( + Accumulator accumulator, + RenderedLessonDto lesson, + String type, + String severity, + String title, + String description, + int penalty + ) { + accumulator.increment("PREFERENCE_VIOLATIONS", 1, penalty); + accumulator.problems.add(problem( + type, + severity, + title, + description, + penalty, + lesson, + lesson.teacherId(), + lesson.teacherName(), + null, + null + )); + } + + private boolean matchesPreference(TeacherPreference preference, RenderedLessonDto lesson) { + if (preference.getPreferenceDate() != null) { + return Objects.equals(preference.getPreferenceDate(), lesson.date()); + } + return Objects.equals(preference.getDayOfWeek(), lesson.dayOfWeek()) + && preference.getTimeSlot() != null + && Objects.equals(preference.getTimeSlot().getOrderNumber(), lesson.timeSlotOrder()); + } + + private void addTimeCandidates( + QualityContext context, + RenderedLessonDto source, + Evaluation baseline, + List candidates + ) { + for (TimeSlot slot : effectiveTimeSlotService.findEffective(source.date())) { + if (Objects.equals(slot.getId(), source.timeSlotId())) continue; + ScheduleOverrideDto override = overrideRequest( + source, + "MOVE", + slot.getId(), + null, + "Рекомендация анализатора качества: перенос на " + slot.getOrderNumber() + " пару" + ); + if (!validCandidate(override)) continue; + List changed = replaceLesson( + context.lessons(), + source.scheduleRuleSlotId(), + source.date(), + lesson -> copyLesson(lesson, slot, null) + ); + Evaluation result = evaluate(context, changed); + candidates.add(toCandidate( + "TIME:" + slot.getId(), + "MOVE", + slot.getOrderNumber() + " пара · " + slot.getStartTime() + "–" + slot.getEndTime(), + baseline, + result, + override + )); + } + } + + private void addClassroomCandidates( + QualityContext context, + List sourceRows, + RenderedLessonDto source, + Evaluation baseline, + List candidates + ) { + int requiredCapacity = requiredCapacity(context, sourceRows); + List classrooms = context.classrooms().values().stream() + .filter(classroom -> !Objects.equals(classroom.getId(), source.classroomId())) + .filter(classroom -> !LifecycleEntity.STATUS_ARCHIVED.equals(classroom.getStatus())) + .filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable())) + .filter(classroom -> classroom.isActiveOn(source.date())) + .filter(classroom -> classroom.getCapacity() != null && classroom.getCapacity() >= requiredCapacity) + .sorted(Comparator + .comparingInt((Classroom classroom) -> classroom.getCapacity() - requiredCapacity) + .thenComparing(Classroom::getName)) + .limit(12) + .toList(); + for (Classroom classroom : classrooms) { + ScheduleOverrideDto override = overrideRequest( + source, + "REPLACE", + null, + classroom.getId(), + "Рекомендация анализатора качества: аудитория " + classroom.getName() + ); + if (!validCandidate(override)) continue; + List changed = replaceLesson( + context.lessons(), + source.scheduleRuleSlotId(), + source.date(), + lesson -> copyLesson(lesson, null, classroom) + ); + Evaluation result = evaluate(context, changed); + candidates.add(toCandidate( + "ROOM:" + classroom.getId(), + "REPLACE", + classroom.getName() + " · " + classroom.getCapacity() + " мест", + baseline, + result, + override + )); + } + } + + private boolean validCandidate(ScheduleOverrideDto override) { + try { + scheduleOverrideService.validateCandidate(override); + return true; + } catch (RuntimeException exception) { + return false; + } + } + + private ScheduleQualityRecommendationsDto.Candidate toCandidate( + String id, + String action, + String label, + Evaluation baseline, + Evaluation result, + ScheduleOverrideDto override + ) { + int penaltyDelta = baseline.rawPenalty() - result.rawPenalty(); + int scoreDelta = result.score() - baseline.score(); + MetricDifference difference = metricDifference(baseline.metrics(), result.metrics()); + String description; + if (penaltyDelta > 0) { + description = "Снижает суммарный штраф на " + penaltyDelta; + } else if (penaltyDelta < 0) { + description = "Увеличивает суммарный штраф на " + Math.abs(penaltyDelta); + } else { + description = "Допустимый вариант без изменения суммарного штрафа"; + } + return new ScheduleQualityRecommendationsDto.Candidate( + id, + action, + label, + description, + scoreDelta, + penaltyDelta, + result.score(), + difference.improvements(), + difference.tradeoffs(), + override + ); + } + + private MetricDifference metricDifference( + List baseline, + List result + ) { + Map resultByCode = result.stream() + .collect(Collectors.toMap(ScheduleQualityAnalysisDto.Metric::code, Function.identity())); + List improvements = new ArrayList<>(); + List tradeoffs = new ArrayList<>(); + for (ScheduleQualityAnalysisDto.Metric before : baseline) { + ScheduleQualityAnalysisDto.Metric after = resultByCode.get(before.code()); + if (after == null || Double.compare(before.value(), after.value()) == 0) continue; + boolean higherIsBetter = "DISTRIBUTION_BALANCE".equals(before.code()); + boolean improved = higherIsBetter ? after.value() > before.value() : after.value() < before.value(); + double delta = Math.abs(after.value() - before.value()); + String text = before.label() + ": " + (improved ? "лучше на " : "хуже на ") + + formatNumber(delta) + (before.unit().isBlank() ? "" : " " + before.unit()); + (improved ? improvements : tradeoffs).add(text); + } + return new MetricDifference(improvements, tradeoffs); + } + + private List replaceLesson( + List lessons, + Long scheduleRuleSlotId, + LocalDate lessonDate, + Function replacement + ) { + return lessons.stream() + .map(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), scheduleRuleSlotId) + && Objects.equals(lesson.date(), lessonDate) + ? replacement.apply(lesson) + : lesson) + .toList(); + } + + private RenderedLessonDto copyLesson(RenderedLessonDto source, TimeSlot slot, Classroom classroom) { + return new RenderedLessonDto( + source.scheduleRuleId(), + source.scheduleRuleSlotId(), + source.date(), + source.dayOfWeek(), + source.dayName(), + source.weekNumber(), + source.parity(), + slot == null ? source.timeSlotId() : slot.getId(), + slot == null ? source.timeSlotOrder() : slot.getOrderNumber(), + slot == null ? source.startTime() : slot.getStartTime(), + slot == null ? source.endTime() : slot.getEndTime(), + source.subjectId(), + source.subjectName(), + source.teacherId(), + source.teacherName(), + classroom == null ? source.classroomId() : classroom.getId(), + classroom == null ? source.classroomName() : classroom.getName(), + source.lessonTypeId(), + source.lessonTypeName(), + source.lessonFormat(), + source.subgroupId(), + source.subgroupName(), + source.subgroupIds(), + source.subgroupNames(), + source.groupIds(), + source.groupNames(), + source.activityType(), + source.lessonTypeAcademicHours(), + source.consumedLessonTypeAcademicHoursBeforeLesson(), + source.remainingLessonTypeAcademicHoursAfterLesson(), + source.ruleParity(), + source.scheduleOverrideId(), + source.overrideAction(), + source.originalLessonDate() + ); + } + + private ScheduleOverrideDto overrideRequest( + RenderedLessonDto source, + String action, + Long timeSlotId, + Long classroomId, + String comment + ) { + return new ScheduleOverrideDto( + null, + source.scheduleRuleSlotId(), + source.date(), + null, + action, + timeSlotId, + classroomId, + null, + null, + comment, + null, + null + ); + } + + private int requiredCapacity(QualityContext context, List rows) { + Set groupIds = rows.stream() + .flatMap(lesson -> safeIds(lesson.groupIds()).stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set subgroupIds = rows.stream() + .flatMap(lesson -> safeIds(lesson.subgroupIds()).stream()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set groupsCoveredBySubgroups = new LinkedHashSet<>(); + int subgroupCapacity = 0; + for (Long subgroupId : subgroupIds) { + Subgroup subgroup = context.subgroups().get(subgroupId); + if (subgroup == null) continue; + subgroupCapacity += Math.max(0, subgroup.getStudentCapacity()); + groupsCoveredBySubgroups.add(subgroup.getStudentGroup().getId()); + } + int groupCapacity = groupIds.stream() + .filter(groupId -> !groupsCoveredBySubgroups.contains(groupId)) + .map(context.groups()::get) + .filter(Objects::nonNull) + .map(StudentGroup::getGroupSize) + .filter(Objects::nonNull) + .mapToInt(value -> Math.toIntExact(Math.max(0L, value))) + .sum(); + return subgroupCapacity + groupCapacity; + } + + private ScheduleQualityAnalysisDto toDto(Semester semester, + ScheduleVersion version, + Evaluation evaluation) { + List returnedProblems = evaluation.problems().stream() + .limit(MAX_RETURNED_PROBLEMS) + .toList(); + return new ScheduleQualityAnalysisDto( + semester.getId(), + semesterLabel(semester), + semester.getStartDate(), + semester.getEndDate(), + evaluation.score(), + evaluation.rawPenalty(), + evaluation.lessonCount(), + evaluation.problems().size(), + evaluation.problems().size() > MAX_RETURNED_PROBLEMS, + evaluation.metrics(), + returnedProblems, + version == null ? null : version.getId(), + version == null ? null : version.getVersionNumber(), + version == null ? null : version.getName(), + version == null ? null : version.getStatus() + ); + } + + private ScheduleQualityAnalysisDto.Metric metric( + Accumulator accumulator, + String code, + String label, + String unit, + String explanation + ) { + return metric( + accumulator, + code, + label, + unit, + accumulator.metricPenalties.getOrDefault(code, 0), + explanation + ); + } + + private ScheduleQualityAnalysisDto.Metric metric( + Accumulator accumulator, + String code, + String label, + String unit, + int penalty, + String explanation + ) { + return new ScheduleQualityAnalysisDto.Metric( + code, + label, + accumulator.metricValues.getOrDefault(code, 0.0), + unit, + penalty, + explanation + ); + } + + private ScheduleQualityAnalysisDto.Problem problem( + String type, + String severity, + String title, + String description, + int penalty, + RenderedLessonDto lesson, + Long entityId, + String entityName, + Integer requiredCapacity, + Integer classroomCapacity + ) { + String id = String.join(":", + type, + String.valueOf(lesson.date()), + String.valueOf(lesson.scheduleRuleSlotId()), + String.valueOf(entityId) + ); + return new ScheduleQualityAnalysisDto.Problem( + id, + type, + severity, + title, + description, + penalty, + lesson.scheduleRuleSlotId(), + lesson.date(), + lesson.timeSlotOrder(), + lesson.subjectName(), + entityId, + entityName, + requiredCapacity, + classroomCapacity, + lesson.scheduleRuleSlotId() != null && lesson.scheduleOverrideId() == null + ); + } + + private Comparator problemComparator() { + Map severityOrder = Map.of( + "CRITICAL", 0, + "HIGH", 1, + "MEDIUM", 2, + "LOW", 3 + ); + return Comparator + .comparingInt((ScheduleQualityAnalysisDto.Problem problem) -> + severityOrder.getOrDefault(problem.severity(), 9)) + .thenComparing( + ScheduleQualityAnalysisDto.Problem::lessonDate, + Comparator.nullsLast(LocalDate::compareTo) + ) + .thenComparing(ScheduleQualityAnalysisDto.Problem::title); + } + + private List uniqueLessonsByOrder(List lessons) { + return lessons.stream() + .filter(lesson -> lesson.timeSlotOrder() != null) + .collect(Collectors.toMap( + RenderedLessonDto::timeSlotOrder, + Function.identity(), + (first, second) -> first, + LinkedHashMap::new + )) + .values().stream() + .sorted(Comparator.comparing(RenderedLessonDto::timeSlotOrder)) + .toList(); + } + + private double distributionBalance(Map> groupDays) { + Map> dailyLoadsByGroup = groupDays.entrySet().stream() + .collect(Collectors.groupingBy( + entry -> entry.getKey().entityId(), + LinkedHashMap::new, + Collectors.mapping( + entry -> uniqueLessonsByOrder(entry.getValue()).size(), + Collectors.toList() + ) + )); + if (dailyLoadsByGroup.isEmpty()) return 100.0; + + double averageBalance = dailyLoadsByGroup.values().stream() + .mapToDouble(loads -> { + if (loads.size() < 2) return 100.0; + double average = loads.stream().mapToInt(Integer::intValue).average().orElse(0.0); + if (average <= 0.0) return 100.0; + double meanAbsoluteDeviation = loads.stream() + .mapToDouble(load -> Math.abs(load - average)) + .average() + .orElse(0.0); + return Math.max(0.0, 100.0 - meanAbsoluteDeviation * 100.0 / average); + }) + .average() + .orElse(100.0); + return roundOne(averageBalance); + } + + private RenderedLessonDto lessonAtOrder(List lessons, int order) { + return lessons.stream() + .filter(lesson -> Objects.equals(lesson.timeSlotOrder(), order)) + .findFirst() + .orElse(lessons.get(lessons.size() - 1)); + } + + private List safeIds(List ids) { + return ids == null ? List.of() : ids.stream().filter(Objects::nonNull).toList(); + } + + private StudentGroup emptyGroup(Long id) { + StudentGroup group = new StudentGroup(); + group.setId(id); + group.setName("Группа " + id); + return group; + } + + private String semesterLabel(Semester semester) { + String type = switch (semester.getSemesterType()) { + case autumn -> "осенний семестр"; + case spring -> "весенний семестр"; + }; + return semester.getAcademicYear().getTitle() + " · " + type; + } + + private String formatDate(LocalDate date) { + return String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear()); + } + + private String plural(int value, String one, String few, String many) { + int mod100 = value % 100; + int mod10 = value % 10; + if (mod100 >= 11 && mod100 <= 14) return many; + if (mod10 == 1) return one; + if (mod10 >= 2 && mod10 <= 4) return few; + return many; + } + + private String formatNumber(double value) { + return value == Math.rint(value) + ? String.valueOf((long) value) + : String.format(Locale.ROOT, "%.1f", value); + } + + private double roundOne(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private record QualityContext( + Semester semester, + List lessons, + Map groups, + Map subgroups, + Map classrooms, + Map> preferencesByTeacher, + ScheduleVersion version + ) { + } + + private record Evaluation( + int score, + int rawPenalty, + int lessonCount, + List metrics, + List problems + ) { + } + + private record EntityDayKey(Long entityId, LocalDate date) { + } + + private record OccurrenceKey(LocalDate date, Long ruleSlotId, Long classroomId, Integer timeSlotOrder) { + } + + private record MetricDifference(List improvements, List tradeoffs) { + } + + private static final class Accumulator { + private final Map metricValues = new HashMap<>(); + private final Map metricPenalties = new HashMap<>(); + private final List problems = new ArrayList<>(); + + void increment(String code, double value, int penalty) { + metricValues.merge(code, value, Double::sum); + metricPenalties.merge(code, penalty, Integer::sum); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java index 965a09f..8646df4 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java @@ -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 getAll(Long semesterId, Long groupId) { - return scheduleRuleRepository.findAllWithDetails().stream() + return getAll(semesterId, groupId, null); + } + + @Transactional(readOnly = true) + public List getAll(Long semesterId, Long groupId, Long scheduleVersionId) { + List 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 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> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots()); - ScheduleRuleConflict conflict = scheduleRuleRepository - .findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId) + List 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 validateVersion(Long scheduleVersionId) { + if (scheduleVersionId == null) { + return List.of("Версия расписания не выбрана"); + } + List rules = scheduleRuleRepository + .findActiveByVersionIdWithDetails(scheduleVersionId, null); + if (rules.isEmpty()) { + return List.of("В черновике нет ни одного правила расписания"); + } + LinkedHashSet 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 sortedSubgroups = sortedSubgroups(slot); diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleVersionService.java b/backend/src/main/java/com/magistr/app/service/ScheduleVersionService.java new file mode 100644 index 0000000..5ff321e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/ScheduleVersionService.java @@ -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 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 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 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 rules = activeRules(versionId); + List 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 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 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 beforeRules = activeRules(base.getId()); + List afterRules = activeRules(target.getId()); + + Map beforeByGroup = indexRulesByVersionGroup(beforeRules); + Map afterByGroup = indexRulesByVersionGroup(afterRules); + LinkedHashSet allGroups = new LinkedHashSet<>(); + allGroups.addAll(beforeByGroup.keySet()); + allGroups.addAll(afterByGroup.keySet()); + + int added = 0; + int removed = 0; + int changed = 0; + int unchanged = 0; + List 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 sourceRules = activeRules(base.getId()); + List 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 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 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 beforeRules, + List afterRules) { + List beforeLessons = loadVersionLessons(base); + List afterLessons = loadVersionLessons(target); + Map beforeGroups = beforeRules.stream().collect(Collectors.toMap( + ScheduleRule::getId, + rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId() + )); + Map afterGroups = afterRules.stream().collect(Collectors.toMap( + ScheduleRule::getId, + rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId() + )); + Map> before = indexLessons(beforeLessons, beforeGroups); + Map> after = indexLessons(afterLessons, afterGroups); + LinkedHashSet 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 oldValues = new ArrayList<>(before.getOrDefault(key, List.of())); + List 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> indexLessons(List lessons, + Map versionGroups) { + Map unique = lessons.stream().collect(Collectors.toMap( + this::renderedLessonIdentity, + Function.identity(), + (first, ignored) -> first, + LinkedHashMap::new + )); + Map> 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 loadVersionLessons(ScheduleVersion version) { + List 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 indexRulesByVersionGroup(List 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 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 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 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 sortedIds(List ids) { + return ids == null ? List.of() : ids.stream().filter(Objects::nonNull).sorted().toList(); + } + + private record LessonIdentity( + Long versionGroupId, + LocalDate date, + Long lessonTypeId, + List groupIds, + List subgroupIds + ) { + } + + private record LessonDifference(int added, int removed, int changed) { + } +} diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 15f9194..4654cd3 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 'Неизменяемый журнал создания, публикации, архивации и восстановления версий'; diff --git a/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql b/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql deleted file mode 100644 index 7e4333b..0000000 --- a/backend/src/main/resources/db/migration/V2__teacher_absences_and_replacement_wizard.sql +++ /dev/null @@ -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 'Выбранный способ разрешения затронутого занятия'; diff --git a/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql b/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql deleted file mode 100644 index 7e48e2f..0000000 --- a/backend/src/main/resources/db/migration/V3__teacher_preferences_and_change_requests.sql +++ /dev/null @@ -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 'Неизменяемая история статусов и комментариев по заявке преподавателя'; diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleQualityControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleQualityControllerTest.java new file mode 100644 index 0000000..d336524 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleQualityControllerTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java index 600e772..d7ee900 100644 --- a/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java @@ -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, diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java index 7270392..a17beba 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java @@ -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) { } } diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java index 1506912..b2f023d 100644 --- a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java @@ -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 { diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java index ead5bfe..42f7d1a 100644 --- a/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java @@ -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"); diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java new file mode 100644 index 0000000..e4a9142 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java @@ -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 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 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 classrooms, + List 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 + ); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java index 806550a..65554a3 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java @@ -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, diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java index 72b1371..d49ecfa 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java @@ -432,7 +432,8 @@ class ScheduleRuleServiceTest { 1, List.of(GROUP_ID), null, - List.of(slots) + List.of(slots), + null ); } diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleVersionServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleVersionServiceTest.java new file mode 100644 index 0000000..7ed3a86 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/ScheduleVersionServiceTest.java @@ -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> 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 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; + } +} diff --git a/docs/API.md b/docs/API.md index 3ee8436..cfb3937 100644 --- a/docs/API.md +++ b/docs/API.md @@ -623,6 +623,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 ```json { + "scheduleVersionId": 7, "subjectId": 1, "semesterId": 1, "lectureAcademicHours": 32, @@ -652,7 +653,7 @@ CRUD доступен по: | Метод | URL | Назначение | |-------|-----|------------| -| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` | +| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` | | `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | | `POST` | `/api/admin/schedule-rules` | Создать правило | | `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | @@ -660,6 +661,11 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. +Создание, изменение и архивирование правил разрешены только внутри версии со статусом +`DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному +семестру. Без `versionId` список возвращает только правила текущей опубликованной версии, +а конструктор всегда передаёт ID выбранного черновика. + `subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит @@ -841,6 +847,112 @@ API возвращает `409 Conflict`; соседние интервалы и } ``` +### Версии и публикация расписания + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/edu-office/schedule/versions?semesterId=` | Версии семестра с авторами, датами и количеством правил | +| `GET` | `/api/edu-office/schedule/versions/{id}` | Одна версия | +| `POST` | `/api/edu-office/schedule/versions` | Создать пустой черновик или копию выбранной версии | +| `GET` | `/api/edu-office/schedule/versions/{id}/validate` | Полная проверка правил черновика | +| `GET` | `/api/edu-office/schedule/versions/{id}/diff?baseVersionId=` | Сравнение правил и сформированных занятий | +| `POST` | `/api/edu-office/schedule/versions/{id}/publish` | Атомарно опубликовать проверенный черновик | +| `POST` | `/api/edu-office/schedule/versions/{id}/restore` | Восстановить ранее опубликованную архивную версию | +| `GET` | `/api/edu-office/schedule/versions/history?semesterId=` | Хронология создания, публикации, архивации и восстановления | + +Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна +версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание +копии переносит правила, группы, слоты и подгруппы, сохраняя `versionGroupId` для diff. + +```json +{ + "semesterId": 3, + "name": "Расписание после распределения аудиторий", + "basedOnVersionId": 11 +} +``` + +Публикация и восстановление принимают обязательную причину: + +```json +{ "reason": "Согласовано учебным отделом 11.08.2026" } +``` + +Перед публикацией backend блокирует версию, повторно проверяет весь набор активных правил, +архивирует текущую публикацию и переводит черновик в `PUBLISHED` одной транзакцией. При +ошибке ни один статус не меняется. Обычные endpoints просмотра и генерации выбирают только +опубликованную версию; overrides архивных версий не попадают в актуальное расписание и +операционный реестр. + +### Анализ качества расписания + +| Метод | URL | Назначение | +|-------|-----|------------| +| `GET` | `/api/edu-office/schedule/quality?semesterId=&versionId=` | Итоговая оценка, метрики и объяснимый список проблем выбранной версии | +| `GET` | `/api/edu-office/schedule/quality/recommendations?semesterId=&versionId=&scheduleRuleSlotId=&lessonDate=` | Проверенные локальные варианты улучшения опубликованного занятия | + +Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. `versionId` необязателен: без него +анализируется текущая публикация. Для опубликованной версии анализатор рассматривает +фактическое расписание всего семестра, включая уже применённые `schedule_overrides`, и учитывает окна +групп и преподавателей, перегруженные дни, вместимость и избыточный размер аудитории, +согласованные пожелания и недоступность преподавателей, а также равномерность нагрузки. +Длинный семестр загружается внутренними интервалами не более 120 дней, поэтому публичный +запрос не требует диапазона дат. + +Черновик и архивная версия генерируются напрямую по собственным правилам, без подмешивания +override текущей публикации. Для них доступны оценка, метрики и проблемы, но не локальные +рекомендации: исправления черновика выполняются в конструкторе до публикации, архив остаётся +только для чтения. + +```json +{ + "semesterId": 3, + "semesterLabel": "2026/2027 · осенний семестр", + "scheduleVersionId": 12, + "scheduleVersionNumber": 2, + "scheduleVersionName": "Расписание после распределения аудиторий", + "scheduleVersionStatus": "DRAFT", + "score": 84, + "rawPenalty": 17, + "lessonCount": 36, + "totalProblemCount": 5, + "problemsTruncated": false, + "metrics": [ + { + "code": "GROUP_GAPS", + "label": "Окна у групп", + "value": 3, + "unit": "окон", + "penalty": 6, + "explanation": "Промежутки между занятиями одной группы" + } + ], + "problems": [ + { + "id": "GROUP_GAP:2026-09-14:31:18", + "type": "GROUP_GAP", + "severity": "MEDIUM", + "title": "Окно в расписании группы", + "penalty": 2, + "scheduleRuleSlotId": 31, + "lessonDate": "2026-09-14", + "optimizable": true + } + ] +} +``` + +`score` находится в диапазоне от 0 до 100 и нормализует сумму объяснимых штрафов +относительно числа занятий. Поле `totalProblemCount` содержит полный размер результата, +а массив `problems` ограничен 300 элементами; `problemsTruncated` сообщает об усечении. + +Рекомендации подбирают другой эффективный временной слот того же учебного дня либо +подходящую активную аудиторию. Занятия, уже закреплённые ручным override, исключаются. +Каждый кандидат до выдачи проходит `ScheduleOverrideService`, содержит готовый payload +`override`, ожидаемые `scoreDelta`, `penaltyDelta`, улучшения и компромиссы. API ничего не +изменяет автоматически: после подтверждения пользователь применяет payload обычным +`POST /api/edu-office/schedule/overrides`. + ### Отсутствия преподавателей и мастер замены | Метод | URL | Назначение | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 36daed6..25c238c 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -265,8 +265,32 @@ constraint. При чтении API разворачивает периоды о - **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах. - **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации. - **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю. -- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи. -- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание сначала блокирует строку семестра, а update блокирует правило и старый/новый семестры в стабильном порядке, поэтому два backend-pod не могут одновременно пройти проверку одного семестра по устаревшему снимку. +- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила той же версии. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи. +- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание блокирует строку семестра и выбранную версию, а update — правило, версию и старый/новый семестры в стабильном порядке. Публикация блокирует версию до завершения полной проверки, поэтому другой backend-pod не может дописать правило после валидации черновика. + +### Черновики, версии и публикация + +Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии: + +1. `DRAFT` создаётся пустым или как полная копия выбранной версии. +2. Конструктор добавляет, изменяет и архивирует правила только в выбранном черновике. +3. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по + стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра. +4. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем + переводит проверенный черновик в `PUBLISHED`. +5. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же + атомарной операцией с обязательной причиной. + +На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на +семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с +редактированием черновика или конкурентной публикацией. Каждое создание, архивирование, +публикация и восстановление записывается в неизменяемый журнал с автором, временем и +причиной. + +Обычная генерация для студентов, преподавателей и кабинетов просмотра всегда выбирает +только опубликованные правила. Точечные изменения привязаны к слотам конкретной версии: +после новой публикации overrides прежней версии сохраняются как аудит, но не влияют на +актуальное расписание и не показываются в его операционном реестре. ### Точечные изменения расписания @@ -386,6 +410,41 @@ constraint. При чтении API разворачивает периоды о отозвать только собственную ожидающую заявку. История содержит автора, время, статус и комментарий каждого перехода. +### Анализ качества и локальная оптимизация расписания + +Анализатор качества работает без собственной таблицы и не меняет правила расписания. +Пользователь выбирает конкретную версию семестра. Для `PUBLISHED` он строит фактические +занятия через `ScheduleQueryService`, поэтому в расчёт входят переносы, замены и отмены из +`schedule_overrides`. `DRAFT` и `ARCHIVED` генерируются напрямую по собственным правилам, +без точечных изменений текущей публикации. Семестр загружается частями не более 120 дней, +но оценка рассчитывается единообразно по всему периоду. + +Итоговая оценка от 0 до 100 формируется из объяснимых штрафов: + +- окна групп и преподавателей между занятиями одного дня; +- пятая и последующие пары группы или преподавателя за день; +- нехватка мест и заметно избыточная вместимость аудитории; +- занятие в подтверждённый строго недоступный или нежелательный интервал; +- занятие вне предпочтительного интервала преподавателя на выбранный день недели; +- нарушение пожеланий `NO_GAPS` и `CONSECUTIVE`. + +Неравномерность дневной нагрузки выводится отдельной диагностической метрикой в процентах +и не добавляет скрытого штрафа к итоговой оценке. + +В ответе сохраняются исходный штраф, вклад каждого критерия и конкретные проблемы с +датой, занятием и затронутой сущностью. Это делает оценку воспроизводимой и позволяет +фильтровать проблемы по типу и серьёзности. + +Для проблемы опубликованной версии помощник перебирает другие слоты эффективной сетки того же дня и +активные аудитории достаточной вместимости. Каждый вариант повторно проходит +`ScheduleOverrideService`, затем анализатор моделирует его влияние на общую оценку и +показывает улучшения и компромиссы. Занятия с ручным override считаются закреплёнными и не +получают рекомендаций. Применение возможно только после подтверждения пользователя через +обычный механизм `schedule_overrides`; автоматической публикации и полного solver в MVP +нет. Для черновика доступны те же оценка, метрики и объяснимые проблемы, но рекомендации +не создаются: пользователь исправляет правила в изолированном конструкторе и повторяет +проверку до публикации. Архив анализируется только для чтения. + ## Привязка преподаватель ↔ дисциплина Связь Many-to-Many через таблицу `teacher_subjects`: diff --git a/docs/DATABASE.md b/docs/DATABASE.md index ab5c894..81406c2 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -286,10 +286,37 @@ erDiagram BIGINT calendar_id FK } + schedule_versions { + BIGSERIAL id PK + BIGINT semester_id FK + INT version_number + VARCHAR name + VARCHAR status + BIGINT based_on_version_id FK + BIGINT restored_from_version_id FK + TEXT change_reason + BIGINT created_by FK + TIMESTAMPTZ created_at + BIGINT published_by FK + TIMESTAMPTZ published_at + TIMESTAMPTZ archived_at + } + + schedule_version_history { + BIGSERIAL id PK + BIGINT version_id FK + VARCHAR action + BIGINT actor_id FK + TEXT reason + TIMESTAMPTZ created_at + } + schedule_rules { BIGSERIAL id PK BIGINT subject_id FK BIGINT semester_id FK + BIGINT schedule_version_id FK + BIGINT version_group_id VARCHAR status DATE valid_from DATE valid_to @@ -442,6 +469,12 @@ erDiagram academic_years ||--o{ semesters : "academic_year_id" academic_years ||--o{ academic_calendars : "academic_year_id" academic_years ||--o{ student_group_calendar_assignments : "academic_year_id" + semesters ||--o{ schedule_versions : "semester_id" + schedule_versions ||--o{ schedule_rules : "schedule_version_id" + schedule_versions ||--o{ schedule_version_history : "version_id" + schedule_versions o|--o{ schedule_versions : "based_on/restored_from" + users ||--o{ schedule_versions : "created_by/published_by" + users ||--o{ schedule_version_history : "actor_id" semesters ||--o{ schedule_rules : "semester_id" specialties ||--o{ academic_calendars : "specialty_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" @@ -896,12 +929,50 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки ссылочных строк закрывают конкурентные записи между несколькими backend-pod. +#### `schedule_versions` — Версии расписания семестра + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID версии | +| `semester_id` | BIGINT FK → semesters (CASCADE) | Семестр | +| `version_number` | INT CHECK(> 0) | Последовательный номер внутри семестра | +| `name` | VARCHAR(160) | Пользовательское название | +| `status` | VARCHAR(20) | `DRAFT`, `PUBLISHED` или `ARCHIVED` | +| `based_on_version_id` | BIGINT FK → schedule_versions | Версия-основа черновика | +| `restored_from_version_id` | BIGINT FK → schedule_versions | Публикация, которую заменили при восстановлении | +| `change_reason` | TEXT | Причина последней публикации или восстановления | +| `created_by` | BIGINT FK → users | Автор черновика | +| `created_at` | TIMESTAMPTZ | Время создания | +| `published_by` | BIGINT FK → users | Автор публикации | +| `published_at` | TIMESTAMPTZ | Время последней публикации | +| `archived_at` | TIMESTAMPTZ | Время архивирования | + +Пара `semester_id + version_number` уникальна. Частичный индекс +`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на +семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и +привязывает к ней существующие seed-правила. + +#### `schedule_version_history` — Аудит версий расписания + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID события | +| `version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания | +| `action` | VARCHAR(30) | `CREATED`, `PUBLISHED`, `ARCHIVED` или `RESTORED` | +| `actor_id` | BIGINT FK → users | Автор действия; `NULL` для системной инициализации | +| `reason` | TEXT | Причина или описание события, до 2000 символов | +| `created_at` | TIMESTAMPTZ | Время события | + +Журнал добавляется при каждом переходе версии и выводится в обратной хронологии. Он не +заменяет данные версии, а сохраняет отдельные факты аудита. + #### `schedule_rules` — Правила расписания | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | | `subject_id` | BIGINT FK → subjects | Дисциплина | | `semester_id` | BIGINT FK → semesters | Семестр | +| `schedule_version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания | | `lecture_academic_hours` | INT | Лимит академических часов лекций | | `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ | | `practice_academic_hours` | INT | Лимит академических часов практик | @@ -914,7 +985,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл | `version_group_id` | BIGINT | Группа версий одного правила | | `change_reason` | TEXT | Причина изменения | -`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`. +`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`. Публичная генерация дополнительно требует статус `PUBLISHED` у связанной версии. Уникальный индекс по `schedule_version_id + version_group_id` не позволяет дважды скопировать одну логическую линию правила в одну версию. Базовая схема V1 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум. Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1; @@ -1111,19 +1182,19 @@ CHECK фиксирует допустимую форму каждого типа | Файл | Описание | |------|----------| -| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | -| `V2__teacher_absences_and_replacement_wizard.sql` | Реестр отсутствий преподавателей, статусы согласования и журнал применённых/отклонённых решений со ссылками на обычные `schedule_overrides` | -| `V3__teacher_preferences_and_change_requests.sql` | Пожелания преподавателей на семестр, строгая и мягкая доступность, заявки на изменение занятия и неизменяемая история решений | +| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | ### Этап разработки -Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были -объединены в baseline `V1`. После фиксации baseline нумерация начата заново: `V2` -добавляет отсутствия и мастер замены, а `V3` — пожелания преподавателей и заявки на -изменение занятий, не изменяя контрольную сумму `V1`. +Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями +и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца +проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл +`V1__init.sql`. Интервальное хранение активностей и правильная нумерация недель календарного графика входят непосредственно в V1. -Перед применением этой редакции требуется полностью пустая tenant-схема. +Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где +предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет +ошибку проверки. ### Полный сброс БД (локально) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index eeb75c3..0b3947e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -261,6 +261,9 @@ public class AbsenceController { Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД. +Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних +вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в +проекте нет. ### Применение diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index 857a5e5..52525c6 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -112,15 +112,19 @@ ## 2. Черновики, версии, публикация и аудит расписания +**Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`, +полное копирование правил, валидация и diff правил/занятий, атомарная публикация, +восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет +проверять черновики до публикации; изменения в них выполняются через конструктор правил. + ### Проблема и пользователи Изменение активного правила сейчас сразу влияет на следующий запрос расписания. При большой переработке семестра это создаёт риск показать студентам и преподавателям промежуточное состояние. Учебному отделу нужен безопасный цикл «подготовить — проверить — опубликовать». -В таблице `schedule_rules` уже предусмотрены поля `version_group_id`, `change_reason`, -`created_by` и `created_at`, которые можно использовать как основу, но полноценный жизненный -цикл версий пока не реализован. +Поля `version_group_id`, `change_reason`, `created_by` и `created_at` в `schedule_rules` +используются вместе с отдельными таблицами версий и истории публикаций. ### Пользовательский сценарий @@ -144,9 +148,10 @@ ### Связь с текущей системой -Текущая динамическая генерация сохраняется, но получает явный контекст версии. Конструктор -правил работает как с черновиком, так и с опубликованной версией, а кабинеты просмотра — только с опубликованной версией. Точечные изменения можно вести отдельным аудитом либо включать в пакет публикации в зависимости от -будущих продуктовых требований. +Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет +только черновик, кабинеты просмотра используют только опубликованную версию, а анализатор +качества позволяет выбрать опубликованную, черновую или архивную версию. Точечные изменения +остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически. ### Дальнейшее развитие @@ -396,7 +401,10 @@ feed должен отдавать только опубликованное р **Ожидаемый результат:** заметное сокращение ручного заполнения и ошибок ввода. **Сложность:** M. **Демонстрационный эффект:** высокий благодаря наглядному предпросмотру. -## 8. (отдельным запросом) Анализ качества и автоматическая оптимизация расписания +## 8. Анализ качества и автоматическая оптимизация расписания + +**Статус:** реализован MVP анализатора и локальных рекомендаций. Полный solver, фоновые +черновики и сравнение нескольких глобальных вариантов остаются дальнейшим этапом. ### Проблема и пользователи @@ -436,6 +444,12 @@ feed должен отдавать только опубликованное р конфликтного или неудобного занятия. Такой MVP позволяет проверить метрики качества, интерфейс объяснений и производительность на реальных данных. +Реализованный MVP анализирует фактическое расписание выбранного семестра с учётом ручных +правок, формирует оценку и список проблем, а для конкретного занятия предлагает проверенные +перестановки времени в пределах дня или аудитории. Кандидат применяется только после +подтверждения пользователя через существующий `schedule_override`; ручные правки считаются +закреплёнными. + Red Zone уже выявляет накладки и превышение вместимости, а вкладка загруженности показывает занятые и свободные слоты. Новый анализатор не должен дублировать эти экраны: его ценность — единая объяснимая оценка, мягкие критерии качества и рекомендации по улучшению. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d163cd6..f7c7b9d 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -33,6 +33,8 @@ frontend/ │ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh │ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов │ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки +│ ├── schedule-quality.test.mjs # Метрики качества, фильтры проблем и доступность вкладки +│ ├── schedule-versions.test.mjs # Статусы версий, журнал и навигация в черновик/качество │ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра │ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям │ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора @@ -52,7 +54,9 @@ frontend/ │ │ ├── modals.css # Модальные окна │ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности │ │ ├── departments-data.css # Стили создания кафедры/специальности -│ │ └── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей +│ │ ├── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей +│ │ ├── schedule-quality.css # Диагностический экран качества расписания +│ │ └── schedule-versions.css # Контур публикации, diff и журнал версий │ ├── js/ │ │ ├── main.js # Инициализация, маршрутизация, навигация │ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям @@ -72,6 +76,8 @@ frontend/ │ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки │ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений │ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение +│ │ ├── schedule-quality.js # Оценка, фильтры и локальные рекомендации +│ │ ├── schedule-versions.js # Черновики, публикация, восстановление, diff и аудит │ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей │ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки │ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года @@ -88,6 +94,8 @@ frontend/ │ │ ├── department-workspace.html │ │ ├── schedule-view.html │ │ ├── teacher-absences.html +│ │ ├── schedule-quality.html +│ │ ├── schedule-versions.html │ │ ├── schedule.html │ │ ├── academic-calendar.html │ │ └── auditorium-workload.html @@ -147,7 +155,7 @@ frontend/ | Роль | Доступные вкладки | |------|-------------------| | `ADMIN` | Все вкладки | -| `EDUCATION_OFFICE` | Просмотр и конструктор расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения | +| `EDUCATION_OFFICE` | Просмотр, конструктор и анализ качества расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения | | `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей | | `SCHEDULE_VIEWER` | Только просмотр расписаний | @@ -173,6 +181,8 @@ frontend/ | `schedule-view` | Просмотр расписаний: семестр выбирается в дополнительных фильтрах, для учебного периода строится двухнедельный диапазон, найденные расписания выбираются в переключателе, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | | `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers` | | `schedule` | Конструктор правил динамического расписания с подсказками согласованных пожеланий и выезжающей визуальной матрицей групп | `/api/admin/schedule-rules`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | +| `schedule-versions` | Контур публикации: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` | +| `schedule-quality` | Диагностика выбранной версии семестра: оценка, метрики, фильтруемые проблемы; для публикации — подтверждаемое локальное улучшение | `/api/edu-office/schedule/quality`, `/api/edu-office/schedule/quality/recommendations`, `/api/edu-office/schedule/overrides` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | @@ -197,6 +207,22 @@ frontend/ `ADMIN` и `EDUCATION_OFFICE`; кафедра видит записи своих преподавателей без управляющих действий. Каждая карточка показывает исходное и запрошенное состояние, результат предварительной проверки и хронологию решения. +- Вкладка `schedule-quality` доступна администратору и учебному отделу. После выбора + семестра и версии она выводит круговую оценку от 0 до 100, карточки метрик и реестр проблем с + фильтрами по серьёзности и типу. Для проблемы, связанной с конкретным незакреплённым + занятием опубликованной версии, кнопка `Подобрать улучшение` запрашивает проверенные варианты времени и + аудитории. Карточка кандидата показывает изменение оценки, улучшения и компромиссы; + override создаётся только после явного подтверждения, затем анализ запускается заново. + Для `DRAFT` экран рассчитывает те же метрики и проблемы напрямую по правилам черновика, + но направляет пользователя в конструктор и не создаёт override. `ARCHIVED` анализируется + только для чтения. Экран не предлагает автоматически менять занятия, уже отредактированные вручную. +- Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как + отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные + пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал. + Новый черновик копирует выбранную опубликованную версию. Перед публикацией интерфейс + одновременно запрашивает полную валидацию и diff, требует причину и блокирует действие + при ошибках. Кнопки черновика сохраняют его ID в `localStorage` и открывают конструктор + или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. diff --git a/frontend/admin/css/schedule-quality.css b/frontend/admin/css/schedule-quality.css new file mode 100644 index 0000000..1cdf65f --- /dev/null +++ b/frontend/admin/css/schedule-quality.css @@ -0,0 +1,790 @@ +/* Анализ качества: диагностическая рабочая станция учебного отдела. */ + +.quality-lab { + --quality-ink: #17324d; + --quality-cyan: #0f9fa8; + --quality-amber: #d88419; + --quality-red: #d6534d; + display: grid; + gap: 1rem; +} + +.quality-hero { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.55fr); + gap: clamp(1.5rem, 4vw, 4rem); + overflow: hidden; + padding: clamp(1.5rem, 4vw, 3.25rem); + color: #f7fbff; + background: + linear-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.055) 1px, transparent 1px), + radial-gradient(circle at 88% 16%, rgba(16, 194, 193, 0.26), transparent 34%), + linear-gradient(135deg, #12283b, #173b4d 58%, #0e5559); + background-size: 32px 32px, 32px 32px, auto, auto; + border: 1px solid rgba(116, 229, 225, 0.28); + border-radius: var(--radius-lg); + box-shadow: 0 24px 55px rgba(7, 32, 44, 0.22); +} + +.quality-hero::after { + content: ''; + position: absolute; + right: -4rem; + bottom: -7rem; + width: 19rem; + height: 19rem; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + box-shadow: + 0 0 0 2.5rem rgba(255, 255, 255, 0.025), + 0 0 0 5rem rgba(255, 255, 255, 0.018); + pointer-events: none; +} + +.quality-hero-copy, +.quality-run-panel { + position: relative; + z-index: 1; +} + +.quality-kicker { + display: block; + margin-bottom: 0.65rem; + color: var(--quality-cyan); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.quality-hero .quality-kicker { + color: #72e2dd; +} + +.quality-hero h2 { + max-width: 790px; + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(2rem, 4.5vw, 4.4rem); + font-weight: 500; + letter-spacing: -0.045em; + line-height: 0.98; +} + +.quality-hero-copy p { + max-width: 720px; + margin: 1.25rem 0 0; + color: rgba(239, 249, 252, 0.76); + font-size: 0.95rem; + line-height: 1.7; +} + +.quality-run-panel { + align-self: end; + display: grid; + gap: 0.65rem; + padding: 1.1rem; + background: rgba(4, 27, 39, 0.38); + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: var(--radius-md); + backdrop-filter: blur(16px); +} + +.quality-run-panel label { + color: rgba(238, 249, 252, 0.7); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.quality-run-panel select, +.quality-filters select { + width: 100%; + min-height: 42px; + padding: 0.55rem 2rem 0.55rem 0.75rem; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + font: inherit; +} + +.quality-run-panel select { + color: #eef9fb; + background: rgba(255, 255, 255, 0.09); + border-color: rgba(255, 255, 255, 0.16); +} + +.quality-run-panel select option { + color: #182b38; + background: #f7fbfc; +} + +.quality-status { + min-height: 2.75rem; + padding: 0.8rem 1rem; + color: var(--text-secondary); + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-left: 4px solid var(--quality-cyan); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.quality-status[data-tone="loading"] { + border-left-color: var(--quality-amber); +} + +.quality-status[data-tone="success"] { + border-left-color: var(--success); +} + +.quality-status[data-tone="warning"], +.quality-status[data-tone="error"] { + border-left-color: var(--quality-red); +} + +.quality-results { + display: grid; + gap: 1rem; +} + +.quality-results[hidden] { + display: none; +} + +.quality-scoreboard { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: clamp(1.5rem, 4vw, 3rem); + align-items: center; + padding: clamp(1.25rem, 3vw, 2.25rem); + background: + linear-gradient(110deg, color-mix(in srgb, var(--quality-cyan) 7%, var(--bg-card)), var(--bg-card) 44%); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-lg); +} + +.quality-score-orbit { + --quality-ring: var(--quality-cyan); + position: relative; + display: grid; + width: 164px; + aspect-ratio: 1; + place-items: center; + border-radius: 50%; + box-shadow: 0 15px 40px color-mix(in srgb, var(--quality-ring) 24%, transparent); + animation: quality-orbit-arrive 520ms cubic-bezier(.2, .8, .2, 1) both; +} + +.quality-score-ring { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.quality-score-ring circle { + fill: none; + stroke-width: 2.5; +} + +.quality-score-ring-track { + stroke: color-mix(in srgb, var(--text-secondary) 14%, transparent); +} + +.quality-score-ring-value { + stroke: var(--quality-ring); + stroke-linecap: round; + transition: stroke-dasharray 420ms ease; +} + +.quality-score-orbit[data-band="attention"] { + --quality-ring: var(--quality-amber); +} + +.quality-score-orbit[data-band="critical"] { + --quality-ring: var(--quality-red); +} + +.quality-score-orbit[data-band="excellent"] { + --quality-ring: #23965c; +} + +.quality-score-inner { + position: relative; + z-index: 1; + display: grid; + width: 132px; + aspect-ratio: 1; + place-content: center; + text-align: center; + background: var(--bg-card); + border-radius: 50%; +} + +.quality-score-inner strong { + color: var(--text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: 3.2rem; + font-weight: 500; + line-height: 0.95; +} + +.quality-score-inner span { + margin-top: 0.4rem; + color: var(--text-secondary); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.quality-score-band { + display: inline-flex; + width: fit-content; + padding: 0.3rem 0.65rem; + color: var(--quality-cyan); + background: color-mix(in srgb, var(--quality-cyan) 10%, transparent); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.quality-score-band[data-band="attention"] { + color: var(--quality-amber); +} + +.quality-score-band[data-band="critical"] { + color: var(--quality-red); +} + +.quality-score-band[data-band="excellent"] { + color: #23965c; +} + +.quality-score-copy h3 { + margin: 0.55rem 0 0; + color: var(--text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.6rem, 3vw, 2.5rem); + font-weight: 500; +} + +.quality-score-copy p { + max-width: 760px; + margin: 0.55rem 0 1.1rem; + color: var(--text-secondary); + line-height: 1.55; +} + +.quality-score-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.quality-score-meta span, +.quality-problem-total { + padding: 0.32rem 0.65rem; + color: var(--text-secondary); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: 999px; + font-size: 0.75rem; +} + +.quality-metrics { + display: grid; + grid-template-columns: repeat(6, minmax(145px, 1fr)); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.quality-metric { + position: relative; + min-height: 210px; + padding: 1.25rem; + background: var(--bg-card); + border-right: 1px solid var(--bg-card-border); + animation: quality-metric-arrive 420ms ease both; +} + +.quality-metric-2 { animation-delay: 45ms; } +.quality-metric-3 { animation-delay: 90ms; } +.quality-metric-4 { animation-delay: 135ms; } +.quality-metric-5 { animation-delay: 180ms; } +.quality-metric-6 { animation-delay: 225ms; } + +.quality-metric:last-child { + border-right: 0; +} + +.quality-metric::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + width: 48px; + height: 3px; + background: var(--quality-cyan); +} + +.quality-metric-index { + color: color-mix(in srgb, var(--text-secondary) 65%, transparent); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.78rem; +} + +.quality-metric > strong { + display: block; + margin-top: 1.25rem; + color: var(--text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: 2rem; + font-weight: 500; +} + +.quality-metric h4 { + margin: 0.4rem 0; + color: var(--text-primary); + font-size: 0.8rem; +} + +.quality-metric p { + margin: 0; + color: var(--text-secondary); + font-size: 0.72rem; + line-height: 1.45; +} + +.quality-metric-penalty { + position: absolute; + bottom: 1rem; + left: 1.25rem; + color: var(--quality-red); + font-size: 0.68rem; + font-weight: 700; +} + +.quality-workbench { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(310px, 0.75fr); + gap: 1rem; + align-items: start; +} + +.quality-problems-column, +.quality-recommendations { + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-lg); +} + +.quality-problems-column { + padding: clamp(1rem, 2.5vw, 1.75rem); +} + +.quality-section-heading { + display: flex; + gap: 1rem; + align-items: end; + justify-content: space-between; +} + +.quality-section-heading h3, +.quality-recommendations h3 { + margin: 0; + color: var(--text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.65rem; + font-weight: 500; +} + +.quality-filters { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 1.25rem 0; +} + +.quality-filters label { + display: grid; + gap: 0.35rem; + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 700; +} + +.quality-problems { + display: grid; + gap: 0.65rem; + max-height: 820px; + padding-right: 0.2rem; + overflow: auto; +} + +.quality-problem { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-left: 4px solid var(--quality-amber); + border-radius: var(--radius-sm); + transition: border-color var(--transition), transform var(--transition), box-shadow var(--transition); +} + +.quality-problem[data-severity="CRITICAL"] { + border-left-color: var(--quality-red); +} + +.quality-problem[data-severity="LOW"] { + border-left-color: var(--quality-cyan); +} + +.quality-problem:hover, +.quality-problem.is-selected { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--quality-cyan) 55%, var(--bg-card-border)); + box-shadow: 0 8px 24px rgba(15, 64, 76, 0.08); +} + +.quality-problem-main { + display: grid; + grid-template-columns: 76px minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: start; + width: 100%; + padding: 1rem; + color: inherit; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} + +.quality-severity { + width: fit-content; + padding: 0.2rem 0.4rem; + color: var(--text-secondary); + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: 4px; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.quality-problem-copy { + display: grid; + gap: 0.28rem; +} + +.quality-problem-copy strong { + color: var(--text-primary); + font-size: 0.88rem; +} + +.quality-problem-copy > span { + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.4; +} + +.quality-problem-copy small { + color: color-mix(in srgb, var(--text-secondary) 78%, transparent); + font-size: 0.7rem; +} + +.quality-penalty { + color: var(--quality-red); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.15rem; +} + +.quality-problem-action { + display: flex; + align-items: center; + padding: 0.7rem; + border-left: 1px solid var(--bg-card-border); +} + +.quality-problem-action > span { + max-width: 95px; + color: var(--text-secondary); + font-size: 0.68rem; + line-height: 1.25; +} + +.quality-recommendations { + position: sticky; + top: 1rem; + min-height: 360px; + max-height: calc(100vh - 2rem); + padding: 1.25rem; + overflow: auto; +} + +.quality-recommendations-empty, +.quality-recommendations-loading { + display: grid; + min-height: 320px; + place-content: center; + padding: 2rem; + text-align: center; +} + +.quality-recommendations-mark { + display: grid; + width: 54px; + aspect-ratio: 1; + margin: 0 auto 1rem; + place-items: center; + color: var(--quality-cyan); + background: color-mix(in srgb, var(--quality-cyan) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--quality-cyan) 35%, transparent); + border-radius: 50%; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.6rem; +} + +.quality-recommendations-empty p, +.quality-recommendations-loading p, +.quality-recommendations-head p, +.quality-recommendations-note { + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.5; +} + +.quality-loader { + width: 34px; + height: 34px; + margin: 0 auto 1rem; + border: 3px solid color-mix(in srgb, var(--quality-cyan) 18%, transparent); + border-top-color: var(--quality-cyan); + border-radius: 50%; + animation: quality-spin 0.8s linear infinite; +} + +.quality-candidates { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.quality-candidate { + padding: 1rem; + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-top: 3px solid var(--text-secondary); + border-radius: var(--radius-sm); +} + +.quality-candidate.is-positive { + border-top-color: var(--success); +} + +.quality-candidate.is-negative { + border-top-color: var(--quality-red); +} + +.quality-candidate-top { + display: grid; + gap: 0.2rem; +} + +.quality-candidate-top span { + color: var(--text-secondary); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.quality-candidate-top strong { + color: var(--text-primary); + font-size: 0.94rem; +} + +.quality-candidate > p { + margin: 0.6rem 0; + color: var(--text-secondary); + font-size: 0.76rem; +} + +.quality-candidate-impact { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.65rem; +} + +.quality-candidate-impact span { + padding: 0.2rem 0.42rem; + color: var(--text-secondary); + background: var(--bg-card); + border-radius: 4px; + font-size: 0.66rem; +} + +.quality-change-list { + display: grid; + gap: 0.25rem; + margin: 0.55rem 0; + padding-left: 0.65rem; + border-left: 2px solid var(--success); +} + +.quality-change-list.is-negative { + border-left-color: var(--quality-red); +} + +.quality-change-list strong, +.quality-change-list span { + font-size: 0.68rem; +} + +.quality-change-list strong { + color: var(--text-primary); +} + +.quality-change-list span { + color: var(--text-secondary); +} + +.quality-apply { + width: 100%; + margin-top: 0.75rem; +} + +.quality-empty-state { + display: grid; + min-height: 180px; + place-content: center; + gap: 0.35rem; + color: var(--text-secondary); + text-align: center; +} + +.quality-empty-state strong { + color: var(--text-primary); +} + +@keyframes quality-orbit-arrive { + from { opacity: 0; transform: scale(0.82) rotate(-14deg); } + to { opacity: 1; transform: scale(1) rotate(0); } +} + +@keyframes quality-metric-arrive { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes quality-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 1180px) { + .quality-metrics { + grid-template-columns: repeat(3, minmax(160px, 1fr)); + } + + .quality-metric:nth-child(3) { + border-right: 0; + } + + .quality-metric:nth-child(-n + 3) { + border-bottom: 1px solid var(--bg-card-border); + } +} + +@media (max-width: 920px) { + .quality-hero, + .quality-workbench { + grid-template-columns: 1fr; + } + + .quality-recommendations { + position: static; + max-height: none; + } +} + +@media (max-width: 680px) { + .quality-hero { + padding: 1.35rem; + } + + .quality-scoreboard { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + + .quality-score-meta { + justify-content: center; + } + + .quality-metrics { + grid-template-columns: 1fr; + } + + .quality-metric, + .quality-metric:nth-child(3), + .quality-metric:nth-child(-n + 3) { + min-height: 190px; + border-right: 0; + border-bottom: 1px solid var(--bg-card-border); + } + + .quality-metric:last-child { + border-bottom: 0; + } + + .quality-filters { + grid-template-columns: 1fr; + } + + .quality-problem { + grid-template-columns: 1fr; + } + + .quality-problem-main { + grid-template-columns: 1fr auto; + } + + .quality-severity { + grid-column: 1; + } + + .quality-problem-copy { + grid-column: 1; + } + + .quality-penalty { + grid-column: 2; + grid-row: 1 / 3; + } + + .quality-problem-action { + justify-content: flex-end; + border-top: 1px solid var(--bg-card-border); + border-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .quality-score-orbit, + .quality-metric, + .quality-loader { + animation: none; + } +} diff --git a/frontend/admin/css/schedule-versions.css b/frontend/admin/css/schedule-versions.css new file mode 100644 index 0000000..3d5fad5 --- /dev/null +++ b/frontend/admin/css/schedule-versions.css @@ -0,0 +1,299 @@ +.version-studio { + --version-ink: #12253a; + --version-paper: #f5f0e4; + --version-copper: #d46b3c; + --version-mint: #52c7a3; + display: grid; + gap: 1rem; +} + +.version-hero { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(260px, .55fr); + gap: clamp(1.5rem, 5vw, 5rem); + overflow: hidden; + padding: clamp(1.6rem, 4vw, 3.5rem); + color: #f8f4eb; + background: + radial-gradient(circle at 82% 22%, rgba(237, 129, 75, .28), transparent 27%), + repeating-linear-gradient(118deg, rgba(255, 255, 255, .025) 0 1px, transparent 1px 19px), + linear-gradient(135deg, #102438, #19364b 54%, #233f45); + border: 1px solid rgba(240, 173, 119, .25); + border-radius: var(--radius-lg, 20px); + box-shadow: 0 26px 65px rgba(5, 20, 32, .27); +} + +.version-hero::after { + content: '01 / LIVE'; + position: absolute; + right: -1.5rem; + bottom: 1.2rem; + color: rgba(255, 255, 255, .055); + font-family: Georgia, serif; + font-size: clamp(4rem, 10vw, 9rem); + letter-spacing: -.08em; + pointer-events: none; +} + +.version-hero > * { position: relative; z-index: 1; } + +.version-eyebrow { + display: block; + margin-bottom: .55rem; + color: var(--version-copper); + font-size: .68rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; +} + +.version-hero .version-eyebrow { color: #f3a170; } + +.version-hero h2 { + max-width: 760px; + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(2rem, 4.5vw, 4.5rem); + font-weight: 500; + letter-spacing: -.05em; + line-height: .98; +} + +.version-hero p { + max-width: 700px; + margin: 1.25rem 0 0; + color: rgba(242, 247, 248, .72); + line-height: 1.7; +} + +.version-hero-controls { + align-self: end; + display: grid; + gap: .65rem; + padding: 1rem; + background: rgba(3, 17, 28, .42); + border: 1px solid rgba(255, 255, 255, .12); + border-radius: var(--radius-md); + backdrop-filter: blur(14px); +} + +.version-hero-controls label { + color: rgba(255, 255, 255, .62); + font-size: .7rem; + font-weight: 800; + letter-spacing: .09em; + text-transform: uppercase; +} + +.version-hero-controls select { + width: 100%; + min-height: 42px; + padding: .55rem .7rem; + color: #f8fafc; + background: rgba(255, 255, 255, .08); + border: 1px solid rgba(255, 255, 255, .15); + border-radius: var(--radius-sm); +} + +.version-hero-controls option { color: #152536; background: #f7f4ed; } + +.version-status { + min-height: 2.8rem; + padding: .8rem 1rem; + color: var(--text-secondary); + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-left: 4px solid var(--version-copper); + border-radius: var(--radius-sm); +} + +.version-status[data-tone="success"] { border-left-color: var(--success); } +.version-status[data-tone="error"] { border-left-color: var(--error); } +.version-status[data-tone="loading"] { border-left-color: var(--warning); } + +.version-constructor-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, .55fr); + gap: 1rem; + align-items: center; + margin-bottom: 1rem; + padding: 1.15rem 1.3rem; + background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card)); + border: 1px solid color-mix(in srgb, var(--version-copper) 30%, var(--bg-card-border)); + border-radius: var(--radius-lg, 20px); +} +.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; } +.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; } +.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .55rem; align-items: end; } +.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); } + +.version-current { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 1.25rem; + align-items: center; + min-height: 120px; + padding: 1.4rem; + background: linear-gradient(100deg, color-mix(in srgb, var(--version-mint) 9%, var(--bg-card)), var(--bg-card)); + border: 1px solid color-mix(in srgb, var(--version-mint) 35%, var(--bg-card-border)); + border-radius: var(--radius-lg, 20px); +} + +.version-live-mark { + display: grid; + width: 68px; + aspect-ratio: 1; + place-content: center; + color: #eafff8; + background: #16856a; + border-radius: 50%; + font-size: .7rem; + font-weight: 900; + letter-spacing: .12em; + box-shadow: 0 0 0 7px color-mix(in srgb, var(--version-mint) 15%, transparent); +} + +.version-current h3, +.version-section-head h3, +.version-card h4, +.version-diff-panel h3 { margin: 0; color: var(--text-primary); } + +.version-current p, +.version-card p, +.version-empty p { margin: .45rem 0 0; color: var(--text-secondary); line-height: 1.5; } + +.version-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; } +.version-stats span, +.version-status-chip { + padding: .28rem .55rem; + color: var(--text-secondary); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); + border-radius: 999px; + font-size: .72rem; +} + +.version-workbench { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(330px, .85fr); + gap: 1rem; + align-items: start; +} + +.version-drafts-panel, +.version-diff-panel, +.version-archive-panel, +.version-history-panel { + padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-lg, 20px); +} + +.version-diff-panel { position: sticky; top: 1rem; min-height: 310px; } + +.version-section-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + margin-bottom: 1rem; +} + +.version-section-head > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 2rem; } +.version-draft-list { display: grid; gap: .8rem; } + +.version-card { + position: relative; + overflow: hidden; + padding: 1rem; + background: color-mix(in srgb, var(--version-paper) 4%, var(--bg-input)); + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-md); +} + +.version-card::before { + content: attr(data-number); + position: absolute; + right: .5rem; + top: -.8rem; + color: color-mix(in srgb, var(--version-copper) 12%, transparent); + font-family: Georgia, serif; + font-size: 5rem; +} + +.version-card > * { position: relative; } +.version-card-top { display: flex; justify-content: space-between; gap: 1rem; } +.version-card-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1rem; } +.version-card small { display: block; margin-top: .45rem; color: var(--text-placeholder); } + +.version-empty { + display: grid; + min-height: 270px; + place-content: center; + text-align: center; +} +.version-empty > span { color: var(--version-copper); font-family: Georgia, serif; font-size: 4rem; } +.version-empty p { max-width: 360px; } + +.version-diff-score { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: .55rem; + margin: 1rem 0; +} +.version-diff-score div { padding: .75rem; background: var(--bg-input); border-radius: var(--radius-sm); text-align: center; } +.version-diff-score strong { display: block; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.75rem; } +.version-diff-score span { color: var(--text-secondary); font-size: .68rem; text-transform: uppercase; } + +.version-change-list { display: grid; gap: .55rem; max-height: 390px; overflow: auto; } +.version-change { padding: .75rem; border-left: 3px solid var(--version-copper); background: var(--bg-input); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; } +.version-change[data-type="ADDED"] { border-left-color: var(--success); } +.version-change[data-type="REMOVED"] { border-left-color: var(--error); } +.version-change strong { color: var(--text-primary); } +.version-change span { display: block; margin-top: .3rem; color: var(--text-secondary); font-size: .78rem; line-height: 1.45; } + +.version-archive-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: .75rem; } +.version-history { display: grid; gap: 0; } +.version-history-row { display: grid; grid-template-columns: 120px 1fr auto; gap: 1rem; padding: .8rem 0; border-bottom: 1px solid var(--bg-card-border); } +.version-history-row:last-child { border-bottom: 0; } +.version-history-row time { color: var(--text-placeholder); font-size: .75rem; } +.version-history-row strong { color: var(--text-primary); } +.version-history-row p { margin: .25rem 0 0; color: var(--text-secondary); font-size: .8rem; } + +.version-modal { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + padding: 1rem; + place-items: center; + background: rgba(4, 13, 22, .75); + backdrop-filter: blur(8px); +} +.version-modal[hidden] { display: none; } +.version-modal-card { position: relative; width: min(560px, 100%); padding: 1.6rem; background: var(--bg-primary); border: 1px solid var(--bg-card-border); border-radius: var(--radius-lg, 20px); box-shadow: 0 30px 80px rgba(0, 0, 0, .45); } +.version-modal-card h3 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 2rem; } +.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; } +.version-modal-card form { display: grid; gap: .65rem; } +.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; } +.version-modal-card textarea { width: 100%; resize: vertical; padding: .8rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); font: inherit; } +.version-modal-close { position: absolute; top: .7rem; right: .8rem; color: var(--text-secondary); background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; } +.version-modal-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: .4rem; } +.version-modal-summary { padding: .75rem; color: var(--text-secondary); background: var(--bg-input); border-radius: var(--radius-sm); font-size: .8rem; line-height: 1.5; } + +@media (max-width: 900px) { + .version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; } + .version-diff-panel { position: static; } + .version-current { grid-template-columns: auto 1fr; } + .version-current .version-card-actions { grid-column: 1 / -1; } +} + +@media (max-width: 560px) { + .version-current { grid-template-columns: 1fr; } + .version-live-mark { width: 54px; } + .version-diff-score { grid-template-columns: 1fr; } + .version-history-row { grid-template-columns: 1fr; gap: .25rem; } +} diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 6596c90..a278051 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -13,6 +13,8 @@ + + @@ -87,6 +89,27 @@ Конструктор правил + + + + + + + + Версии расписания + + + + + + + + + + Качество расписания + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index d5f8733..c368f9c 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -101,6 +101,22 @@ const ROUTES = { await module.initSchedule(); } }, + 'schedule-versions': { + title: 'Версии расписания', + file: '/admin/views/schedule-versions.html', + init: async () => { + const module = await import('./views/schedule-versions.js'); + await module.initScheduleVersions(); + } + }, + 'schedule-quality': { + title: 'Анализ качества расписания', + file: '/admin/views/schedule-quality.html', + init: async () => { + const module = await import('./views/schedule-quality.js'); + await module.initScheduleQuality(); + } + }, 'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', diff --git a/frontend/admin/js/role-capabilities.js b/frontend/admin/js/role-capabilities.js index be6cc1a..1794a1c 100644 --- a/frontend/admin/js/role-capabilities.js +++ b/frontend/admin/js/role-capabilities.js @@ -1,13 +1,13 @@ const capabilities = { ADMIN: { defaultAdminTab: 'dashboard', - adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'], + adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'], defaultSettingsTab: 'general', settingsTabs: ['general', 'time-slots', 'database', 'edu-forms'] }, EDUCATION_OFFICE: { defaultAdminTab: 'dashboard', - adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'], + adminTabs: ['dashboard', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms'], defaultSettingsTab: 'time-slots', settingsTabs: ['time-slots', 'edu-forms'] }, diff --git a/frontend/admin/js/views/schedule-quality.js b/frontend/admin/js/views/schedule-quality.js new file mode 100644 index 0000000..7ab4659 --- /dev/null +++ b/frontend/admin/js/views/schedule-quality.js @@ -0,0 +1,462 @@ +import { api } from '../api.js'; +import { escapeHtml } from '../utils.js'; + +let qualityState = null; + +export async function initScheduleQuality() { + const root = document.querySelector('.quality-lab'); + if (!root) return; + qualityState = { root, analysis: null, versions: [], visibleProblems: [], selectedProblemId: null, applying: false }; + root.querySelector('#quality-run')?.addEventListener('click', runAnalysis); + root.querySelector('#quality-semester')?.addEventListener('change', loadVersions); + root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems); + root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems); + root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick); + root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick); + await loadSemesters(); +} + +async function loadSemesters() { + const select = qualityState?.root.querySelector('#quality-semester'); + if (!select) return; + try { + const semesters = await api.get('/api/schedule/semesters'); + select.innerHTML = (semesters || []).map(semester => ` + + `).join(''); + if (!semesters?.length) { + select.innerHTML = ''; + qualityStatus('Сначала создайте учебный год и семестр.', 'warning'); + return; + } + const today = localIsoDate(new Date()); + const current = semesters.find(semester => + semester.startDate <= today && semester.endDate >= today + ); + select.value = String(current?.id ?? semesters[0].id); + const savedSemesterId = localStorage.getItem('magistr.quality.semesterId'); + if (semesters.some(semester => String(semester.id) === savedSemesterId)) { + select.value = savedSemesterId; + } + await loadVersions(); + } catch (error) { + select.innerHTML = ''; + qualityStatus(error.message || 'Не удалось загрузить семестры.', 'error'); + } +} + +async function loadVersions() { + const semesterId = qualityState?.root.querySelector('#quality-semester')?.value; + const select = qualityState?.root.querySelector('#quality-version'); + if (!select || !semesterId) return; + select.innerHTML = ''; + try { + qualityState.versions = await api.get( + `/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}` + ); + select.innerHTML = qualityState.versions.map(version => ` + + `).join(''); + if (!qualityState.versions.length) { + select.innerHTML = ''; + return; + } + const savedVersionId = localStorage.getItem('magistr.quality.versionId'); + const saved = qualityState.versions.find(version => String(version.id) === savedVersionId); + const published = qualityState.versions.find(version => version.status === 'PUBLISHED'); + select.value = String((saved || published || qualityState.versions[0]).id); + localStorage.setItem('magistr.quality.semesterId', semesterId); + } catch (error) { + select.innerHTML = ''; + qualityStatus(error.message || 'Не удалось загрузить версии расписания.', 'error'); + } +} + +async function runAnalysis() { + const semesterId = qualityState?.root.querySelector('#quality-semester')?.value; + const versionId = qualityState?.root.querySelector('#quality-version')?.value; + if (!semesterId) { + qualityStatus('Выберите семестр для анализа.', 'warning'); + return; + } + if (!versionId) { + qualityStatus('Выберите версию расписания для анализа.', 'warning'); + return; + } + localStorage.setItem('magistr.quality.semesterId', semesterId); + localStorage.setItem('magistr.quality.versionId', versionId); + const button = qualityState.root.querySelector('#quality-run'); + button.disabled = true; + button.textContent = 'Анализируем…'; + qualityStatus('Строим фактическое расписание и рассчитываем показатели…', 'loading'); + qualityState.root.querySelector('#quality-results').hidden = true; + try { + qualityState.analysis = await api.get( + `/api/edu-office/schedule/quality?semesterId=${encodeURIComponent(semesterId)}&versionId=${encodeURIComponent(versionId)}` + ); + qualityState.selectedProblemId = null; + renderAnalysis(); + qualityStatus( + qualityState.analysis.scheduleVersionStatus === 'DRAFT' + ? 'Черновик проверен. Исправления вносите в его правила до публикации.' + : 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.', + 'success' + ); + } catch (error) { + qualityStatus(error.message || 'Не удалось выполнить анализ качества.', 'error'); + } finally { + button.disabled = false; + button.textContent = 'Выполнить анализ'; + } +} + +function renderAnalysis() { + const analysis = qualityState?.analysis; + if (!analysis) return; + qualityState.root.querySelector('#quality-results').hidden = false; + const band = qualityBand(analysis.score); + const orbit = qualityState.root.querySelector('#quality-score-orbit'); + orbit.dataset.band = band.code; + qualityState.root.querySelector('#quality-score-ring-value') + .setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, analysis.score))} 100`); + qualityState.root.querySelector('#quality-score').textContent = String(analysis.score); + qualityState.root.querySelector('#quality-score-band').textContent = band.label; + qualityState.root.querySelector('#quality-score-band').dataset.band = band.code; + qualityState.root.querySelector('#quality-semester-label').textContent = analysis.semesterLabel; + qualityState.root.querySelector('#quality-score-summary').textContent = band.summary; + qualityState.root.querySelector('#quality-score-meta').innerHTML = ` + ${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))} + ${escapeHtml(String(analysis.lessonCount))} занятий + ${escapeHtml(String(analysis.totalProblemCount))} проблем + штраф ${escapeHtml(String(analysis.rawPenalty))} + `; + qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || []) + .map((metric, index) => ` +
+ ${String(index + 1).padStart(2, '0')} + ${escapeHtml(formatMetricValue(metric))} +

${escapeHtml(metric.label)}

+

${escapeHtml(metric.explanation)}

+ штраф ${escapeHtml(String(metric.penalty))} +
+ `).join(''); + const totalLabel = analysis.problemsTruncated + ? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}` + : `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`; + qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel; + renderProblems(); + renderRecommendationsEmpty(); +} + +function renderProblems() { + const container = qualityState?.root.querySelector('#quality-problems'); + if (!container || !qualityState.analysis) return; + const severity = qualityState.root.querySelector('#quality-severity-filter')?.value || ''; + const category = qualityState.root.querySelector('#quality-type-filter')?.value || ''; + qualityState.visibleProblems = filterQualityProblems( + qualityState.analysis.problems || [], severity, category + ); + if (!qualityState.visibleProblems.length) { + container.innerHTML = ` +
+ По выбранному фильтру проблем нет + Измените уровень важности или категорию. +
+ `; + return; + } + container.innerHTML = qualityState.visibleProblems.map((problem, index) => { + const selected = problem.id === qualityState.selectedProblemId; + return ` +
+ +
+ ${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED' + ? `` + : `${escapeHtml(problemActionLabel(problem))}`} +
+
+ `; + }).join(''); +} + +async function handleProblemClick(event) { + const button = event.target.closest('[data-problem-index]'); + if (!button) return; + const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)]; + if (!problem) return; + qualityState.selectedProblemId = problem.id; + renderProblems(); + if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') { + renderRecommendationsMessage( + qualityState.analysis.scheduleVersionStatus === 'DRAFT' + ? 'Черновик остаётся изолированным' + : 'Архивная версия доступна только для проверки', + qualityState.analysis.scheduleVersionStatus === 'DRAFT' + ? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.' + : 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.' + ); + return; + } + if (!problem.optimizable) { + renderRecommendationsMessage( + 'Ручное решение сохранено', + 'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.' + ); + return; + } + await loadRecommendations(problem); +} + +async function loadRecommendations(problem) { + const panel = qualityState.root.querySelector('#quality-recommendations'); + panel.innerHTML = ` +
+ + Проверяем варианты +

Каждый кандидат проходит полную проверку конфликтов.

+
+ `; + try { + const params = new URLSearchParams({ + semesterId: String(qualityState.analysis.semesterId), + versionId: String(qualityState.analysis.scheduleVersionId), + scheduleRuleSlotId: String(problem.scheduleRuleSlotId), + lessonDate: problem.lessonDate + }); + const result = await api.get( + `/api/edu-office/schedule/quality/recommendations?${params.toString()}` + ); + renderRecommendations(problem, result); + } catch (error) { + renderRecommendationsMessage( + 'Не удалось подобрать варианты', + error.message || 'Повторите попытку после обновления анализа.' + ); + } +} + +function renderRecommendations(problem, result) { + const panel = qualityState.root.querySelector('#quality-recommendations'); + const candidates = result.candidates || []; + if (!candidates.length) { + renderRecommendationsMessage( + 'Свободных улучшений не найдено', + 'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.' + ); + return; + } + panel.innerHTML = ` +
+ Проверенные варианты +

${escapeHtml(problem.title)}

+

${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}

+
+
+ ${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')} +
+

+ Применение создаёт обычное точечное изменение и не переписывает правило семестра. +

+ `; + panel._qualityCandidates = candidates; +} + +function recommendationCard(candidate, index) { + const impactClass = candidate.penaltyDelta > 0 + ? 'is-positive' + : candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral'; + const impact = candidate.scoreDelta > 0 + ? `+${candidate.scoreDelta} к оценке` + : candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений'; + return ` +
+
+ ${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'} + ${escapeHtml(candidate.label)} +
+

${escapeHtml(candidate.description)}

+
+ ${escapeHtml(impact)} + результат ${escapeHtml(String(candidate.resultingScore))}/100 +
+ ${changeList('Улучшится', candidate.improvements, 'positive')} + ${changeList('Компромиссы', candidate.tradeoffs, 'negative')} + +
+ `; +} + +function changeList(label, items, tone) { + if (!items?.length) return ''; + return ` +
+ ${escapeHtml(label)} + ${items.map(item => `${escapeHtml(item)}`).join('')} +
+ `; +} + +async function handleRecommendationClick(event) { + const button = event.target.closest('[data-candidate-index]'); + if (!button || qualityState?.applying) return; + const panel = qualityState.root.querySelector('#quality-recommendations'); + const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)]; + if (!candidate) return; + const confirmed = window.confirm( + `Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.` + ); + if (!confirmed) return; + qualityState.applying = true; + button.disabled = true; + button.textContent = 'Применяем…'; + try { + await api.post('/api/edu-office/schedule/overrides', candidate.override); + qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success'); + await runAnalysis(); + } catch (error) { + qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error'); + button.disabled = false; + button.textContent = 'Применить после подтверждения'; + } finally { + qualityState.applying = false; + } +} + +function renderRecommendationsEmpty() { + if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') { + const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT'; + renderRecommendationsMessage( + isDraft ? 'Режим проверки черновика' : 'Режим проверки архива', + isDraft + ? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.' + : 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.' + ); + return; + } + renderRecommendationsMessage( + 'Локальные улучшения', + 'Выберите проблему, чтобы проверить свободные слоты и аудитории.' + ); +} + +function problemActionLabel(problem) { + if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') { + return 'Исправляется в конструкторе'; + } + if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') { + return 'Только просмотр'; + } + return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную'; +} + +function renderRecommendationsMessage(title, message) { + const panel = qualityState?.root.querySelector('#quality-recommendations'); + if (!panel) return; + panel.innerHTML = ` +
+ ↗ +

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+ `; + panel._qualityCandidates = []; +} + +function qualityStatus(message, tone = '') { + const status = qualityState?.root.querySelector('#quality-status'); + if (!status) return; + status.textContent = message; + status.dataset.tone = tone; +} + +export function filterQualityProblems(problems, severity = '', category = '') { + return (problems || []).filter(problem => { + const severityMatches = !severity || problem.severity === severity; + const categoryMatches = !category || problemCategory(problem.type) === category; + return severityMatches && categoryMatches; + }); +} + +function problemCategory(type = '') { + if (type.includes('GAP') || type.includes('COMPACTNESS')) return 'GAPS'; + if (type.includes('LOAD')) return 'LOAD'; + if (type.includes('ROOM')) return 'ROOM'; + return 'PREFERENCE'; +} + +export function qualityBand(score) { + if (score >= 90) { + return { code: 'excellent', label: 'Сильное расписание', summary: 'Найдены только локальные неудобства с небольшим влиянием.' }; + } + if (score >= 75) { + return { code: 'stable', label: 'Устойчивое расписание', summary: 'Расписание работоспособно, но несколько перестановок заметно улучшат комфорт.' }; + } + if (score >= 55) { + return { code: 'attention', label: 'Нужна доработка', summary: 'Мягкие ограничения и распределение нагрузки требуют внимания учебного отдела.' }; + } + return { code: 'critical', label: 'Высокая цена неудобств', summary: 'Сначала разберите критические проблемы и перегруженные дни.' }; +} + +export function formatMetricValue(metric) { + const value = Number(metric?.value || 0); + const formatted = Number.isInteger(value) + ? String(value) + : value.toLocaleString('ru-RU', { maximumFractionDigits: 1 }); + return `${formatted}${metric?.unit === '%' ? '%' : metric?.unit ? ` ${metric.unit}` : ''}`; +} + +function severityLabel(severity) { + return { CRITICAL: 'Критично', HIGH: 'Высокая', MEDIUM: 'Средняя', LOW: 'Низкая' }[severity] || severity; +} + +function semesterLabel(semester) { + const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний'; + return `${semester.academicYearTitle} · ${type} · ${formatDate(semester.startDate)}—${formatDate(semester.endDate)}`; +} + +function formatDate(value) { + if (!value) return 'дата не указана'; + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; +} + +function localIsoDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function problemWord(count) { + const mod100 = count % 100; + const mod10 = count % 10; + if (mod100 >= 11 && mod100 <= 14) return 'проблем'; + if (mod10 === 1) return 'проблема'; + if (mod10 >= 2 && mod10 <= 4) return 'проблемы'; + return 'проблем'; +} + +export function versionStatusLabel(status) { + return { DRAFT: 'Черновик', PUBLISHED: 'Опубликована', ARCHIVED: 'Архив' }[status] || 'Версия'; +} diff --git a/frontend/admin/js/views/schedule-versions.js b/frontend/admin/js/views/schedule-versions.js new file mode 100644 index 0000000..fadcb7f --- /dev/null +++ b/frontend/admin/js/views/schedule-versions.js @@ -0,0 +1,423 @@ +import { api } from '../api.js'; +import { escapeHtml } from '../utils.js'; + +let versionState = null; + +export async function initScheduleVersions() { + const root = document.querySelector('.version-studio'); + if (!root) return; + versionState = { + root, + semesters: [], + versions: [], + history: [], + modal: null, + loading: false + }; + bindEvents(); + await loadSemesters(); +} + +function bindEvents() { + const root = versionState.root; + root.querySelector('#version-semester')?.addEventListener('change', loadWorkspace); + root.querySelector('#version-create')?.addEventListener('click', () => openCreateModal()); + root.querySelector('#version-drafts')?.addEventListener('click', handleVersionAction); + root.querySelector('#version-archive')?.addEventListener('click', handleVersionAction); + root.querySelector('#version-current')?.addEventListener('click', handleVersionAction); + root.querySelector('#version-modal-close')?.addEventListener('click', closeModal); + root.querySelector('#version-modal-cancel')?.addEventListener('click', closeModal); + root.querySelector('#version-modal')?.addEventListener('click', event => { + if (event.target.id === 'version-modal') closeModal(); + }); + root.querySelector('#version-modal-form')?.addEventListener('submit', submitModal); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !root.querySelector('#version-modal')?.hidden) closeModal(); + }); +} + +async function loadSemesters() { + const select = versionState.root.querySelector('#version-semester'); + try { + versionState.semesters = await api.get('/api/schedule/semesters'); + select.innerHTML = versionState.semesters.map(semester => ` + + `).join(''); + if (!versionState.semesters.length) { + select.innerHTML = ''; + setStatus('Сначала создайте учебный год и семестр.', 'error'); + return; + } + const savedSemester = localStorage.getItem('magistr.schedule.semesterId'); + const today = localIsoDate(new Date()); + const current = versionState.semesters.find(item => item.startDate <= today && item.endDate >= today); + const selected = versionState.semesters.find(item => String(item.id) === savedSemester) + || current + || versionState.semesters[0]; + select.value = String(selected.id); + await loadWorkspace(); + } catch (error) { + setStatus(error.message || 'Не удалось загрузить семестры.', 'error'); + } +} + +async function loadWorkspace() { + const semesterId = selectedSemesterId(); + if (!semesterId || versionState.loading) return; + versionState.loading = true; + localStorage.setItem('magistr.schedule.semesterId', semesterId); + setStatus('Собираем версии, правила и журнал публикаций…', 'loading'); + try { + [versionState.versions, versionState.history] = await Promise.all([ + api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`), + api.get(`/api/edu-office/schedule/versions/history?semesterId=${encodeURIComponent(semesterId)}`) + ]); + renderWorkspace(); + setStatus('Контур версий синхронизирован.', 'success'); + } catch (error) { + setStatus(error.message || 'Не удалось загрузить версии расписания.', 'error'); + } finally { + versionState.loading = false; + } +} + +function renderWorkspace() { + const published = versionState.versions.find(version => version.status === 'PUBLISHED'); + const drafts = versionState.versions.filter(version => version.status === 'DRAFT'); + const archive = versionState.versions.filter(version => version.status === 'ARCHIVED'); + renderCurrent(published); + renderDrafts(drafts); + renderArchive(archive); + renderHistory(versionState.history); +} + +function renderCurrent(version) { + const container = versionState.root.querySelector('#version-current'); + if (!version) { + container.innerHTML = ` +
ПУСТО
+

Опубликованной версии нет

Создайте первый черновик и опубликуйте его после проверки.

+ `; + return; + } + container.innerHTML = ` +
LIVE
+
+ Сейчас видят пользователи +

${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}

+

${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}

+
+ ${escapeHtml(String(version.ruleCount))} правил + ${escapeHtml(String(version.slotCount))} слотов + ${escapeHtml(formatDateTime(version.publishedAt))} +
+
+
+ +
+ `; +} + +function renderDrafts(drafts) { + const container = versionState.root.querySelector('#version-drafts'); + versionState.root.querySelector('#version-draft-count').textContent = String(drafts.length); + if (!drafts.length) { + container.innerHTML = ` +
+ +

Нет активных черновиков

+

Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.

+
+ `; + return; + } + container.innerHTML = drafts.map(version => ` +
+
+
+ Черновик · v${escapeHtml(String(version.versionNumber))} +

${escapeHtml(version.name)}

+
+
+

Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}

+ ${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))} +
+ + + + +
+
+ `).join(''); +} + +function renderArchive(versions) { + const container = versionState.root.querySelector('#version-archive'); + if (!versions.length) { + container.innerHTML = '

Предыдущих публикаций пока нет.

'; + return; + } + container.innerHTML = versions.map(version => ` +
+ Архив · v${escapeHtml(String(version.versionNumber))} +

${escapeHtml(version.name)}

+

${escapeHtml(version.changeReason || 'Причина не указана')}

+ ${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))} +
+ + + +
+
+ `).join(''); +} + +function renderHistory(history) { + const container = versionState.root.querySelector('#version-history'); + if (!history.length) { + container.innerHTML = '

Действия ещё не зафиксированы.

'; + return; + } + container.innerHTML = history.map(item => ` +
+ +
+ ${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)} +

${escapeHtml(item.reason || 'Без комментария')}

+
+ ${escapeHtml(item.actorName || 'Система')} +
+ `).join(''); +} + +async function handleVersionAction(event) { + const button = event.target.closest('[data-action][data-id]'); + if (!button) return; + const version = versionState.versions.find(item => String(item.id) === button.dataset.id); + if (!version) return; + const action = button.dataset.action; + if (action === 'edit') { + localStorage.setItem('magistr.schedule.versionId', String(version.id)); + navigateTo('schedule'); + return; + } + if (action === 'quality') { + localStorage.setItem('magistr.quality.semesterId', String(version.semesterId)); + localStorage.setItem('magistr.quality.versionId', String(version.id)); + navigateTo('schedule-quality'); + return; + } + if (action === 'diff') { + await loadDiff(version); + return; + } + if (action === 'publish') { + await openPublishModal(version); + return; + } + if (action === 'restore') { + openActionModal('restore', version); + } +} + +async function loadDiff(version) { + const panel = versionState.root.querySelector('#version-diff'); + panel.innerHTML = '
…

Сравниваем расписания

Генерируем занятия обеих версий по всему семестру.

'; + try { + const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`); + renderDiff(diff); + } catch (error) { + panel.innerHTML = `
!

Сравнение недоступно

${escapeHtml(error.message)}

`; + } +} + +function renderDiff(diff) { + const panel = versionState.root.querySelector('#version-diff'); + panel.innerHTML = ` + ${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)} +

Что изменится после публикации

+
+
+${escapeHtml(String(diff.addedLessonCount))}занятий
+
${escapeHtml(String(diff.changedLessonCount))}изменено
+
−${escapeHtml(String(diff.removedLessonCount))}удалено
+
+

Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}

+
+ ${(diff.ruleChanges || []).length + ? diff.ruleChanges.map(ruleChangeCard).join('') + : '

Правила совпадают

Структурных изменений между версиями нет.

'} +
+ `; +} + +function ruleChangeCard(change) { + const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type; + return ` +
+ ${escapeHtml(label)} · ${escapeHtml(change.subjectName)} + ${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')} + ${change.beforeSummary ? `Было: ${escapeHtml(change.beforeSummary)}` : ''} + ${change.afterSummary ? `Стало: ${escapeHtml(change.afterSummary)}` : ''} +
+ `; +} + +function openCreateModal() { + const published = versionState.versions.find(version => version.status === 'PUBLISHED'); + openModal({ + mode: 'create', + version: published, + kicker: 'Изолированная рабочая копия', + title: 'Создать новый черновик', + copy: published + ? `Правила версии ${published.versionNumber} будут скопированы. Пользователи продолжат видеть текущую публикацию.` + : 'Будет создан пустой черновик для первого расписания семестра.', + label: 'Название черновика', + value: '', + submit: 'Создать черновик' + }); +} + +async function openPublishModal(version) { + setStatus('Проверяем целостность черновика перед публикацией…', 'loading'); + try { + const [validation, diff] = await Promise.all([ + api.get(`/api/edu-office/schedule/versions/${version.id}/validate`), + api.get(`/api/edu-office/schedule/versions/${version.id}/diff`) + ]); + const summary = validation.valid + ? `Проверка пройдена: ${validation.ruleCount} правил, ${validation.slotCount} слотов. Занятия: +${diff.addedLessonCount}, ~${diff.changedLessonCount}, −${diff.removedLessonCount}.` + : `Публикация заблокирована: ${(validation.issues || []).join('; ')}`; + openModal({ + mode: 'publish', + version, + valid: validation.valid, + kicker: 'Атомарная публикация', + title: `Опубликовать версию ${version.versionNumber}`, + copy: 'После подтверждения текущая версия целиком уйдёт в архив, а этот черновик станет виден студентам и преподавателям.', + label: 'Причина изменений', + value: '', + submit: 'Опубликовать', + summary + }); + setStatus(validation.valid ? 'Черновик готов к публикации.' : 'Черновик требует исправлений.', validation.valid ? 'success' : 'error'); + } catch (error) { + setStatus(error.message || 'Не удалось проверить черновик.', 'error'); + } +} + +function openActionModal(mode, version) { + openModal({ + mode, + version, + kicker: 'Контролируемый возврат', + title: `Восстановить версию ${version.versionNumber}`, + copy: 'Текущая публикация будет архивирована, а выбранная версия снова станет активной одной транзакцией.', + label: 'Причина восстановления', + value: '', + submit: 'Восстановить' + }); +} + +function openModal(config) { + versionState.modal = config; + const root = versionState.root; + root.querySelector('#version-modal-kicker').textContent = config.kicker; + root.querySelector('#version-modal-title').textContent = config.title; + root.querySelector('#version-modal-copy').textContent = config.copy; + root.querySelector('#version-modal-label').textContent = config.label; + root.querySelector('#version-modal-value').value = config.value || ''; + const summary = root.querySelector('#version-modal-summary'); + summary.hidden = !config.summary; + summary.textContent = config.summary || ''; + const submit = root.querySelector('#version-modal-submit'); + submit.textContent = config.submit; + submit.disabled = config.valid === false; + root.querySelector('#version-modal').hidden = false; + root.querySelector('#version-modal-value').focus(); +} + +function closeModal() { + const modal = versionState?.root.querySelector('#version-modal'); + if (modal) modal.hidden = true; + if (versionState) versionState.modal = null; +} + +async function submitModal(event) { + event.preventDefault(); + const config = versionState.modal; + if (!config || config.valid === false) return; + const value = versionState.root.querySelector('#version-modal-value').value.trim(); + if (!value) { + setStatus(config.mode === 'create' ? 'Укажите название черновика.' : 'Укажите причину операции.', 'error'); + return; + } + const submit = versionState.root.querySelector('#version-modal-submit'); + submit.disabled = true; + submit.textContent = 'Выполняем…'; + try { + if (config.mode === 'create') { + const created = await api.post('/api/edu-office/schedule/versions', { + semesterId: Number(selectedSemesterId()), + name: value, + basedOnVersionId: config.version?.id || null + }); + localStorage.setItem('magistr.schedule.versionId', String(created.id)); + setStatus(`Черновик «${created.name}» создан.`, 'success'); + } else { + await api.post( + `/api/edu-office/schedule/versions/${config.version.id}/${config.mode}`, + { reason: value } + ); + setStatus(config.mode === 'publish' ? 'Версия опубликована атомарно.' : 'Предыдущая версия восстановлена.', 'success'); + } + closeModal(); + await loadWorkspace(); + } catch (error) { + setStatus(error.message || 'Операцию не удалось выполнить.', 'error'); + submit.disabled = false; + submit.textContent = config.submit; + } +} + +function navigateTo(tab) { + document.querySelector(`.nav-item[data-tab="${tab}"]`)?.click(); +} + +function selectedSemesterId() { + return versionState?.root.querySelector('#version-semester')?.value || ''; +} + +function setStatus(message, tone = '') { + const status = versionState?.root.querySelector('#version-status'); + if (!status) return; + status.textContent = message; + status.dataset.tone = tone; +} + +export function historyActionLabel(action) { + return { + CREATED: 'Создан черновик', + PUBLISHED: 'Опубликовано', + ARCHIVED: 'Архивировано', + RESTORED: 'Восстановлено' + }[action] || action; +} + +function semesterLabel(semester) { + const type = semester.semesterType === 'autumn' ? 'Осенний' : 'Весенний'; + return `${semester.academicYearTitle} · ${type} семестр`; +} + +function formatDateTime(value) { + if (!value) return 'дата не указана'; + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' + }).format(new Date(value)); +} + +function localIsoDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 78d0165..df4c876 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -67,6 +67,9 @@ export async function initSchedule() { const ruleIdInput = document.getElementById('schedule-rule-id'); const ruleSubjectSelect = document.getElementById('schedule-rule-subject'); const ruleSemesterSelect = document.getElementById('schedule-rule-semester'); + const scheduleVersionSelect = document.getElementById('schedule-version-select'); + const scheduleVersionNote = document.getElementById('schedule-version-note'); + const scheduleVersionManage = document.getElementById('schedule-version-manage'); const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, { hours: document.getElementById(config.hoursInputId), startWeek: document.getElementById(config.startWeekInputId) @@ -125,6 +128,7 @@ export async function initSchedule() { let academicYears = []; let semesters = []; let approvedTeacherPreferences = []; + let scheduleVersions = []; let selectedVisualYearId = ''; let selectedVisualSemesterId = ''; let selectedVisualGroupIds = new Set(); @@ -139,8 +143,11 @@ export async function initSchedule() { try { await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]); + await loadScheduleVersions(); renderRuleGroups([]); renderRuleSlots([{}]); + syncRuleSemesterWithVersion(); + setRuleEditorEnabled(Boolean(selectedScheduleVersion())); await loadRules(); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); @@ -148,6 +155,17 @@ export async function initSchedule() { function bindEvents() { refreshRulesButton?.addEventListener('click', loadRules); + scheduleVersionSelect?.addEventListener('change', async () => { + if (scheduleVersionSelect.value) { + localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value); + } + resetRuleForm(); + syncRuleSemesterWithVersion(); + await loadRules(); + }); + scheduleVersionManage?.addEventListener('click', () => { + document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click(); + }); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); visualToggleButton?.addEventListener('click', toggleVisualDrawer); @@ -284,10 +302,62 @@ export async function initSchedule() { updatePreferenceHints(); } + async function loadScheduleVersions() { + const responses = await Promise.all(semesters.map(semester => + api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`) + )); + scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT'); + const storedId = localStorage.getItem('magistr.schedule.versionId'); + const stored = scheduleVersions.find(version => String(version.id) === storedId); + const selected = stored || scheduleVersions[0] || null; + scheduleVersionSelect.innerHTML = scheduleVersions.length + ? scheduleVersions.map(version => ` + + `).join('') + : ''; + scheduleVersionSelect.value = selected ? String(selected.id) : ''; + if (selected) { + localStorage.setItem('magistr.schedule.versionId', String(selected.id)); + scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`; + } else { + scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».'; + } + setRuleEditorEnabled(Boolean(selected)); + } + + function selectedScheduleVersion() { + return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null; + } + + function syncRuleSemesterWithVersion() { + const version = selectedScheduleVersion(); + if (!version) return; + ruleSemesterSelect.value = String(version.semesterId); + ruleSemesterSelect.disabled = true; + syncSelects(ruleSemesterSelect); + } + + function setRuleEditorEnabled(enabled) { + document.getElementById('schedule-rule-editor')?.classList.toggle('is-disabled', !enabled); + ruleForm.querySelectorAll('input, select, button').forEach(control => { + control.disabled = !enabled; + }); + if (enabled) ruleSemesterSelect.disabled = true; + } + async function loadRules() { rulesTbody.innerHTML = 'Загрузка...'; + const version = selectedScheduleVersion(); + if (!version) { + rules = []; + rulesTbody.innerHTML = 'Создайте черновик в разделе «Версии расписания»'; + setVisualState('Нет выбранного черновика'); + return; + } try { - rules = await api.get('/api/admin/schedule-rules'); + rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`); if (!visualPeriodTouched) { selectDefaultVisualPeriod(); } @@ -1244,6 +1314,7 @@ export async function initSchedule() { id: Number(rule.id), subjectId: Number(rule.subjectId), semesterId: Number(rule.semesterId), + scheduleVersionId: Number(rule.scheduleVersionId || scheduleVersionSelect.value), lectureAcademicHours: Number(rule.lectureAcademicHours || 0), laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0), practiceAcademicHours: Number(rule.practiceAcademicHours || 0), @@ -1319,6 +1390,7 @@ export async function initSchedule() { const groupIds = selectedRuleGroupIds(); const slots = readRuleSlots(true); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); + if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); const typeLimits = readTypeLimits(); validateTypeLimits(typeLimits, slots); @@ -1328,6 +1400,7 @@ export async function initSchedule() { id: ruleIdInput.value ? Number(ruleIdInput.value) : null, subjectId: Number(ruleSubjectSelect.value), semesterId: Number(ruleSemesterSelect.value), + scheduleVersionId: Number(scheduleVersionSelect.value), ...typeLimits, groupIds, slots @@ -1343,6 +1416,7 @@ export async function initSchedule() { syncSelects(ruleSubjectSelect, ruleSemesterSelect); renderRuleGroups(rule.groupIds || []); renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]); + syncRuleSemesterWithVersion(); hideAlert('schedule-rule-alert'); ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -1354,6 +1428,7 @@ export async function initSchedule() { fillTypeLimits({}); renderRuleGroups([]); renderRuleSlots([{}]); + syncRuleSemesterWithVersion(); syncSelects(ruleSubjectSelect, ruleSemesterSelect); hideAlert('schedule-rule-alert'); } diff --git a/frontend/admin/views/schedule-quality.html b/frontend/admin/views/schedule-quality.html new file mode 100644 index 0000000..5f61fbe --- /dev/null +++ b/frontend/admin/views/schedule-quality.html @@ -0,0 +1,98 @@ +
+
+
+ Диагностика · публикация или черновик +

Качество видно до перестановок

+

+ Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения + пожеланий. Черновик можно оценить до публикации, не показывая промежуточное + расписание студентам и преподавателям. +

+
+
+ + + + + +
+
+ +
+ Выберите семестр, чтобы получить объяснимую оценку. +
+ + +
diff --git a/frontend/admin/views/schedule-versions.html b/frontend/admin/views/schedule-versions.html new file mode 100644 index 0000000..2024e54 --- /dev/null +++ b/frontend/admin/views/schedule-versions.html @@ -0,0 +1,79 @@ +
+
+
+ Контур публикации +

Расписание меняется за кулисами

+

Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.

+
+
+ + + +
+
+ +
Загружаем контур версий…
+ +
+ +
+
+
+
+ Подготовка +

Черновики

+
+ 0 +
+
+
+ + +
+ +
+
+
+ Возврат без паники +

Архив публикаций

+
+
+
+
+ +
+
+
+ Неизменяемый след +

Журнал действий

+
+
+
+
+ + +
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index a1d94df..dcfd8f7 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,4 +1,19 @@ -
+
+
+ Изолированный контекст +

Рабочий черновик

+

Выберите черновик: только его правила можно изменять в конструкторе.

+
+
+ + + +
+
+ +

Новое правило расписания

diff --git a/frontend/package.json b/frontend/package.json index 05baef4..ab8fa96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "build": "npm run build:vendor", "build:vendor": "node scripts/build-vendor.mjs", "test": "node --test tests/*.test.mjs", - "check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/views/teacher-absences.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs" + "check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check admin/js/views/schedule.js && node --check admin/js/views/schedule-versions.js && node --check admin/js/views/teacher-absences.js && node --check admin/js/views/schedule-quality.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs" }, "devDependencies": { "@opentelemetry/api": "1.9.0", diff --git a/frontend/tests/schedule-quality.test.mjs b/frontend/tests/schedule-quality.test.mjs new file mode 100644 index 0000000..bdb44b7 --- /dev/null +++ b/frontend/tests/schedule-quality.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + filterQualityProblems, + formatMetricValue, + qualityBand +} from '../admin/js/views/schedule-quality.js'; + +test('оценка качества получает понятный диапазон', () => { + assert.equal(qualityBand(94).code, 'excellent'); + assert.equal(qualityBand(80).code, 'stable'); + assert.equal(qualityBand(60).code, 'attention'); + assert.equal(qualityBand(40).code, 'critical'); +}); + +test('фильтр проблем одновременно учитывает важность и категорию', () => { + const problems = [ + { type: 'GROUP_GAP', severity: 'HIGH' }, + { type: 'ROOM_OVERSIZED', severity: 'LOW' }, + { type: 'TEACHER_SOFT_UNWANTED', severity: 'HIGH' } + ]; + + assert.deepEqual( + filterQualityProblems(problems, 'HIGH', 'GAPS'), + [problems[0]] + ); + assert.deepEqual( + filterQualityProblems(problems, '', 'ROOM'), + [problems[1]] + ); +}); + +test('метрики и вкладка анализа подключены к SPA ответственных ролей', async () => { + assert.equal(formatMetricValue({ value: 93.4, unit: '%' }), '93,4%'); + const [capabilities, main, index, view, script] = await Promise.all([ + readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/index.html', import.meta.url), 'utf8'), + readFile(new URL('../admin/views/schedule-quality.html', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8') + ]); + + assert.match(capabilities, /ADMIN:[\s\S]*schedule-quality/); + assert.match(capabilities, /EDUCATION_OFFICE:[\s\S]*schedule-quality/); + assert.doesNotMatch(capabilities, /DEPARTMENT:[\s\S]*schedule-quality/); + assert.match(main, /'schedule-quality':[\s\S]*schedule-quality\.js/); + assert.match(index, /data-tab="schedule-quality"/); + assert.match(view, /Локальные улучшения/); + assert.match(script, /Применить после подтверждения/); +}); diff --git a/frontend/tests/schedule-versions.test.mjs b/frontend/tests/schedule-versions.test.mjs new file mode 100644 index 0000000..c9ca997 --- /dev/null +++ b/frontend/tests/schedule-versions.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import { historyActionLabel } from '../admin/js/views/schedule-versions.js'; +import { versionStatusLabel } from '../admin/js/views/schedule-quality.js'; + +test('журнал версий переводит системные действия на русский язык', () => { + assert.equal(historyActionLabel('CREATED'), 'Создан черновик'); + assert.equal(historyActionLabel('PUBLISHED'), 'Опубликовано'); + assert.equal(historyActionLabel('RESTORED'), 'Восстановлено'); +}); + +test('анализ качества различает черновик, публикацию и архив', () => { + assert.equal(versionStatusLabel('DRAFT'), 'Черновик'); + assert.equal(versionStatusLabel('PUBLISHED'), 'Опубликована'); + assert.equal(versionStatusLabel('ARCHIVED'), 'Архив'); +}); + +test('контур версий подключён к SPA, а конструктор сохраняет правила в выбранный черновик', async () => { + const [main, roles, constructor, quality, index] = await Promise.all([ + readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8'), + readFile(new URL('../admin/index.html', import.meta.url), 'utf8') + ]); + + assert.match(main, /'schedule-versions'/); + assert.match(roles, /schedule-versions/); + assert.match(index, /Версии расписания/); + assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/); + assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/); +}); diff --git a/graphify-out/memory/query_20260811_135409_а_теперь_выполни_задачу_номер_2_и_сделай_чтобы_кач.md b/graphify-out/memory/query_20260811_135409_а_теперь_выполни_задачу_номер_2_и_сделай_чтобы_кач.md new file mode 100644 index 0000000..59a3cb3 --- /dev/null +++ b/graphify-out/memory/query_20260811_135409_а_теперь_выполни_задачу_номер_2_и_сделай_чтобы_кач.md @@ -0,0 +1,23 @@ +--- +type: "query" +date: "2026-08-11T13:54:09.086312+00:00" +question: "а теперь выполни задачу номер 2 и сделай чтобы качество черновиков тоже можно было проверять" +contributor: "graphify" +outcome: "useful" +source_nodes: ["ScheduleGeneratorService", "ScheduleRuleService"] +--- + +# Q: а теперь выполни задачу номер 2 и сделай чтобы качество черновиков тоже можно было проверять + +## Answer + +Реализован жизненный цикл расписания DRAFT/PUBLISHED/ARCHIVED: отдельные версии семестра, копирование правил, валидация, diff правил и сформированных занятий, атомарная публикация, восстановление и аудит. Публичная генерация и overrides ограничены опубликованной версией. Анализ качества принимает versionId и строит черновые/архивные версии напрямую по их правилам; рекомендации с изменениями доступны только опубликованной версии. UI получил вкладку версий, контекст черновика в конструкторе и выбор версии в анализе качества. Схема объединена в V1, отдельных V2/V3 нет. + +## Outcome + +- Signal: useful + +## Source Nodes + +- ScheduleGeneratorService +- ScheduleRuleService \ No newline at end of file