задачи 2 и 8

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleVersionHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ScheduleVersionHistoryRepository extends JpaRepository<ScheduleVersionHistory, Long> {
@Query("""
select history
from ScheduleVersionHistory history
left join fetch history.version version
left join fetch history.actor
where version.semester.id = :semesterId
order by history.createdAt desc, history.id desc
""")
List<ScheduleVersionHistory> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
-- Реестр временных отсутствий преподавателей и журнал решений мастера замены.
CREATE TABLE teacher_absences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
requested_by BIGINT REFERENCES users(id),
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date),
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
CONSTRAINT chk_teacher_absences_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
)
);
CREATE INDEX idx_teacher_absences_teacher_period
ON teacher_absences(teacher_id, start_date, end_date);
CREATE INDEX idx_teacher_absences_status
ON teacher_absences(status);
CREATE TABLE teacher_absence_decisions (
id BIGSERIAL PRIMARY KEY,
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
resolution VARCHAR(30) NOT NULL,
decision_status VARCHAR(20) NOT NULL,
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
comment TEXT,
decided_by BIGINT REFERENCES users(id),
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
absence_id,
base_rule_slot_id,
lesson_date
),
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
),
CONSTRAINT chk_teacher_absence_decision_status CHECK (
decision_status IN ('APPLIED', 'REJECTED')
)
);
CREATE INDEX idx_teacher_absence_decisions_absence
ON teacher_absence_decisions(absence_id, lesson_date);
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 | Назначение |

View File

@@ -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`:

View File

@@ -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`, изменённая контрольная сумма вызовет
ошибку проверки.
### Полный сброс БД (локально)

View File

@@ -261,6 +261,9 @@ public class AbsenceController {
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в
проекте нет.
### Применение

View File

@@ -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 уже выявляет накладки и превышение вместимости, а вкладка загруженности показывает
занятые и свободные слоты. Новый анализатор не должен дублировать эти экраны: его ценность —
единая объяснимая оценка, мягкие критерии качества и рекомендации по улучшению.

View File

@@ -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`.

View File

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

View File

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

View File

@@ -13,6 +13,8 @@
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
<link rel="stylesheet" href="css/teacher-absences.css">
<link rel="stylesheet" href="css/schedule-quality.css">
<link rel="stylesheet" href="css/schedule-versions.css">
</head>
<body>
@@ -87,6 +89,27 @@
</svg>
<span>Конструктор правил</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-versions">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
<path d="M16 3l4 4-4 4" />
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
<path d="M8 21l-4-4 4-4" />
</svg>
<span>Версии расписания</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-quality">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19V9" />
<path d="M10 19V5" />
<path d="M16 19v-7" />
<path d="M22 19V3" />
<path d="M2 19h20" />
</svg>
<span>Качество расписания</span>
</a>
<a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3z"></path>

View File

@@ -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',

View File

@@ -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']
},

View File

@@ -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 => `
<option value="${escapeHtml(String(semester.id))}">
${escapeHtml(semesterLabel(semester))}
</option>
`).join('');
if (!semesters?.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
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 = '<option value="">Ошибка загрузки</option>';
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 = '<option value="">Загрузка версий…</option>';
try {
qualityState.versions = await api.get(
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
);
select.innerHTML = qualityState.versions.map(version => `
<option value="${escapeHtml(String(version.id))}">
v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} · ${escapeHtml(versionStatusLabel(version.status))}
</option>
`).join('');
if (!qualityState.versions.length) {
select.innerHTML = '<option value="">Версии не созданы</option>';
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 = '<option value="">Ошибка загрузки</option>';
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 = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
.map((metric, index) => `
<article class="quality-metric quality-metric-${index + 1}">
<span class="quality-metric-index">${String(index + 1).padStart(2, '0')}</span>
<strong>${escapeHtml(formatMetricValue(metric))}</strong>
<h4>${escapeHtml(metric.label)}</h4>
<p>${escapeHtml(metric.explanation)}</p>
<span class="quality-metric-penalty">штраф ${escapeHtml(String(metric.penalty))}</span>
</article>
`).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 = `
<div class="quality-empty-state">
<strong>По выбранному фильтру проблем нет</strong>
<span>Измените уровень важности или категорию.</span>
</div>
`;
return;
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId;
return `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
<span class="quality-severity">${escapeHtml(severityLabel(problem.severity))}</span>
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<small>
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
Подобрать улучшения
</button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
</div>
</article>
`;
}).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 = `
<div class="quality-recommendations-loading">
<span class="quality-loader"></span>
<strong>Проверяем варианты</strong>
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
</div>
`;
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 = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span>
<h3>${escapeHtml(problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
</div>
<div class="quality-candidates">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
</div>
<p class="quality-recommendations-note">
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
</p>
`;
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 `
<article class="quality-candidate ${impactClass}">
<div class="quality-candidate-top">
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
<strong>${escapeHtml(candidate.label)}</strong>
</div>
<p>${escapeHtml(candidate.description)}</p>
<div class="quality-candidate-impact">
<span>${escapeHtml(impact)}</span>
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
</div>
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
Применить после подтверждения
</button>
</article>
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
<div class="quality-change-list is-${tone}">
<strong>${escapeHtml(label)}</strong>
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
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 = `
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(message)}</p>
</div>
`;
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] || 'Версия';
}

View File

@@ -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 => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterLabel(semester))}</option>
`).join('');
if (!versionState.semesters.length) {
select.innerHTML = '<option value="">Семестры не настроены</option>';
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 = `
<div class="version-live-mark">ПУСТО</div>
<div><h3>Опубликованной версии нет</h3><p>Создайте первый черновик и опубликуйте его после проверки.</p></div>
`;
return;
}
container.innerHTML = `
<div class="version-live-mark">LIVE</div>
<div>
<span class="version-eyebrow">Сейчас видят пользователи</span>
<h3>${escapeHtml(version.name)} · версия ${escapeHtml(String(version.versionNumber))}</h3>
<p>${escapeHtml(version.changeReason || 'Причина первой публикации не указана')}</p>
<div class="version-stats">
<span>${escapeHtml(String(version.ruleCount))} правил</span>
<span>${escapeHtml(String(version.slotCount))} слотов</span>
<span>${escapeHtml(formatDateTime(version.publishedAt))}</span>
</div>
</div>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div>
`;
}
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 = `
<div class="version-empty">
<span>+</span><h3>Нет активных черновиков</h3>
<p>Создайте копию опубликованной версии — её изменения останутся невидимыми до публикации.</p>
</div>
`;
return;
}
container.innerHTML = drafts.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<div class="version-card-top">
<div>
<span class="version-status-chip">Черновик · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
</div>
</div>
<p>Основа: ${version.basedOnVersionId ? `версия #${escapeHtml(String(version.basedOnVersionId))}` : 'пустое расписание'}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · ${escapeHtml(String(version.slotCount))} слотов · ${escapeHtml(formatDateTime(version.createdAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="publish" data-id="${version.id}">Опубликовать</button>
</div>
</article>
`).join('');
}
function renderArchive(versions) {
const container = versionState.root.querySelector('#version-archive');
if (!versions.length) {
container.innerHTML = '<p class="version-empty-note">Предыдущих публикаций пока нет.</p>';
return;
}
container.innerHTML = versions.map(version => `
<article class="version-card" data-number="${escapeHtml(String(version.versionNumber).padStart(2, '0'))}">
<span class="version-status-chip">Архив · v${escapeHtml(String(version.versionNumber))}</span>
<h4>${escapeHtml(version.name)}</h4>
<p>${escapeHtml(version.changeReason || 'Причина не указана')}</p>
<small>${escapeHtml(String(version.ruleCount))} правил · публикация ${escapeHtml(formatDateTime(version.publishedAt))}</small>
<div class="version-card-actions">
<button type="button" class="btn btn-sm btn-secondary" data-action="diff" data-id="${version.id}">Сравнить</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Качество</button>
<button type="button" class="btn btn-sm btn-danger" data-action="restore" data-id="${version.id}">Восстановить</button>
</div>
</article>
`).join('');
}
function renderHistory(history) {
const container = versionState.root.querySelector('#version-history');
if (!history.length) {
container.innerHTML = '<p class="version-empty-note">Действия ещё не зафиксированы.</p>';
return;
}
container.innerHTML = history.map(item => `
<article class="version-history-row">
<time>${escapeHtml(formatDateTime(item.createdAt))}</time>
<div>
<strong>${escapeHtml(historyActionLabel(item.action))}: v${escapeHtml(String(item.versionNumber))} · ${escapeHtml(item.versionName)}</strong>
<p>${escapeHtml(item.reason || 'Без комментария')}</p>
</div>
<span class="version-status-chip">${escapeHtml(item.actorName || 'Система')}</span>
</article>
`).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 = '<div class="version-empty"><span>…</span><h3>Сравниваем расписания</h3><p>Генерируем занятия обеих версий по всему семестру.</p></div>';
try {
const diff = await api.get(`/api/edu-office/schedule/versions/${version.id}/diff`);
renderDiff(diff);
} catch (error) {
panel.innerHTML = `<div class="version-empty"><span>!</span><h3>Сравнение недоступно</h3><p>${escapeHtml(error.message)}</p></div>`;
}
}
function renderDiff(diff) {
const panel = versionState.root.querySelector('#version-diff');
panel.innerHTML = `
<span class="version-eyebrow">${escapeHtml(diff.baseVersionLabel)} → ${escapeHtml(diff.targetVersionLabel)}</span>
<h3>Что изменится после публикации</h3>
<div class="version-diff-score">
<div><strong>+${escapeHtml(String(diff.addedLessonCount))}</strong><span>занятий</span></div>
<div><strong>${escapeHtml(String(diff.changedLessonCount))}</strong><span>изменено</span></div>
<div><strong>−${escapeHtml(String(diff.removedLessonCount))}</strong><span>удалено</span></div>
</div>
<p class="version-diff-caption">Правила: +${escapeHtml(String(diff.addedRuleCount))} · ~${escapeHtml(String(diff.changedRuleCount))} · −${escapeHtml(String(diff.removedRuleCount))} · без изменений ${escapeHtml(String(diff.unchangedRuleCount))}</p>
<div class="version-change-list">
${(diff.ruleChanges || []).length
? diff.ruleChanges.map(ruleChangeCard).join('')
: '<div class="version-empty"><h3>Правила совпадают</h3><p>Структурных изменений между версиями нет.</p></div>'}
</div>
`;
}
function ruleChangeCard(change) {
const label = { ADDED: 'Добавлено', REMOVED: 'Удалено', CHANGED: 'Изменено' }[change.type] || change.type;
return `
<article class="version-change" data-type="${escapeHtml(change.type)}">
<strong>${escapeHtml(label)} · ${escapeHtml(change.subjectName)}</strong>
<span>${escapeHtml((change.groupNames || []).join(', ') || 'Группы не указаны')}</span>
${change.beforeSummary ? `<span>Было: ${escapeHtml(change.beforeSummary)}</span>` : ''}
${change.afterSummary ? `<span>Стало: ${escapeHtml(change.afterSummary)}</span>` : ''}
</article>
`;
}
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}`;
}

View File

@@ -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 => `
<option value="${version.id}">
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
</option>
`).join('')
: '<option value="">Черновики не созданы</option>';
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 = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion();
if (!version) {
rules = [];
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
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');
}

View File

@@ -0,0 +1,98 @@
<section class="quality-lab" aria-labelledby="quality-lab-title">
<header class="quality-hero">
<div class="quality-hero-copy">
<span class="quality-kicker">Диагностика · публикация или черновик</span>
<h2 id="quality-lab-title">Качество видно до перестановок</h2>
<p>
Анализатор находит окна, перегруженные дни, неудачные аудитории и нарушения
пожеланий. Черновик можно оценить до публикации, не показывая промежуточное
расписание студентам и преподавателям.
</p>
</div>
<div class="quality-run-panel">
<label for="quality-semester">Семестр</label>
<select id="quality-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
</select>
<label for="quality-version">Версия</label>
<select id="quality-version" data-native-select="true">
<option value="">Сначала выберите семестр</option>
</select>
<button type="button" class="btn btn-md btn-primary" id="quality-run">
Выполнить анализ
</button>
</div>
</header>
<div class="quality-status" id="quality-status" role="status" aria-live="polite">
Выберите семестр, чтобы получить объяснимую оценку.
</div>
<div class="quality-results" id="quality-results" hidden>
<section class="quality-scoreboard" aria-label="Итог анализа">
<div class="quality-score-orbit" id="quality-score-orbit">
<svg class="quality-score-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle class="quality-score-ring-track" cx="18" cy="18" r="15.9155"></circle>
<circle class="quality-score-ring-value" id="quality-score-ring-value"
cx="18" cy="18" r="15.9155" pathLength="100"></circle>
</svg>
<div class="quality-score-inner">
<strong id="quality-score">—</strong>
<span>из 100</span>
</div>
</div>
<div class="quality-score-copy">
<span class="quality-score-band" id="quality-score-band">Нет данных</span>
<h3 id="quality-semester-label">Семестр</h3>
<p id="quality-score-summary"></p>
<div class="quality-score-meta" id="quality-score-meta"></div>
</div>
</section>
<section class="quality-metrics" id="quality-metrics" aria-label="Метрики качества"></section>
<section class="quality-workbench">
<div class="quality-problems-column">
<div class="quality-section-heading">
<div>
<span class="quality-kicker">Карта неудобств</span>
<h3>Что снижает оценку</h3>
</div>
<span class="quality-problem-total" id="quality-problem-total"></span>
</div>
<div class="quality-filters">
<label>
Важность
<select id="quality-severity-filter" data-native-select="true">
<option value="">Все уровни</option>
<option value="CRITICAL">Критические</option>
<option value="HIGH">Высокие</option>
<option value="MEDIUM">Средние</option>
<option value="LOW">Низкие</option>
</select>
</label>
<label>
Категория
<select id="quality-type-filter" data-native-select="true">
<option value="">Все категории</option>
<option value="GAPS">Окна и компактность</option>
<option value="LOAD">Дневная нагрузка</option>
<option value="ROOM">Аудитории</option>
<option value="PREFERENCE">Пожелания</option>
</select>
</label>
</div>
<div class="quality-problems" id="quality-problems"></div>
</div>
<aside class="quality-recommendations" id="quality-recommendations" aria-live="polite">
<div class="quality-recommendations-empty">
<span class="quality-recommendations-mark">↗</span>
<h3>Локальные улучшения</h3>
<p>Выберите проблему, чтобы проверить свободные слоты и аудитории.</p>
</div>
</aside>
</section>
</div>
</section>

View File

@@ -0,0 +1,79 @@
<section class="version-studio" aria-labelledby="version-studio-title">
<header class="version-hero">
<div>
<span class="version-eyebrow">Контур публикации</span>
<h2 id="version-studio-title">Расписание меняется за кулисами</h2>
<p>Черновики изолированы от студентов и преподавателей. Сравните правила и занятия, проверьте целостность и опубликуйте весь пакет одной операцией.</p>
</div>
<div class="version-hero-controls">
<label for="version-semester">Рабочий семестр</label>
<select id="version-semester" data-native-select="true">
<option value="">Загрузка семестров…</option>
</select>
<button type="button" class="btn btn-md btn-primary" id="version-create">Новый черновик</button>
</div>
</header>
<div class="version-status" id="version-status" role="status" aria-live="polite">Загружаем контур версий…</div>
<section class="version-current" id="version-current" aria-label="Опубликованная версия"></section>
<div class="version-workbench">
<section class="version-drafts-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Подготовка</span>
<h3>Черновики</h3>
</div>
<span id="version-draft-count">0</span>
</div>
<div class="version-draft-list" id="version-drafts"></div>
</section>
<aside class="version-diff-panel" id="version-diff">
<div class="version-empty">
<span>Δ</span>
<h3>Сравнение версий</h3>
<p>Выберите «Сравнить» у черновика, чтобы увидеть изменения правил и сформированных занятий.</p>
</div>
</aside>
</div>
<section class="version-archive-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Возврат без паники</span>
<h3>Архив публикаций</h3>
</div>
</div>
<div class="version-archive-list" id="version-archive"></div>
</section>
<section class="version-history-panel">
<div class="version-section-head">
<div>
<span class="version-eyebrow">Неизменяемый след</span>
<h3>Журнал действий</h3>
</div>
</div>
<div class="version-history" id="version-history"></div>
</section>
<div class="version-modal" id="version-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<button type="button" class="version-modal-close" id="version-modal-close" aria-label="Закрыть">×</button>
<span class="version-eyebrow" id="version-modal-kicker">Новая операция</span>
<h3 id="version-modal-title">Версия расписания</h3>
<p id="version-modal-copy"></p>
<form id="version-modal-form">
<label for="version-modal-value" id="version-modal-label">Название</label>
<textarea id="version-modal-value" rows="3" maxlength="2000" required></textarea>
<div class="version-modal-summary" id="version-modal-summary" hidden></div>
<div class="version-modal-actions">
<button type="button" class="btn btn-md btn-ghost" id="version-modal-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="version-modal-submit">Продолжить</button>
</div>
</form>
</div>
</div>
</section>

View File

@@ -1,4 +1,19 @@
<div class="card create-card">
<section class="version-constructor-bar" id="schedule-version-context">
<div>
<span class="version-eyebrow">Изолированный контекст</span>
<h2>Рабочий черновик</h2>
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p>
</div>
<div class="version-constructor-controls">
<label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true">
<option value="">Загрузка черновиков…</option>
</select>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Управление версиями</button>
</div>
</section>
<div class="card create-card" id="schedule-rule-editor">
<div class="card-header-row">
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>

View File

@@ -9,7 +9,7 @@
"build": "npm run build:vendor",
"build:vendor": "node scripts/build-vendor.mjs",
"test": "node --test tests/*.test.mjs",
"check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check 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",

View File

@@ -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, /Применить после подтверждения/);
});

View File

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

View File

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