задачи 2 и 8
This commit is contained in:
@@ -60,6 +60,8 @@ final class DatabaseConstraintViolationMapper {
|
||||
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
||||
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
||||
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
||||
conflict("uq_schedule_versions_number", "Версия с таким номером уже существует в семестре"),
|
||||
conflict("uq_schedule_versions_published_semester", "Для семестра уже опубликована другая версия расписания"),
|
||||
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
||||
conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
|
||||
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
|
||||
import com.magistr.app.dto.ScheduleQualityRecommendationsDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleQualityService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/edu-office/schedule/quality")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleQualityController {
|
||||
|
||||
private final ScheduleQualityService scheduleQualityService;
|
||||
|
||||
public ScheduleQualityController(ScheduleQualityService scheduleQualityService) {
|
||||
this.scheduleQualityService = scheduleQualityService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ScheduleQualityAnalysisDto analyze(
|
||||
@RequestParam Long semesterId,
|
||||
@RequestParam(name = "versionId", required = false) Long versionId
|
||||
) {
|
||||
return scheduleQualityService.analyze(semesterId, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/recommendations")
|
||||
public ScheduleQualityRecommendationsDto recommendations(
|
||||
@RequestParam Long semesterId,
|
||||
@RequestParam(name = "versionId", required = false) Long versionId,
|
||||
@RequestParam Long scheduleRuleSlotId,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate lessonDate
|
||||
) {
|
||||
return scheduleQualityService.recommendations(semesterId, versionId, scheduleRuleSlotId, lessonDate);
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,9 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
|
||||
@RequestParam(name = "groupId", required = false) Long groupId) {
|
||||
return scheduleRuleService.getAll(semesterId, groupId);
|
||||
@RequestParam(name = "groupId", required = false) Long groupId,
|
||||
@RequestParam(name = "versionId", required = false) Long versionId) {
|
||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@@ -46,6 +47,7 @@ public class ScheduleRuleAdminController {
|
||||
@PostMapping
|
||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||
try {
|
||||
requireDraftVersion(request);
|
||||
return ResponseEntity.ok(scheduleRuleService.create(request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
@@ -55,6 +57,7 @@ public class ScheduleRuleAdminController {
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
|
||||
try {
|
||||
requireDraftVersion(request);
|
||||
return ResponseEntity.ok(scheduleRuleService.update(id, request));
|
||||
} catch (ScheduleRuleConflictException exception) {
|
||||
return conflictResponse(exception);
|
||||
@@ -77,4 +80,10 @@ public class ScheduleRuleAdminController {
|
||||
body.put("conflictReasons", exception.getConflictReasons());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
|
||||
}
|
||||
|
||||
private void requireDraftVersion(ScheduleRuleDto request) {
|
||||
if (request == null || request.scheduleVersionId() == null) {
|
||||
throw new IllegalArgumentException("Выберите черновик расписания");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleVersionActionDto;
|
||||
import com.magistr.app.dto.ScheduleVersionCreateDto;
|
||||
import com.magistr.app.dto.ScheduleVersionDiffDto;
|
||||
import com.magistr.app.dto.ScheduleVersionDto;
|
||||
import com.magistr.app.dto.ScheduleVersionHistoryDto;
|
||||
import com.magistr.app.dto.ScheduleVersionValidationDto;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleVersionService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/edu-office/schedule/versions")
|
||||
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
|
||||
public class ScheduleVersionController {
|
||||
|
||||
private final ScheduleVersionService service;
|
||||
|
||||
public ScheduleVersionController(ScheduleVersionService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ScheduleVersionDto> getVersions(@RequestParam("semesterId") Long semesterId) {
|
||||
return service.getVersions(semesterId);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ScheduleVersionDto getVersion(@PathVariable("id") Long id) {
|
||||
return service.getVersion(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ScheduleVersionDto> createDraft(@RequestBody ScheduleVersionCreateDto request) {
|
||||
return ResponseEntity.ok(service.createDraft(request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/validate")
|
||||
public ScheduleVersionValidationDto validate(@PathVariable("id") Long id) {
|
||||
return service.validate(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/diff")
|
||||
public ScheduleVersionDiffDto diff(
|
||||
@PathVariable("id") Long id,
|
||||
@RequestParam(name = "baseVersionId", required = false) Long baseVersionId
|
||||
) {
|
||||
return service.diff(id, baseVersionId);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/publish")
|
||||
public ScheduleVersionDto publish(
|
||||
@PathVariable("id") Long id,
|
||||
@RequestBody ScheduleVersionActionDto request
|
||||
) {
|
||||
return service.publish(id, request == null ? null : request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/restore")
|
||||
public ScheduleVersionDto restore(
|
||||
@PathVariable("id") Long id,
|
||||
@RequestBody ScheduleVersionActionDto request
|
||||
) {
|
||||
return service.restore(id, request == null ? null : request.reason());
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
public List<ScheduleVersionHistoryDto> history(@RequestParam("semesterId") Long semesterId) {
|
||||
return service.getHistory(semesterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
|
||||
public record ScheduleQualityAnalysisDto(
|
||||
Long semesterId,
|
||||
String semesterLabel,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
int score,
|
||||
int rawPenalty,
|
||||
int lessonCount,
|
||||
int totalProblemCount,
|
||||
boolean problemsTruncated,
|
||||
List<Metric> metrics,
|
||||
List<Problem> problems,
|
||||
Long scheduleVersionId,
|
||||
Integer scheduleVersionNumber,
|
||||
String scheduleVersionName,
|
||||
ScheduleVersionStatus scheduleVersionStatus
|
||||
) {
|
||||
public record Metric(
|
||||
String code,
|
||||
String label,
|
||||
double value,
|
||||
String unit,
|
||||
int penalty,
|
||||
String explanation
|
||||
) {
|
||||
}
|
||||
|
||||
public record Problem(
|
||||
String id,
|
||||
String type,
|
||||
String severity,
|
||||
String title,
|
||||
String description,
|
||||
int penalty,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
Integer timeSlotOrder,
|
||||
String subjectName,
|
||||
Long entityId,
|
||||
String entityName,
|
||||
Integer requiredCapacity,
|
||||
Integer classroomCapacity,
|
||||
boolean optimizable
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleQualityRecommendationsDto(
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
String subjectName,
|
||||
int baselineScore,
|
||||
List<Candidate> candidates
|
||||
) {
|
||||
public record Candidate(
|
||||
String id,
|
||||
String action,
|
||||
String label,
|
||||
String description,
|
||||
int scoreDelta,
|
||||
int penaltyDelta,
|
||||
int resultingScore,
|
||||
List<String> improvements,
|
||||
List<String> tradeoffs,
|
||||
ScheduleOverrideDto override
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public record ScheduleRuleDto(
|
||||
Integer practiceStartWeek,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
List<ScheduleRuleSlotDto> slots
|
||||
List<ScheduleRuleSlotDto> slots,
|
||||
Long scheduleVersionId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionActionDto(String reason) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionCreateDto(
|
||||
Long semesterId,
|
||||
String name,
|
||||
Long basedOnVersionId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleVersionDiffDto(
|
||||
Long baseVersionId,
|
||||
String baseVersionLabel,
|
||||
Long targetVersionId,
|
||||
String targetVersionLabel,
|
||||
int addedRuleCount,
|
||||
int removedRuleCount,
|
||||
int changedRuleCount,
|
||||
int unchangedRuleCount,
|
||||
int addedLessonCount,
|
||||
int removedLessonCount,
|
||||
int changedLessonCount,
|
||||
List<RuleChange> ruleChanges
|
||||
) {
|
||||
public record RuleChange(
|
||||
String type,
|
||||
Long versionGroupId,
|
||||
Long beforeRuleId,
|
||||
Long afterRuleId,
|
||||
String subjectName,
|
||||
List<String> groupNames,
|
||||
String beforeSummary,
|
||||
String afterSummary
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ScheduleVersionDto(
|
||||
Long id,
|
||||
Long semesterId,
|
||||
String semesterLabel,
|
||||
Integer versionNumber,
|
||||
String name,
|
||||
ScheduleVersionStatus status,
|
||||
Long basedOnVersionId,
|
||||
Long restoredFromVersionId,
|
||||
String changeReason,
|
||||
Long createdById,
|
||||
String createdByName,
|
||||
Instant createdAt,
|
||||
Long publishedById,
|
||||
String publishedByName,
|
||||
Instant publishedAt,
|
||||
Instant archivedAt,
|
||||
long ruleCount,
|
||||
long slotCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ScheduleVersionHistoryDto(
|
||||
Long id,
|
||||
Long versionId,
|
||||
Integer versionNumber,
|
||||
String versionName,
|
||||
String action,
|
||||
Long actorId,
|
||||
String actorName,
|
||||
String reason,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleVersionValidationDto(
|
||||
Long versionId,
|
||||
boolean valid,
|
||||
long ruleCount,
|
||||
long slotCount,
|
||||
List<String> issues
|
||||
) {
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -23,6 +24,23 @@ public class ScheduleRule {
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "schedule_version_id", nullable = false)
|
||||
private ScheduleVersion scheduleVersion;
|
||||
|
||||
@Column(name = "version_group_id")
|
||||
private Long versionGroupId;
|
||||
|
||||
@Column(name = "change_reason")
|
||||
private String changeReason;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String status = LifecycleEntity.STATUS_ACTIVE;
|
||||
|
||||
@@ -85,6 +103,42 @@ public class ScheduleRule {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public ScheduleVersion getScheduleVersion() {
|
||||
return scheduleVersion;
|
||||
}
|
||||
|
||||
public void setScheduleVersion(ScheduleVersion scheduleVersion) {
|
||||
this.scheduleVersion = scheduleVersion;
|
||||
}
|
||||
|
||||
public Long getVersionGroupId() {
|
||||
return versionGroupId;
|
||||
}
|
||||
|
||||
public void setVersionGroupId(Long versionGroupId) {
|
||||
this.versionGroupId = versionGroupId;
|
||||
}
|
||||
|
||||
public String getChangeReason() {
|
||||
return changeReason;
|
||||
}
|
||||
|
||||
public void setChangeReason(String changeReason) {
|
||||
this.changeReason = changeReason;
|
||||
}
|
||||
|
||||
public User getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
public void setCreatedBy(User createdBy) {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal file
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal file
@@ -0,0 +1,166 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_versions")
|
||||
public class ScheduleVersion {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@Column(name = "version_number", nullable = false)
|
||||
private Integer versionNumber;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String name;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private ScheduleVersionStatus status = ScheduleVersionStatus.DRAFT;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "based_on_version_id")
|
||||
private ScheduleVersion basedOnVersion;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "restored_from_version_id")
|
||||
private ScheduleVersion restoredFromVersion;
|
||||
|
||||
@Column(name = "change_reason")
|
||||
private String changeReason;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by")
|
||||
private User createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "published_by")
|
||||
private User publishedBy;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private Instant publishedAt;
|
||||
|
||||
@Column(name = "archived_at")
|
||||
private Instant archivedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Semester getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(Semester semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public Integer getVersionNumber() {
|
||||
return versionNumber;
|
||||
}
|
||||
|
||||
public void setVersionNumber(Integer versionNumber) {
|
||||
this.versionNumber = versionNumber;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public ScheduleVersionStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(ScheduleVersionStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public ScheduleVersion getBasedOnVersion() {
|
||||
return basedOnVersion;
|
||||
}
|
||||
|
||||
public void setBasedOnVersion(ScheduleVersion basedOnVersion) {
|
||||
this.basedOnVersion = basedOnVersion;
|
||||
}
|
||||
|
||||
public ScheduleVersion getRestoredFromVersion() {
|
||||
return restoredFromVersion;
|
||||
}
|
||||
|
||||
public void setRestoredFromVersion(ScheduleVersion restoredFromVersion) {
|
||||
this.restoredFromVersion = restoredFromVersion;
|
||||
}
|
||||
|
||||
public String getChangeReason() {
|
||||
return changeReason;
|
||||
}
|
||||
|
||||
public void setChangeReason(String changeReason) {
|
||||
this.changeReason = changeReason;
|
||||
}
|
||||
|
||||
public User getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
public void setCreatedBy(User createdBy) {
|
||||
this.createdBy = createdBy;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public User getPublishedBy() {
|
||||
return publishedBy;
|
||||
}
|
||||
|
||||
public void setPublishedBy(User publishedBy) {
|
||||
this.publishedBy = publishedBy;
|
||||
}
|
||||
|
||||
public Instant getPublishedAt() {
|
||||
return publishedAt;
|
||||
}
|
||||
|
||||
public void setPublishedAt(Instant publishedAt) {
|
||||
this.publishedAt = publishedAt;
|
||||
}
|
||||
|
||||
public Instant getArchivedAt() {
|
||||
return archivedAt;
|
||||
}
|
||||
|
||||
public void setArchivedAt(Instant archivedAt) {
|
||||
this.archivedAt = archivedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "schedule_version_history")
|
||||
public class ScheduleVersionHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "version_id", nullable = false)
|
||||
private ScheduleVersion version;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String action;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "actor_id")
|
||||
private User actor;
|
||||
|
||||
@Column
|
||||
private String reason;
|
||||
|
||||
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public ScheduleVersion getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(ScheduleVersion version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public User getActor() {
|
||||
return actor;
|
||||
}
|
||||
|
||||
public void setActor(User actor) {
|
||||
this.actor = actor;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum ScheduleVersionStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
ARCHIVED
|
||||
}
|
||||
@@ -19,12 +19,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
join rule.scheduleVersion version
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
where (o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate)
|
||||
and version.status = 'PUBLISHED'
|
||||
""")
|
||||
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@@ -38,6 +40,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.scheduleVersion version
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
@@ -48,6 +51,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where version.status = 'PUBLISHED'
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findAllWithRegistryDetails();
|
||||
@@ -57,6 +61,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
from ScheduleOverride o
|
||||
join fetch o.baseRuleSlot baseSlot
|
||||
join fetch baseSlot.scheduleRule rule
|
||||
join fetch rule.scheduleVersion version
|
||||
join fetch rule.subject
|
||||
left join fetch rule.groups
|
||||
join fetch rule.semester
|
||||
@@ -67,8 +72,9 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate
|
||||
where (o.lessonDate between :startDate and :endDate
|
||||
or o.targetLessonDate between :startDate and :endDate)
|
||||
and version.status = 'PUBLISHED'
|
||||
order by o.lessonDate, o.id
|
||||
""")
|
||||
List<ScheduleOverride> findForRegistry(
|
||||
@@ -81,12 +87,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
||||
from ScheduleOverride o
|
||||
left join fetch o.baseRuleSlot baseSlot
|
||||
left join fetch baseSlot.scheduleRule rule
|
||||
join rule.scheduleVersion version
|
||||
left join fetch rule.semester
|
||||
left join fetch o.newTimeSlot
|
||||
left join fetch o.newClassroom
|
||||
left join fetch o.newTeacher
|
||||
where o.lessonDate in :dates
|
||||
or o.targetLessonDate in :dates
|
||||
where (o.lessonDate in :dates
|
||||
or o.targetLessonDate in :dates)
|
||||
and version.status = 'PUBLISHED'
|
||||
""")
|
||||
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups g
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
@@ -30,6 +32,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where g.id = :groupId
|
||||
and sem.id = :semesterId
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdAndSemesterId(
|
||||
@@ -41,6 +44,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -54,6 +58,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id = :semesterId
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
||||
@@ -65,6 +70,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.groups matchingGroup
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -78,6 +84,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where matchingGroup.id in :groupIds
|
||||
and sem.id in :semesterIds
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||
@@ -89,6 +96,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.slots teacherSlot
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -102,6 +110,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.lessonType
|
||||
where teacherSlot.teacher.id = :teacherId
|
||||
and sem.id in :semesterIds
|
||||
and version.status = 'PUBLISHED'
|
||||
and r.status <> 'ARCHIVED'
|
||||
""")
|
||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||
@@ -112,6 +121,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
join r.scheduleVersion version
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
@@ -124,6 +134,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.status <> 'ARCHIVED'
|
||||
and version.status = 'PUBLISHED'
|
||||
order by r.id desc
|
||||
""")
|
||||
List<ScheduleRule> findAllWithDetails();
|
||||
@@ -156,12 +167,38 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
@Param("excludeRuleId") Long excludeRuleId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct rule
|
||||
from ScheduleRule rule
|
||||
left join fetch rule.subject
|
||||
left join fetch rule.semester semester
|
||||
left join fetch semester.academicYear
|
||||
left join fetch rule.scheduleVersion version
|
||||
left join fetch rule.groups groups
|
||||
left join fetch rule.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where version.id = :versionId
|
||||
and rule.status <> 'ARCHIVED'
|
||||
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
|
||||
order by rule.id
|
||||
""")
|
||||
List<ScheduleRule> findActiveByVersionIdWithDetails(
|
||||
@Param("versionId") Long versionId,
|
||||
@Param("excludeRuleId") Long excludeRuleId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleVersionHistory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ScheduleVersionHistoryRepository extends JpaRepository<ScheduleVersionHistory, Long> {
|
||||
|
||||
@Query("""
|
||||
select history
|
||||
from ScheduleVersionHistory history
|
||||
left join fetch history.version version
|
||||
left join fetch history.actor
|
||||
where version.semester.id = :semesterId
|
||||
order by history.createdAt desc, history.id desc
|
||||
""")
|
||||
List<ScheduleVersionHistory> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.magistr.app.repository;
|
||||
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ScheduleVersionRepository extends JpaRepository<ScheduleVersion, Long> {
|
||||
|
||||
@Query("""
|
||||
select version
|
||||
from ScheduleVersion version
|
||||
left join fetch version.semester semester
|
||||
left join fetch semester.academicYear
|
||||
left join fetch version.createdBy
|
||||
left join fetch version.publishedBy
|
||||
left join fetch version.basedOnVersion
|
||||
left join fetch version.restoredFromVersion
|
||||
where semester.id = :semesterId
|
||||
order by version.versionNumber desc
|
||||
""")
|
||||
List<ScheduleVersion> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
|
||||
|
||||
@Query("""
|
||||
select version
|
||||
from ScheduleVersion version
|
||||
left join fetch version.semester semester
|
||||
left join fetch semester.academicYear
|
||||
left join fetch version.createdBy
|
||||
left join fetch version.publishedBy
|
||||
left join fetch version.basedOnVersion
|
||||
left join fetch version.restoredFromVersion
|
||||
where version.id = :id
|
||||
""")
|
||||
Optional<ScheduleVersion> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
Optional<ScheduleVersion> findFirstBySemesterIdAndStatus(Long semesterId, ScheduleVersionStatus status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select version from ScheduleVersion version where version.id = :id")
|
||||
Optional<ScheduleVersion> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select version from ScheduleVersion version where version.semester.id = :semesterId order by version.id")
|
||||
List<ScheduleVersion> findBySemesterIdForUpdate(@Param("semesterId") Long semesterId);
|
||||
|
||||
@Query("select coalesce(max(version.versionNumber), 0) from ScheduleVersion version where version.semester.id = :semesterId")
|
||||
int findMaxVersionNumber(@Param("semesterId") Long semesterId);
|
||||
}
|
||||
@@ -67,6 +67,20 @@ public interface TeacherPreferenceRepository extends JpaRepository<TeacherPrefer
|
||||
@Param("semesterId") Long semesterId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select preference
|
||||
from TeacherPreference preference
|
||||
join fetch preference.teacher
|
||||
join fetch preference.semester
|
||||
left join fetch preference.timeSlot slot
|
||||
left join fetch slot.timeSlotScope
|
||||
where preference.semester.id = :semesterId
|
||||
and preference.status = 'APPROVED'
|
||||
order by preference.teacher.id, preference.preferenceType,
|
||||
preference.preferenceDate, preference.dayOfWeek, slot.orderNumber
|
||||
""")
|
||||
List<TeacherPreference> findApprovedForSemester(@Param("semesterId") Long semesterId);
|
||||
|
||||
@Query("""
|
||||
select count(preference) > 0
|
||||
from TeacherPreference preference
|
||||
|
||||
@@ -95,6 +95,30 @@ public class ScheduleGeneratorService {
|
||||
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> buildScheduleForVersion(Long versionId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
if (versionId == null) {
|
||||
throw new IllegalArgumentException("Версия расписания обязательна");
|
||||
}
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findActiveByVersionIdWithDetails(versionId, null);
|
||||
List<StudentGroup> groups = rules.stream()
|
||||
.flatMap(rule -> rule.getGroups().stream())
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
StudentGroup::getId,
|
||||
group -> group,
|
||||
(first, ignored) -> first,
|
||||
LinkedHashMap::new
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.toList();
|
||||
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
|
||||
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
@@ -113,6 +137,24 @@ public class ScheduleGeneratorService {
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
|
||||
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
List<ScheduleRule> rules,
|
||||
List<Semester> semesters) {
|
||||
if (groups.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (semesters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> groupIds = groups.stream()
|
||||
.map(StudentGroup::getId)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||
.createScheduleSnapshot(groups, semesters, endDate);
|
||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.LessonType;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
@@ -10,6 +11,8 @@ import com.magistr.app.model.ScheduleLessonCategory;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
@@ -20,6 +23,7 @@ import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
@@ -72,6 +76,7 @@ public class ScheduleRuleService {
|
||||
private final SubgroupRepository subgroupRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||
private ScheduleVersionRepository scheduleVersionRepository;
|
||||
|
||||
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
@@ -100,9 +105,22 @@ public class ScheduleRuleService {
|
||||
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setScheduleVersionRepository(ScheduleVersionRepository scheduleVersionRepository) {
|
||||
this.scheduleVersionRepository = scheduleVersionRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||
return scheduleRuleRepository.findAllWithDetails().stream()
|
||||
return getAll(semesterId, groupId, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId, Long scheduleVersionId) {
|
||||
List<ScheduleRule> source = scheduleVersionId == null
|
||||
? scheduleRuleRepository.findAllWithDetails()
|
||||
: scheduleRuleRepository.findActiveByVersionIdWithDetails(scheduleVersionId, null);
|
||||
return source.stream()
|
||||
.filter(ScheduleRule::isActiveRecord)
|
||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||
.filter(rule -> groupId == null || rule.getGroups().stream()
|
||||
@@ -127,11 +145,17 @@ public class ScheduleRuleService {
|
||||
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, semester);
|
||||
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId());
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, semester, version);
|
||||
validateCandidate(candidate);
|
||||
validateAgainstSavedRules(candidate, null);
|
||||
|
||||
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
|
||||
if (saved.getVersionGroupId() == null && saved.getId() != null) {
|
||||
saved.setVersionGroupId(saved.getId());
|
||||
saved = scheduleRuleRepository.saveAndFlush(saved);
|
||||
}
|
||||
scheduleGeneratorService.clearCache();
|
||||
return toDto(saved);
|
||||
}
|
||||
@@ -144,6 +168,7 @@ public class ScheduleRuleService {
|
||||
// определён по актуальному состоянию и два update одного правила не расходятся.
|
||||
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureDraftRule(managedRule);
|
||||
Long oldSemesterId = managedRule.getSemester().getId();
|
||||
|
||||
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
|
||||
@@ -154,7 +179,18 @@ public class ScheduleRuleService {
|
||||
throw new IllegalArgumentException("Семестр не найден");
|
||||
}
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, targetSemester);
|
||||
if (managedRule.getScheduleVersion() != null
|
||||
&& request.scheduleVersionId() != null
|
||||
&& !Objects.equals(managedRule.getScheduleVersion().getId(), request.scheduleVersionId())) {
|
||||
throw new IllegalArgumentException("Нельзя переносить правило между версиями расписания");
|
||||
}
|
||||
if (managedRule.getScheduleVersion() != null
|
||||
&& !Objects.equals(managedRule.getScheduleVersion().getSemester().getId(), targetSemester.getId())) {
|
||||
throw new IllegalArgumentException("Нельзя переносить правило в другой семестр внутри версии");
|
||||
}
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, targetSemester, managedRule.getScheduleVersion());
|
||||
candidate.setVersionGroupId(managedRule.getVersionGroupId());
|
||||
validateCandidate(candidate);
|
||||
validateAgainstSavedRules(candidate, managedRule.getId());
|
||||
|
||||
@@ -168,6 +204,7 @@ public class ScheduleRuleService {
|
||||
public void archive(Long id) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureDraftRule(rule);
|
||||
Long semesterId = rule.getSemester().getId();
|
||||
semesterRepository.findByIdForUpdate(semesterId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
|
||||
@@ -274,7 +311,7 @@ public class ScheduleRuleService {
|
||||
normalizeLessonFormat(slot.lessonFormat());
|
||||
}
|
||||
|
||||
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
|
||||
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester, ScheduleVersion version) {
|
||||
Subject subject = subjectRepository.findById(request.subjectId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||
if (subject.isArchivedRecord()) {
|
||||
@@ -293,6 +330,11 @@ public class ScheduleRuleService {
|
||||
ScheduleRule candidate = new ScheduleRule();
|
||||
candidate.setSubject(subject);
|
||||
candidate.setSemester(semester);
|
||||
candidate.setScheduleVersion(version);
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
if (currentUserId != null) {
|
||||
userRepository.findById(currentUserId).ifPresent(candidate::setCreatedBy);
|
||||
}
|
||||
candidate.setLectureAcademicHours(request.lectureAcademicHours());
|
||||
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||
@@ -505,8 +547,12 @@ public class ScheduleRuleService {
|
||||
|
||||
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
|
||||
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
|
||||
ScheduleRuleConflict conflict = scheduleRuleRepository
|
||||
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
|
||||
List<ScheduleRule> savedRules = candidate.getScheduleVersion() == null
|
||||
? scheduleRuleRepository.findActiveBySemesterIdWithDetails(
|
||||
candidate.getSemester().getId(), excludeRuleId)
|
||||
: scheduleRuleRepository.findActiveByVersionIdWithDetails(
|
||||
candidate.getScheduleVersion().getId(), excludeRuleId);
|
||||
ScheduleRuleConflict conflict = savedRules
|
||||
.stream()
|
||||
.filter(ScheduleRule::isActiveRecord)
|
||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||
@@ -805,10 +851,72 @@ public class ScheduleRuleService {
|
||||
rule.getPracticeStartWeek(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
slots,
|
||||
rule.getScheduleVersion() == null ? null : rule.getScheduleVersion().getId()
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> validateVersion(Long scheduleVersionId) {
|
||||
if (scheduleVersionId == null) {
|
||||
return List.of("Версия расписания не выбрана");
|
||||
}
|
||||
List<ScheduleRule> rules = scheduleRuleRepository
|
||||
.findActiveByVersionIdWithDetails(scheduleVersionId, null);
|
||||
if (rules.isEmpty()) {
|
||||
return List.of("В черновике нет ни одного правила расписания");
|
||||
}
|
||||
LinkedHashSet<String> issues = new LinkedHashSet<>();
|
||||
for (ScheduleRule rule : rules) {
|
||||
if (rule.getSlots().isEmpty()) {
|
||||
issues.add("Правило «" + rule.getSubject().getName() + "» не содержит слотов");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validateCandidate(rule);
|
||||
validateAgainstSavedRules(rule, rule.getId());
|
||||
} catch (IllegalArgumentException | ScheduleRuleConflictException exception) {
|
||||
issues.add("Правило «" + rule.getSubject().getName() + "»: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
return List.copyOf(issues);
|
||||
}
|
||||
|
||||
private ScheduleVersion resolveDraftVersion(Long versionId, Long semesterId) {
|
||||
if (versionId == null) {
|
||||
return null;
|
||||
}
|
||||
if (scheduleVersionRepository == null) {
|
||||
throw new IllegalStateException("Хранилище версий расписания недоступно");
|
||||
}
|
||||
ScheduleVersion version = scheduleVersionRepository.findByIdForUpdate(versionId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
|
||||
throw new IllegalArgumentException("Версия относится к другому семестру");
|
||||
}
|
||||
if (version.getStatus() != ScheduleVersionStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("Изменять правила можно только в черновике");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private void ensureDraftRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null
|
||||
&& rule.getScheduleVersion().getStatus() != ScheduleVersionStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("Изменять правила можно только в черновике");
|
||||
}
|
||||
}
|
||||
|
||||
private void lockAndEnsureDraftRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
|
||||
ScheduleVersion lockedVersion = scheduleVersionRepository
|
||||
.findByIdForUpdate(rule.getScheduleVersion().getId())
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
rule.setScheduleVersion(lockedVersion);
|
||||
}
|
||||
ensureDraftRule(rule);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
TimeSlot timeSlot = slot.getTimeSlot();
|
||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.dto.ScheduleVersionCreateDto;
|
||||
import com.magistr.app.dto.ScheduleVersionDiffDto;
|
||||
import com.magistr.app.dto.ScheduleVersionDto;
|
||||
import com.magistr.app.dto.ScheduleVersionHistoryDto;
|
||||
import com.magistr.app.dto.ScheduleVersionValidationDto;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionHistory;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.Subgroup;
|
||||
import com.magistr.app.model.User;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ScheduleVersionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ScheduleVersionService.class);
|
||||
private static final int MAX_RANGE_DAYS = 120;
|
||||
private static final int MAX_REASON_LENGTH = 2000;
|
||||
|
||||
private final ScheduleVersionRepository versionRepository;
|
||||
private final ScheduleVersionHistoryRepository historyRepository;
|
||||
private final ScheduleRuleRepository ruleRepository;
|
||||
private final SemesterRepository semesterRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ScheduleRuleService ruleService;
|
||||
private final ScheduleGeneratorService generatorService;
|
||||
|
||||
public ScheduleVersionService(
|
||||
ScheduleVersionRepository versionRepository,
|
||||
ScheduleVersionHistoryRepository historyRepository,
|
||||
ScheduleRuleRepository ruleRepository,
|
||||
SemesterRepository semesterRepository,
|
||||
UserRepository userRepository,
|
||||
ScheduleRuleService ruleService,
|
||||
ScheduleGeneratorService generatorService
|
||||
) {
|
||||
this.versionRepository = versionRepository;
|
||||
this.historyRepository = historyRepository;
|
||||
this.ruleRepository = ruleRepository;
|
||||
this.semesterRepository = semesterRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.ruleService = ruleService;
|
||||
this.generatorService = generatorService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleVersionDto> getVersions(Long semesterId) {
|
||||
requireSemesterId(semesterId);
|
||||
return versionRepository.findBySemesterIdWithDetails(semesterId).stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ScheduleVersionDto getVersion(Long versionId) {
|
||||
return toDto(findVersion(versionId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleVersionHistoryDto> getHistory(Long semesterId) {
|
||||
requireSemesterId(semesterId);
|
||||
return historyRepository.findBySemesterIdWithDetails(semesterId).stream()
|
||||
.map(this::toHistoryDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleVersionDto createDraft(ScheduleVersionCreateDto request) {
|
||||
if (request == null || request.semesterId() == null) {
|
||||
throw new IllegalArgumentException("Семестр для черновика обязателен");
|
||||
}
|
||||
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
|
||||
List<ScheduleVersion> lockedVersions = versionRepository.findBySemesterIdForUpdate(semester.getId());
|
||||
ScheduleVersion base = resolveBaseVersion(request.basedOnVersionId(), semester.getId(), lockedVersions);
|
||||
int nextNumber = lockedVersions.stream()
|
||||
.map(ScheduleVersion::getVersionNumber)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Integer::compareTo)
|
||||
.orElse(0) + 1;
|
||||
|
||||
ScheduleVersion draft = new ScheduleVersion();
|
||||
draft.setSemester(semester);
|
||||
draft.setVersionNumber(nextNumber);
|
||||
draft.setName(normalizeName(request.name(), "Черновик версии " + nextNumber));
|
||||
draft.setStatus(ScheduleVersionStatus.DRAFT);
|
||||
draft.setBasedOnVersion(base);
|
||||
draft.setCreatedBy(currentUser());
|
||||
ScheduleVersion saved = versionRepository.saveAndFlush(draft);
|
||||
|
||||
if (base != null) {
|
||||
cloneRules(base, saved);
|
||||
}
|
||||
addHistory(saved, "CREATED", currentUser(), base == null
|
||||
? "Создан пустой черновик"
|
||||
: "Создан черновик на основе версии " + base.getVersionNumber());
|
||||
log.info("Создан черновик расписания {} для семестра {} на основе версии {}",
|
||||
saved.getId(), semester.getId(), base == null ? "без основы" : base.getId());
|
||||
return toDto(findVersion(saved.getId()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ScheduleVersionValidationDto validate(Long versionId) {
|
||||
ScheduleVersion version = findVersion(versionId);
|
||||
List<ScheduleRule> rules = activeRules(versionId);
|
||||
List<String> issues = ruleService.validateVersion(versionId);
|
||||
long slotCount = rules.stream().mapToLong(rule -> rule.getSlots().size()).sum();
|
||||
return new ScheduleVersionValidationDto(
|
||||
version.getId(),
|
||||
issues.isEmpty(),
|
||||
rules.size(),
|
||||
slotCount,
|
||||
issues
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleVersionDto publish(Long versionId, String reason) {
|
||||
ScheduleVersion target = lockVersion(versionId);
|
||||
if (target.getStatus() != ScheduleVersionStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("Опубликовать можно только черновик");
|
||||
}
|
||||
String normalizedReason = requireReason(reason, "Для публикации укажите причину изменений");
|
||||
ScheduleVersionValidationDto validation = validate(versionId);
|
||||
if (!validation.valid()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Черновик не прошёл проверку: " + String.join("; ", validation.issues())
|
||||
);
|
||||
}
|
||||
|
||||
List<ScheduleVersion> versions = versionRepository
|
||||
.findBySemesterIdForUpdate(target.getSemester().getId());
|
||||
User actor = currentUser();
|
||||
Instant now = Instant.now();
|
||||
versions.stream()
|
||||
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
|
||||
.filter(version -> !Objects.equals(version.getId(), target.getId()))
|
||||
.forEach(version -> archivePublished(version, actor, normalizedReason, now));
|
||||
versionRepository.flush();
|
||||
|
||||
target.setStatus(ScheduleVersionStatus.PUBLISHED);
|
||||
target.setChangeReason(normalizedReason);
|
||||
target.setPublishedBy(actor);
|
||||
target.setPublishedAt(now);
|
||||
target.setArchivedAt(null);
|
||||
versionRepository.saveAndFlush(target);
|
||||
addHistory(target, "PUBLISHED", actor, normalizedReason);
|
||||
generatorService.clearCache();
|
||||
log.info("Опубликована версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
|
||||
return toDto(findVersion(target.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ScheduleVersionDto restore(Long versionId, String reason) {
|
||||
ScheduleVersion target = lockVersion(versionId);
|
||||
if (target.getStatus() != ScheduleVersionStatus.ARCHIVED || target.getPublishedAt() == null) {
|
||||
throw new IllegalArgumentException("Восстановить можно только ранее опубликованную архивную версию");
|
||||
}
|
||||
String normalizedReason = requireReason(reason, "Для восстановления укажите причину");
|
||||
List<ScheduleVersion> versions = versionRepository
|
||||
.findBySemesterIdForUpdate(target.getSemester().getId());
|
||||
ScheduleVersion current = versions.stream()
|
||||
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
User actor = currentUser();
|
||||
Instant now = Instant.now();
|
||||
if (current != null && !Objects.equals(current.getId(), target.getId())) {
|
||||
archivePublished(current, actor, normalizedReason, now);
|
||||
versionRepository.flush();
|
||||
}
|
||||
|
||||
target.setStatus(ScheduleVersionStatus.PUBLISHED);
|
||||
target.setChangeReason(normalizedReason);
|
||||
target.setPublishedBy(actor);
|
||||
target.setPublishedAt(now);
|
||||
target.setArchivedAt(null);
|
||||
target.setRestoredFromVersion(current);
|
||||
versionRepository.saveAndFlush(target);
|
||||
addHistory(target, "RESTORED", actor, normalizedReason);
|
||||
generatorService.clearCache();
|
||||
log.info("Восстановлена версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
|
||||
return toDto(findVersion(target.getId()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ScheduleVersionDiffDto diff(Long targetVersionId, Long requestedBaseVersionId) {
|
||||
ScheduleVersion target = findVersion(targetVersionId);
|
||||
ScheduleVersion base = resolveDiffBase(target, requestedBaseVersionId);
|
||||
List<ScheduleRule> beforeRules = activeRules(base.getId());
|
||||
List<ScheduleRule> afterRules = activeRules(target.getId());
|
||||
|
||||
Map<Long, ScheduleRule> beforeByGroup = indexRulesByVersionGroup(beforeRules);
|
||||
Map<Long, ScheduleRule> afterByGroup = indexRulesByVersionGroup(afterRules);
|
||||
LinkedHashSet<Long> allGroups = new LinkedHashSet<>();
|
||||
allGroups.addAll(beforeByGroup.keySet());
|
||||
allGroups.addAll(afterByGroup.keySet());
|
||||
|
||||
int added = 0;
|
||||
int removed = 0;
|
||||
int changed = 0;
|
||||
int unchanged = 0;
|
||||
List<ScheduleVersionDiffDto.RuleChange> changes = new ArrayList<>();
|
||||
for (Long groupId : allGroups) {
|
||||
ScheduleRule before = beforeByGroup.get(groupId);
|
||||
ScheduleRule after = afterByGroup.get(groupId);
|
||||
String type;
|
||||
if (before == null) {
|
||||
type = "ADDED";
|
||||
added++;
|
||||
} else if (after == null) {
|
||||
type = "REMOVED";
|
||||
removed++;
|
||||
} else if (!Objects.equals(ruleFingerprint(before), ruleFingerprint(after))) {
|
||||
type = "CHANGED";
|
||||
changed++;
|
||||
} else {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
ScheduleRule representative = after == null ? before : after;
|
||||
changes.add(new ScheduleVersionDiffDto.RuleChange(
|
||||
type,
|
||||
groupId,
|
||||
before == null ? null : before.getId(),
|
||||
after == null ? null : after.getId(),
|
||||
representative.getSubject().getName(),
|
||||
representative.getGroups().stream().map(StudentGroup::getName).sorted().toList(),
|
||||
before == null ? null : ruleSummary(before),
|
||||
after == null ? null : ruleSummary(after)
|
||||
));
|
||||
}
|
||||
|
||||
LessonDifference lessons = compareLessons(base, target, beforeRules, afterRules);
|
||||
return new ScheduleVersionDiffDto(
|
||||
base.getId(),
|
||||
versionLabel(base),
|
||||
target.getId(),
|
||||
versionLabel(target),
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
unchanged,
|
||||
lessons.added(),
|
||||
lessons.removed(),
|
||||
lessons.changed(),
|
||||
changes.stream()
|
||||
.sorted(Comparator.comparing(ScheduleVersionDiffDto.RuleChange::subjectName))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
private void cloneRules(ScheduleVersion base, ScheduleVersion target) {
|
||||
List<ScheduleRule> sourceRules = activeRules(base.getId());
|
||||
List<ScheduleRule> copies = new ArrayList<>();
|
||||
for (ScheduleRule source : sourceRules) {
|
||||
ScheduleRule copy = new ScheduleRule();
|
||||
copy.setSubject(source.getSubject());
|
||||
copy.setSemester(source.getSemester());
|
||||
copy.setScheduleVersion(target);
|
||||
copy.setVersionGroupId(source.getVersionGroupId() == null ? source.getId() : source.getVersionGroupId());
|
||||
copy.setChangeReason(source.getChangeReason());
|
||||
copy.setCreatedBy(currentUser());
|
||||
copy.setStatus(source.getStatus());
|
||||
copy.setValidFrom(source.getValidFrom());
|
||||
copy.setValidTo(source.getValidTo());
|
||||
copy.setLectureAcademicHours(source.getLectureAcademicHours());
|
||||
copy.setLaboratoryAcademicHours(source.getLaboratoryAcademicHours());
|
||||
copy.setPracticeAcademicHours(source.getPracticeAcademicHours());
|
||||
copy.setLectureStartWeek(source.getLectureStartWeek());
|
||||
copy.setLaboratoryStartWeek(source.getLaboratoryStartWeek());
|
||||
copy.setPracticeStartWeek(source.getPracticeStartWeek());
|
||||
copy.setGroups(new LinkedHashSet<>(source.getGroups()));
|
||||
|
||||
LinkedHashSet<ScheduleRuleSlot> copiedSlots = new LinkedHashSet<>();
|
||||
for (ScheduleRuleSlot sourceSlot : source.getSlots()) {
|
||||
ScheduleRuleSlot copySlot = new ScheduleRuleSlot();
|
||||
copySlot.setScheduleRule(copy);
|
||||
copySlot.setDayOfWeek(sourceSlot.getDayOfWeek());
|
||||
copySlot.setParity(sourceSlot.getParity());
|
||||
copySlot.setTimeSlot(sourceSlot.getTimeSlot());
|
||||
copySlot.setSubgroups(new LinkedHashSet<>(sourceSlot.getSubgroups()));
|
||||
copySlot.setTeacher(sourceSlot.getTeacher());
|
||||
copySlot.setClassroom(sourceSlot.getClassroom());
|
||||
copySlot.setLessonType(sourceSlot.getLessonType());
|
||||
copySlot.setLessonFormat(sourceSlot.getLessonFormat());
|
||||
copiedSlots.add(copySlot);
|
||||
}
|
||||
copy.setSlots(copiedSlots);
|
||||
copies.add(copy);
|
||||
}
|
||||
ruleRepository.saveAllAndFlush(copies);
|
||||
}
|
||||
|
||||
private void archivePublished(ScheduleVersion version, User actor, String reason, Instant archivedAt) {
|
||||
version.setStatus(ScheduleVersionStatus.ARCHIVED);
|
||||
version.setArchivedAt(archivedAt);
|
||||
versionRepository.save(version);
|
||||
addHistory(version, "ARCHIVED", actor, reason);
|
||||
}
|
||||
|
||||
private void addHistory(ScheduleVersion version, String action, User actor, String reason) {
|
||||
ScheduleVersionHistory history = new ScheduleVersionHistory();
|
||||
history.setVersion(version);
|
||||
history.setAction(action);
|
||||
history.setActor(actor);
|
||||
history.setReason(reason);
|
||||
historyRepository.save(history);
|
||||
}
|
||||
|
||||
private ScheduleVersion resolveBaseVersion(Long baseVersionId,
|
||||
Long semesterId,
|
||||
List<ScheduleVersion> versions) {
|
||||
ScheduleVersion base = null;
|
||||
if (baseVersionId != null) {
|
||||
base = versions.stream()
|
||||
.filter(version -> Objects.equals(version.getId(), baseVersionId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Базовая версия относится к другому семестру"));
|
||||
} else {
|
||||
base = versions.stream()
|
||||
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
if (base != null && !Objects.equals(base.getSemester().getId(), semesterId)) {
|
||||
throw new IllegalArgumentException("Базовая версия относится к другому семестру");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private ScheduleVersion resolveDiffBase(ScheduleVersion target, Long requestedBaseVersionId) {
|
||||
if (requestedBaseVersionId != null) {
|
||||
ScheduleVersion base = findVersion(requestedBaseVersionId);
|
||||
if (!Objects.equals(base.getSemester().getId(), target.getSemester().getId())) {
|
||||
throw new IllegalArgumentException("Сравнивать можно только версии одного семестра");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
if (target.getBasedOnVersion() != null) {
|
||||
return findVersion(target.getBasedOnVersion().getId());
|
||||
}
|
||||
return versionRepository.findBySemesterIdWithDetails(target.getSemester().getId()).stream()
|
||||
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
|
||||
.filter(version -> !Objects.equals(version.getId(), target.getId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Для этой версии нет базы сравнения"));
|
||||
}
|
||||
|
||||
private LessonDifference compareLessons(ScheduleVersion base,
|
||||
ScheduleVersion target,
|
||||
List<ScheduleRule> beforeRules,
|
||||
List<ScheduleRule> afterRules) {
|
||||
List<RenderedLessonDto> beforeLessons = loadVersionLessons(base);
|
||||
List<RenderedLessonDto> afterLessons = loadVersionLessons(target);
|
||||
Map<Long, Long> beforeGroups = beforeRules.stream().collect(Collectors.toMap(
|
||||
ScheduleRule::getId,
|
||||
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
|
||||
));
|
||||
Map<Long, Long> afterGroups = afterRules.stream().collect(Collectors.toMap(
|
||||
ScheduleRule::getId,
|
||||
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
|
||||
));
|
||||
Map<LessonIdentity, List<String>> before = indexLessons(beforeLessons, beforeGroups);
|
||||
Map<LessonIdentity, List<String>> after = indexLessons(afterLessons, afterGroups);
|
||||
LinkedHashSet<LessonIdentity> keys = new LinkedHashSet<>();
|
||||
keys.addAll(before.keySet());
|
||||
keys.addAll(after.keySet());
|
||||
int added = 0;
|
||||
int removed = 0;
|
||||
int changed = 0;
|
||||
for (LessonIdentity key : keys) {
|
||||
List<String> oldValues = new ArrayList<>(before.getOrDefault(key, List.of()));
|
||||
List<String> newValues = new ArrayList<>(after.getOrDefault(key, List.of()));
|
||||
for (int index = oldValues.size() - 1; index >= 0; index--) {
|
||||
String value = oldValues.get(index);
|
||||
int match = newValues.indexOf(value);
|
||||
if (match >= 0) {
|
||||
oldValues.remove(index);
|
||||
newValues.remove(match);
|
||||
}
|
||||
}
|
||||
int changedHere = Math.min(oldValues.size(), newValues.size());
|
||||
changed += changedHere;
|
||||
removed += oldValues.size() - changedHere;
|
||||
added += newValues.size() - changedHere;
|
||||
}
|
||||
return new LessonDifference(added, removed, changed);
|
||||
}
|
||||
|
||||
private Map<LessonIdentity, List<String>> indexLessons(List<RenderedLessonDto> lessons,
|
||||
Map<Long, Long> versionGroups) {
|
||||
Map<String, RenderedLessonDto> unique = lessons.stream().collect(Collectors.toMap(
|
||||
this::renderedLessonIdentity,
|
||||
Function.identity(),
|
||||
(first, ignored) -> first,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
Map<LessonIdentity, List<String>> result = new LinkedHashMap<>();
|
||||
for (RenderedLessonDto lesson : unique.values()) {
|
||||
LessonIdentity identity = new LessonIdentity(
|
||||
versionGroups.getOrDefault(lesson.scheduleRuleId(), lesson.scheduleRuleId()),
|
||||
lesson.date(),
|
||||
lesson.lessonTypeId(),
|
||||
sortedIds(lesson.groupIds()),
|
||||
sortedIds(lesson.subgroupIds())
|
||||
);
|
||||
String value = List.of(
|
||||
Objects.toString(lesson.timeSlotId(), ""),
|
||||
Objects.toString(lesson.teacherId(), ""),
|
||||
Objects.toString(lesson.classroomId(), ""),
|
||||
Objects.toString(lesson.lessonFormat(), "")
|
||||
).toString();
|
||||
result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(value);
|
||||
}
|
||||
result.values().forEach(values -> values.sort(String::compareTo));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<RenderedLessonDto> loadVersionLessons(ScheduleVersion version) {
|
||||
List<RenderedLessonDto> lessons = new ArrayList<>();
|
||||
LocalDate cursor = version.getSemester().getStartDate();
|
||||
LocalDate semesterEnd = version.getSemester().getEndDate();
|
||||
while (!cursor.isAfter(semesterEnd)) {
|
||||
LocalDate chunkEnd = cursor.plusDays(MAX_RANGE_DAYS - 1L);
|
||||
if (chunkEnd.isAfter(semesterEnd)) {
|
||||
chunkEnd = semesterEnd;
|
||||
}
|
||||
lessons.addAll(generatorService.buildScheduleForVersion(version.getId(), cursor, chunkEnd));
|
||||
cursor = chunkEnd.plusDays(1);
|
||||
}
|
||||
return lessons;
|
||||
}
|
||||
|
||||
private String renderedLessonIdentity(RenderedLessonDto lesson) {
|
||||
return List.of(
|
||||
Objects.toString(lesson.scheduleRuleId(), ""),
|
||||
Objects.toString(lesson.scheduleRuleSlotId(), ""),
|
||||
Objects.toString(lesson.date(), ""),
|
||||
sortedIds(lesson.groupIds()).toString(),
|
||||
sortedIds(lesson.subgroupIds()).toString()
|
||||
).toString();
|
||||
}
|
||||
|
||||
private Map<Long, ScheduleRule> indexRulesByVersionGroup(List<ScheduleRule> rules) {
|
||||
return rules.stream().collect(Collectors.toMap(
|
||||
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId(),
|
||||
Function.identity(),
|
||||
(first, ignored) -> first,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
}
|
||||
|
||||
private String ruleFingerprint(ScheduleRule rule) {
|
||||
List<String> slots = rule.getSlots().stream().map(this::slotFingerprint).sorted().toList();
|
||||
return List.of(
|
||||
rule.getSubject().getId(),
|
||||
rule.getLectureAcademicHours(),
|
||||
rule.getLaboratoryAcademicHours(),
|
||||
rule.getPracticeAcademicHours(),
|
||||
rule.getLectureStartWeek(),
|
||||
rule.getLaboratoryStartWeek(),
|
||||
rule.getPracticeStartWeek(),
|
||||
rule.getGroups().stream().map(StudentGroup::getId).sorted().toList(),
|
||||
slots
|
||||
).toString();
|
||||
}
|
||||
|
||||
private String slotFingerprint(ScheduleRuleSlot slot) {
|
||||
return List.of(
|
||||
slot.getDayOfWeek(),
|
||||
slot.getParity(),
|
||||
slot.getTimeSlot().getId(),
|
||||
slot.getTeacher().getId(),
|
||||
slot.getClassroom().getId(),
|
||||
slot.getLessonType().getId(),
|
||||
slot.getLessonFormat(),
|
||||
slot.getSubgroups().stream().map(Subgroup::getId).sorted().toList()
|
||||
).toString();
|
||||
}
|
||||
|
||||
private String ruleSummary(ScheduleRule rule) {
|
||||
String load = "лекции " + rule.getLectureAcademicHours()
|
||||
+ " ч., лабораторные " + rule.getLaboratoryAcademicHours()
|
||||
+ " ч., практики " + rule.getPracticeAcademicHours() + " ч.";
|
||||
String slots = rule.getSlots().stream()
|
||||
.sorted(Comparator
|
||||
.comparing(ScheduleRuleSlot::getDayOfWeek)
|
||||
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber()))
|
||||
.limit(4)
|
||||
.map(slot -> ScheduleGeneratorService.dayName(DayOfWeek.of(slot.getDayOfWeek()))
|
||||
+ ", " + slot.getTimeSlot().getOrderNumber() + " пара"
|
||||
+ ", " + ScheduleGeneratorService.displayUserName(slot.getTeacher()))
|
||||
.collect(Collectors.joining("; "));
|
||||
return load + " · " + rule.getSlots().size() + " слотов" + (slots.isBlank() ? "" : ": " + slots);
|
||||
}
|
||||
|
||||
private ScheduleVersionDto toDto(ScheduleVersion version) {
|
||||
List<ScheduleRule> rules = activeRules(version.getId());
|
||||
return new ScheduleVersionDto(
|
||||
version.getId(),
|
||||
version.getSemester().getId(),
|
||||
semesterLabel(version.getSemester()),
|
||||
version.getVersionNumber(),
|
||||
version.getName(),
|
||||
version.getStatus(),
|
||||
idOf(version.getBasedOnVersion()),
|
||||
idOf(version.getRestoredFromVersion()),
|
||||
version.getChangeReason(),
|
||||
idOf(version.getCreatedBy()),
|
||||
nameOf(version.getCreatedBy()),
|
||||
version.getCreatedAt(),
|
||||
idOf(version.getPublishedBy()),
|
||||
nameOf(version.getPublishedBy()),
|
||||
version.getPublishedAt(),
|
||||
version.getArchivedAt(),
|
||||
rules.size(),
|
||||
rules.stream().mapToLong(rule -> rule.getSlots().size()).sum()
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleVersionHistoryDto toHistoryDto(ScheduleVersionHistory history) {
|
||||
return new ScheduleVersionHistoryDto(
|
||||
history.getId(),
|
||||
history.getVersion().getId(),
|
||||
history.getVersion().getVersionNumber(),
|
||||
history.getVersion().getName(),
|
||||
history.getAction(),
|
||||
idOf(history.getActor()),
|
||||
nameOf(history.getActor()),
|
||||
history.getReason(),
|
||||
history.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleVersion findVersion(Long versionId) {
|
||||
if (versionId == null) {
|
||||
throw new IllegalArgumentException("Версия расписания обязательна");
|
||||
}
|
||||
return versionRepository.findByIdWithDetails(versionId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
}
|
||||
|
||||
private ScheduleVersion lockVersion(Long versionId) {
|
||||
if (versionId == null) {
|
||||
throw new IllegalArgumentException("Версия расписания обязательна");
|
||||
}
|
||||
return versionRepository.findByIdForUpdate(versionId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
}
|
||||
|
||||
private List<ScheduleRule> activeRules(Long versionId) {
|
||||
return ruleRepository.findActiveByVersionIdWithDetails(versionId, null);
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
Long currentUserId = AuthContext.currentUserId();
|
||||
return currentUserId == null ? null : userRepository.findById(currentUserId).orElse(null);
|
||||
}
|
||||
|
||||
private String normalizeName(String name, String fallback) {
|
||||
String normalized = name == null ? "" : name.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
if (normalized.length() > 160) {
|
||||
throw new IllegalArgumentException("Название версии не может быть длиннее 160 символов");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requireReason(String reason, String message) {
|
||||
String normalized = reason == null ? "" : reason.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
if (normalized.length() > MAX_REASON_LENGTH) {
|
||||
throw new IllegalArgumentException("Причина не может быть длиннее 2000 символов");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void requireSemesterId(Long semesterId) {
|
||||
if (semesterId == null) {
|
||||
throw new IllegalArgumentException("Семестр обязателен");
|
||||
}
|
||||
}
|
||||
|
||||
private String semesterLabel(Semester semester) {
|
||||
return semester.getAcademicYear().getTitle() + " · "
|
||||
+ (semester.getSemesterType().name().equals("autumn") ? "осенний" : "весенний") + " семестр";
|
||||
}
|
||||
|
||||
private String versionLabel(ScheduleVersion version) {
|
||||
return "Версия " + version.getVersionNumber() + " · " + version.getName();
|
||||
}
|
||||
|
||||
private Long idOf(ScheduleVersion version) {
|
||||
return version == null ? null : version.getId();
|
||||
}
|
||||
|
||||
private Long idOf(User user) {
|
||||
return user == null ? null : user.getId();
|
||||
}
|
||||
|
||||
private String nameOf(User user) {
|
||||
return user == null ? null : ScheduleGeneratorService.displayUserName(user);
|
||||
}
|
||||
|
||||
private List<Long> sortedIds(List<Long> ids) {
|
||||
return ids == null ? List.of() : ids.stream().filter(Objects::nonNull).sorted().toList();
|
||||
}
|
||||
|
||||
private record LessonIdentity(
|
||||
Long versionGroupId,
|
||||
LocalDate date,
|
||||
Long lessonTypeId,
|
||||
List<Long> groupIds,
|
||||
List<Long> subgroupIds
|
||||
) {
|
||||
}
|
||||
|
||||
private record LessonDifference(int added, int removed, int changed) {
|
||||
}
|
||||
}
|
||||
@@ -2182,3 +2182,323 @@ ALTER TABLE teacher_department_assignments
|
||||
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
|
||||
ON teacher_department_assignments
|
||||
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
|
||||
|
||||
-- ==========================================
|
||||
-- Отсутствия преподавателей и мастер замены
|
||||
-- ==========================================
|
||||
CREATE TABLE teacher_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date),
|
||||
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
|
||||
CONSTRAINT chk_teacher_absences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_teacher_period
|
||||
ON teacher_absences(teacher_id, start_date, end_date);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_status
|
||||
ON teacher_absences(status);
|
||||
|
||||
CREATE TABLE teacher_absence_decisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
resolution VARCHAR(30) NOT NULL,
|
||||
decision_status VARCHAR(20) NOT NULL,
|
||||
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
comment TEXT,
|
||||
decided_by BIGINT REFERENCES users(id),
|
||||
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
|
||||
absence_id,
|
||||
base_rule_slot_id,
|
||||
lesson_date
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
|
||||
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_status CHECK (
|
||||
decision_status IN ('APPLIED', 'REJECTED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absence_decisions_absence
|
||||
ON teacher_absence_decisions(absence_id, lesson_date);
|
||||
|
||||
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
|
||||
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
|
||||
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
|
||||
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';
|
||||
|
||||
-- ==========================================
|
||||
-- Пожелания и заявки преподавателей
|
||||
-- ==========================================
|
||||
CREATE TABLE teacher_preferences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id),
|
||||
preference_type VARCHAR(30) NOT NULL,
|
||||
day_of_week SMALLINT,
|
||||
time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
preference_date DATE,
|
||||
comment TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_preferences_type CHECK (
|
||||
preference_type IN (
|
||||
'HARD_UNAVAILABLE',
|
||||
'SOFT_PREFERRED',
|
||||
'SOFT_UNWANTED',
|
||||
'CONSECUTIVE',
|
||||
'NO_GAPS'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_day CHECK (
|
||||
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_payload CHECK (
|
||||
(
|
||||
preference_type = 'HARD_UNAVAILABLE'
|
||||
AND (
|
||||
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
|
||||
OR
|
||||
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NOT NULL
|
||||
AND time_slot_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NULL
|
||||
AND time_slot_id IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_comment CHECK (
|
||||
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_teacher_semester
|
||||
ON teacher_preferences(teacher_id, semester_id, status);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_slot
|
||||
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
|
||||
WHERE status = 'APPROVED';
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_date
|
||||
ON teacher_preferences(teacher_id, preference_date)
|
||||
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
|
||||
|
||||
CREATE TABLE teacher_change_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
request_type VARCHAR(30) NOT NULL,
|
||||
target_lesson_date DATE,
|
||||
requested_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
requested_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_requests_type CHECK (
|
||||
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_reason CHECK (
|
||||
length(trim(reason)) BETWEEN 1 AND 2000
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_payload CHECK (
|
||||
(
|
||||
request_type = 'MOVE'
|
||||
AND requested_time_slot_id IS NOT NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CHANGE_CLASSROOM'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CANCEL'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_teacher
|
||||
ON teacher_change_requests(teacher_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_status
|
||||
ON teacher_change_requests(status, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
|
||||
ON teacher_change_requests(base_rule_slot_id, lesson_date)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
CREATE TABLE teacher_change_request_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
comment TEXT,
|
||||
actor_id BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_request_history_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_request_history_request
|
||||
ON teacher_change_request_history(request_id, created_at, id);
|
||||
|
||||
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
|
||||
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
|
||||
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
|
||||
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||
|
||||
-- ==========================================
|
||||
-- Черновики, версии, публикация и аудит расписания
|
||||
-- ==========================================
|
||||
CREATE TABLE schedule_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||
version_number INT NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
based_on_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
|
||||
restored_from_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
|
||||
change_reason TEXT,
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
published_by BIGINT REFERENCES users(id),
|
||||
published_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_schedule_versions_number UNIQUE (semester_id, version_number),
|
||||
CONSTRAINT chk_schedule_versions_number CHECK (version_number > 0),
|
||||
CONSTRAINT chk_schedule_versions_name CHECK (length(trim(name)) BETWEEN 1 AND 160),
|
||||
CONSTRAINT chk_schedule_versions_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
|
||||
CONSTRAINT chk_schedule_versions_publish_fields CHECK (
|
||||
status <> 'PUBLISHED' OR published_at IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_schedule_versions_published_semester
|
||||
ON schedule_versions(semester_id)
|
||||
WHERE status = 'PUBLISHED';
|
||||
|
||||
CREATE INDEX idx_schedule_versions_semester_history
|
||||
ON schedule_versions(semester_id, version_number DESC);
|
||||
|
||||
INSERT INTO schedule_versions (
|
||||
semester_id,
|
||||
version_number,
|
||||
name,
|
||||
status,
|
||||
created_at,
|
||||
published_at
|
||||
)
|
||||
SELECT
|
||||
semester.id,
|
||||
1,
|
||||
'Опубликованная версия 1',
|
||||
'PUBLISHED',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM semesters semester;
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ADD COLUMN schedule_version_id BIGINT;
|
||||
|
||||
UPDATE schedule_rules rule
|
||||
SET schedule_version_id = version.id,
|
||||
version_group_id = COALESCE(rule.version_group_id, rule.id)
|
||||
FROM schedule_versions version
|
||||
WHERE version.semester_id = rule.semester_id
|
||||
AND version.status = 'PUBLISHED';
|
||||
|
||||
ALTER TABLE schedule_rules
|
||||
ALTER COLUMN schedule_version_id SET NOT NULL,
|
||||
ADD CONSTRAINT fk_schedule_rules_version
|
||||
FOREIGN KEY (schedule_version_id) REFERENCES schedule_versions(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_schedule_rules_version
|
||||
ON schedule_rules(schedule_version_id, status);
|
||||
|
||||
CREATE UNIQUE INDEX uq_schedule_rules_version_group
|
||||
ON schedule_rules(schedule_version_id, version_group_id)
|
||||
WHERE version_group_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE schedule_version_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
version_id BIGINT NOT NULL REFERENCES schedule_versions(id) ON DELETE CASCADE,
|
||||
action VARCHAR(30) NOT NULL,
|
||||
actor_id BIGINT REFERENCES users(id),
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_schedule_version_history_action CHECK (
|
||||
action IN ('CREATED', 'PUBLISHED', 'ARCHIVED', 'RESTORED')
|
||||
),
|
||||
CONSTRAINT chk_schedule_version_history_reason CHECK (
|
||||
reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 2000
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_schedule_version_history_version
|
||||
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
||||
|
||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
||||
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
|
||||
FROM schedule_versions;
|
||||
|
||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
||||
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
|
||||
FROM schedule_versions;
|
||||
|
||||
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
||||
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
||||
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
||||
COMMENT ON COLUMN schedule_versions.restored_from_version_id IS 'Предыдущая опубликованная версия при восстановлении';
|
||||
COMMENT ON COLUMN schedule_rules.schedule_version_id IS 'Версия расписания, которой принадлежит правило';
|
||||
COMMENT ON COLUMN schedule_rules.version_group_id IS 'Стабильный идентификатор правила между версиями';
|
||||
COMMENT ON TABLE schedule_version_history IS 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
-- Реестр временных отсутствий преподавателей и журнал решений мастера замены.
|
||||
|
||||
CREATE TABLE teacher_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date),
|
||||
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
|
||||
CONSTRAINT chk_teacher_absences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_teacher_period
|
||||
ON teacher_absences(teacher_id, start_date, end_date);
|
||||
|
||||
CREATE INDEX idx_teacher_absences_status
|
||||
ON teacher_absences(status);
|
||||
|
||||
CREATE TABLE teacher_absence_decisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
resolution VARCHAR(30) NOT NULL,
|
||||
decision_status VARCHAR(20) NOT NULL,
|
||||
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
comment TEXT,
|
||||
decided_by BIGINT REFERENCES users(id),
|
||||
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
|
||||
absence_id,
|
||||
base_rule_slot_id,
|
||||
lesson_date
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
|
||||
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
|
||||
),
|
||||
CONSTRAINT chk_teacher_absence_decision_status CHECK (
|
||||
decision_status IN ('APPLIED', 'REJECTED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_absence_decisions_absence
|
||||
ON teacher_absence_decisions(absence_id, lesson_date);
|
||||
|
||||
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
|
||||
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
|
||||
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
|
||||
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';
|
||||
@@ -1,151 +0,0 @@
|
||||
-- Пожелания преподавателей на семестр и журнал заявок на изменение опубликованных занятий.
|
||||
|
||||
CREATE TABLE teacher_preferences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id),
|
||||
preference_type VARCHAR(30) NOT NULL,
|
||||
day_of_week SMALLINT,
|
||||
time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
preference_date DATE,
|
||||
comment TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
requested_by BIGINT REFERENCES users(id),
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_preferences_type CHECK (
|
||||
preference_type IN (
|
||||
'HARD_UNAVAILABLE',
|
||||
'SOFT_PREFERRED',
|
||||
'SOFT_UNWANTED',
|
||||
'CONSECUTIVE',
|
||||
'NO_GAPS'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_day CHECK (
|
||||
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_payload CHECK (
|
||||
(
|
||||
preference_type = 'HARD_UNAVAILABLE'
|
||||
AND (
|
||||
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
|
||||
OR
|
||||
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NOT NULL
|
||||
AND time_slot_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
|
||||
AND preference_date IS NULL
|
||||
AND day_of_week IS NULL
|
||||
AND time_slot_id IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_teacher_preferences_comment CHECK (
|
||||
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_teacher_semester
|
||||
ON teacher_preferences(teacher_id, semester_id, status);
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_slot
|
||||
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
|
||||
WHERE status = 'APPROVED';
|
||||
|
||||
CREATE INDEX idx_teacher_preferences_date
|
||||
ON teacher_preferences(teacher_id, preference_date)
|
||||
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
|
||||
|
||||
CREATE TABLE teacher_change_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id),
|
||||
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
|
||||
lesson_date DATE NOT NULL,
|
||||
request_type VARCHAR(30) NOT NULL,
|
||||
target_lesson_date DATE,
|
||||
requested_time_slot_id BIGINT REFERENCES time_slots(id),
|
||||
requested_classroom_id BIGINT REFERENCES classrooms(id),
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
reviewed_by BIGINT REFERENCES users(id),
|
||||
review_comment TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_requests_type CHECK (
|
||||
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_reason CHECK (
|
||||
length(trim(reason)) BETWEEN 1 AND 2000
|
||||
),
|
||||
CONSTRAINT chk_teacher_change_requests_payload CHECK (
|
||||
(
|
||||
request_type = 'MOVE'
|
||||
AND requested_time_slot_id IS NOT NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CHANGE_CLASSROOM'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NOT NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
request_type = 'CANCEL'
|
||||
AND target_lesson_date IS NULL
|
||||
AND requested_time_slot_id IS NULL
|
||||
AND requested_classroom_id IS NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_teacher
|
||||
ON teacher_change_requests(teacher_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_teacher_change_requests_status
|
||||
ON teacher_change_requests(status, created_at);
|
||||
|
||||
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
|
||||
ON teacher_change_requests(base_rule_slot_id, lesson_date)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
CREATE TABLE teacher_change_request_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
comment TEXT,
|
||||
actor_id BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_teacher_change_request_history_status CHECK (
|
||||
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_teacher_change_request_history_request
|
||||
ON teacher_change_request_history(request_id, created_at, id);
|
||||
|
||||
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
|
||||
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
|
||||
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
|
||||
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.service.ScheduleQualityService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
class ScheduleQualityControllerTest {
|
||||
|
||||
private ScheduleQualityService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(ScheduleQualityService.class);
|
||||
mockMvc = standaloneSetup(new ScheduleQualityController(service))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsExplainableSemesterScore() throws Exception {
|
||||
when(service.analyze(7L, 12L)).thenReturn(new ScheduleQualityAnalysisDto(
|
||||
7L,
|
||||
"2026/2027 · осенний семестр",
|
||||
LocalDate.of(2026, 9, 1),
|
||||
LocalDate.of(2026, 12, 31),
|
||||
82,
|
||||
18,
|
||||
420,
|
||||
3,
|
||||
false,
|
||||
List.of(new ScheduleQualityAnalysisDto.Metric(
|
||||
"GROUP_GAPS",
|
||||
"Окна у групп",
|
||||
3,
|
||||
"окон",
|
||||
6,
|
||||
"Свободные пары между занятиями"
|
||||
)),
|
||||
List.of(),
|
||||
12L,
|
||||
2,
|
||||
"Черновик новой сетки",
|
||||
ScheduleVersionStatus.DRAFT
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/edu-office/schedule/quality")
|
||||
.param("semesterId", "7")
|
||||
.param("versionId", "12"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.score").value(82))
|
||||
.andExpect(jsonPath("$.scheduleVersionStatus").value("DRAFT"))
|
||||
.andExpect(jsonPath("$.metrics[0].code").value("GROUP_GAPS"));
|
||||
|
||||
verify(service).analyze(7L, 12L);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,8 @@ class ScheduleRuleAdminControllerTest {
|
||||
1,
|
||||
List.of(3L),
|
||||
List.of("Группа"),
|
||||
List.of()
|
||||
List.of(),
|
||||
9L
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ class ScheduleRuleAdminControllerTest {
|
||||
{
|
||||
"subjectId": 1,
|
||||
"semesterId": 2,
|
||||
"scheduleVersionId": 9,
|
||||
"lectureAcademicHours": 2,
|
||||
"laboratoryAcademicHours": 0,
|
||||
"practiceAcademicHours": 0,
|
||||
|
||||
@@ -44,6 +44,13 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
SeedIds seedIds = loadSeedIds(connection);
|
||||
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules WHERE schedule_version_id IS NULL"))
|
||||
.as("Все исходные правила должны принадлежать опубликованной версии")
|
||||
.isZero();
|
||||
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_versions WHERE status = 'PUBLISHED'"))
|
||||
.as("V1 должна создать по одной опубликованной версии на семестр")
|
||||
.isEqualTo(queryLong(connection, "SELECT count(*) FROM semesters"));
|
||||
assertSecondPublishedVersionRejected(connection, seedIds.semesterId());
|
||||
|
||||
insertScheduleRule(connection, seedIds, 2, 4, 6);
|
||||
|
||||
@@ -83,7 +90,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
private SeedIds loadSeedIds(Connection connection) throws SQLException {
|
||||
return new SeedIds(
|
||||
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
|
||||
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1"),
|
||||
queryLong(connection, "SELECT id FROM schedule_versions ORDER BY id LIMIT 1")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,19 +106,21 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
INSERT INTO schedule_rules (
|
||||
subject_id,
|
||||
semester_id,
|
||||
schedule_version_id,
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 1)
|
||||
""")) {
|
||||
statement.setLong(1, seedIds.subjectId());
|
||||
statement.setLong(2, seedIds.semesterId());
|
||||
statement.setInt(3, lectureHours);
|
||||
statement.setInt(4, laboratoryHours);
|
||||
statement.setInt(5, practiceHours);
|
||||
statement.setLong(3, seedIds.scheduleVersionId());
|
||||
statement.setInt(4, lectureHours);
|
||||
statement.setInt(5, laboratoryHours);
|
||||
statement.setInt(6, practiceHours);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
@@ -143,6 +153,23 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
.contains(HOURS_CONSTRAINT);
|
||||
}
|
||||
|
||||
private void assertSecondPublishedVersionRejected(Connection connection, long semesterId) {
|
||||
Throwable failure = catchThrowable(() -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO schedule_versions (
|
||||
semester_id, version_number, name, status, published_at
|
||||
) VALUES (?, 99, 'Недопустимая вторая публикация', 'PUBLISHED', CURRENT_TIMESTAMP)
|
||||
""")) {
|
||||
statement.setLong(1, semesterId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
});
|
||||
assertThat(failure)
|
||||
.as("В одном семестре не может быть двух опубликованных версий")
|
||||
.isInstanceOf(SQLException.class);
|
||||
assertThat(((SQLException) failure).getSQLState()).isEqualTo("23505");
|
||||
}
|
||||
|
||||
private void assertExactSlotDuplicateRejected(Connection connection) {
|
||||
Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));
|
||||
|
||||
@@ -201,6 +228,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private record SeedIds(long subjectId, long semesterId) {
|
||||
private record SeedIds(long subjectId, long semesterId, long scheduleVersionId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +45,16 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
long teacherId = seedTeacherId(connection);
|
||||
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
|
||||
LocalDate initialStart = openPrimaryStart(connection, teacherId);
|
||||
LocalDate initialEnd = initialStart.plusDays(30);
|
||||
LocalDate transferStart = initialEnd.plusDays(1);
|
||||
updateOpenPrimaryEnd(connection, teacherId, initialEnd);
|
||||
|
||||
insertAssignment(
|
||||
connection,
|
||||
teacherId,
|
||||
secondDepartmentId,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
transferStart,
|
||||
null,
|
||||
true
|
||||
);
|
||||
@@ -59,7 +62,7 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
connection,
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
transferStart,
|
||||
null,
|
||||
false
|
||||
);
|
||||
@@ -69,8 +72,8 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
connection,
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 1),
|
||||
transferStart.plusDays(14),
|
||||
transferStart.plusDays(31),
|
||||
true
|
||||
),
|
||||
"23P01",
|
||||
@@ -94,11 +97,14 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
long teacherId;
|
||||
long firstDepartmentId;
|
||||
long secondDepartmentId;
|
||||
LocalDate firstCandidateStart;
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
teacherId = seedTeacherId(connection);
|
||||
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
|
||||
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
|
||||
LocalDate initialStart = openPrimaryStart(connection, teacherId);
|
||||
updateOpenPrimaryEnd(connection, teacherId, initialStart.plusDays(30));
|
||||
firstCandidateStart = initialStart.plusDays(31);
|
||||
}
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
@@ -109,16 +115,16 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
executor.submit(() -> concurrentInsert(
|
||||
teacherId,
|
||||
firstDepartmentId,
|
||||
LocalDate.of(2090, 1, 1),
|
||||
LocalDate.of(2090, 12, 31),
|
||||
firstCandidateStart,
|
||||
firstCandidateStart.plusDays(364),
|
||||
ready,
|
||||
start
|
||||
)),
|
||||
executor.submit(() -> concurrentInsert(
|
||||
teacherId,
|
||||
secondDepartmentId,
|
||||
LocalDate.of(2090, 6, 1),
|
||||
LocalDate.of(2091, 5, 31),
|
||||
firstCandidateStart.plusDays(151),
|
||||
firstCandidateStart.plusDays(515),
|
||||
ready,
|
||||
start
|
||||
))
|
||||
@@ -186,6 +192,22 @@ class TeacherDepartmentInvariantIntegrationTest {
|
||||
);
|
||||
}
|
||||
|
||||
private LocalDate openPrimaryStart(Connection connection, long teacherId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
SELECT valid_from
|
||||
FROM teacher_department_assignments
|
||||
WHERE teacher_id = ?
|
||||
AND is_primary = TRUE
|
||||
AND valid_to IS NULL
|
||||
""")) {
|
||||
statement.setLong(1, teacherId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertThat(resultSet.next()).isTrue();
|
||||
return resultSet.getDate(1).toLocalDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateOpenPrimaryEnd(Connection connection,
|
||||
long teacherId,
|
||||
LocalDate validTo) throws SQLException {
|
||||
|
||||
@@ -18,36 +18,38 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@Testcontainers
|
||||
@DisplayName("Миграция пожеланий и заявок преподавателей")
|
||||
@DisplayName("Преподавательские процессы в базовой схеме")
|
||||
class TeacherPreferencesMigrationIntegrationTest {
|
||||
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
|
||||
|
||||
@Test
|
||||
@DisplayName("V3 создаёт три таблицы и отклоняет противоречивое пожелание")
|
||||
@DisplayName("V1 содержит таблицы прежних V2/V3 и отклоняет противоречивое пожелание")
|
||||
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("3"))
|
||||
.target(MigrationVersion.fromVersion("1"))
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("3");
|
||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||
try (Connection connection = POSTGRES.createConnection("")) {
|
||||
assertThat(queryLong(connection, """
|
||||
SELECT count(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'teacher_absences',
|
||||
'teacher_absence_decisions',
|
||||
'teacher_preferences',
|
||||
'teacher_change_requests',
|
||||
'teacher_change_request_history'
|
||||
)
|
||||
""")).isEqualTo(3L);
|
||||
""")).isEqualTo(5L);
|
||||
|
||||
long teacherId = queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1");
|
||||
long semesterId = queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1");
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.Classroom;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.TimeSlot;
|
||||
import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.repository.TeacherPreferenceRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleQualityServiceTest {
|
||||
|
||||
private ScheduleQueryService scheduleQueryService;
|
||||
private SemesterRepository semesterRepository;
|
||||
private GroupRepository groupRepository;
|
||||
private SubgroupRepository subgroupRepository;
|
||||
private ClassroomRepository classroomRepository;
|
||||
private TeacherPreferenceRepository preferenceRepository;
|
||||
private EffectiveTimeSlotService effectiveTimeSlotService;
|
||||
private ScheduleOverrideService scheduleOverrideService;
|
||||
private ScheduleQualityService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
scheduleQueryService = mock(ScheduleQueryService.class);
|
||||
semesterRepository = mock(SemesterRepository.class);
|
||||
groupRepository = mock(GroupRepository.class);
|
||||
subgroupRepository = mock(SubgroupRepository.class);
|
||||
classroomRepository = mock(ClassroomRepository.class);
|
||||
preferenceRepository = mock(TeacherPreferenceRepository.class);
|
||||
effectiveTimeSlotService = mock(EffectiveTimeSlotService.class);
|
||||
scheduleOverrideService = mock(ScheduleOverrideService.class);
|
||||
service = new ScheduleQualityService(
|
||||
scheduleQueryService,
|
||||
semesterRepository,
|
||||
groupRepository,
|
||||
subgroupRepository,
|
||||
classroomRepository,
|
||||
preferenceRepository,
|
||||
effectiveTimeSlotService,
|
||||
scheduleOverrideService
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void analysisExplainsGroupGapAndCapacityShortage() {
|
||||
Semester semester = semester();
|
||||
StudentGroup group = group(1L, 30L);
|
||||
Classroom room = classroom(1L, "А-101", 20);
|
||||
LocalDate date = LocalDate.of(2026, 9, 7);
|
||||
List<RenderedLessonDto> lessons = List.of(
|
||||
lesson(11L, date, 1, 1L, room, group),
|
||||
lesson(12L, date, 3, 3L, room, group)
|
||||
);
|
||||
stubContext(semester, group, List.of(room), lessons);
|
||||
|
||||
var result = service.analyze(semester.getId());
|
||||
|
||||
assertThat(result.lessonCount()).isEqualTo(2);
|
||||
assertThat(result.score()).isLessThan(100);
|
||||
assertThat(result.problems())
|
||||
.extracting(problem -> problem.type())
|
||||
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
|
||||
assertThat(result.metrics())
|
||||
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
|
||||
.singleElement()
|
||||
.satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
|
||||
Semester semester = semester();
|
||||
StudentGroup group = group(1L, 20L);
|
||||
Classroom room = classroom(1L, "А-101", 20);
|
||||
LocalDate monday = LocalDate.of(2026, 9, 7);
|
||||
LocalDate tuesday = monday.plusDays(1);
|
||||
List<RenderedLessonDto> lessons = List.of(
|
||||
lesson(11L, monday, 1, 1L, room, group),
|
||||
lesson(12L, tuesday, 1, 1L, room, group),
|
||||
lesson(13L, tuesday, 2, 2L, room, group),
|
||||
lesson(14L, tuesday, 3, 3L, room, group)
|
||||
);
|
||||
stubContext(semester, group, List.of(room), lessons);
|
||||
|
||||
var result = service.analyze(semester.getId());
|
||||
|
||||
assertThat(result.metrics())
|
||||
.filteredOn(metric -> "DISTRIBUTION_BALANCE".equals(metric.code()))
|
||||
.singleElement()
|
||||
.satisfies(metric -> {
|
||||
assertThat(metric.value()).isEqualTo(50.0);
|
||||
assertThat(metric.penalty()).isZero();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void recommendationsOfferOnlyValidatedTighterClassroom() {
|
||||
Semester semester = semester();
|
||||
StudentGroup group = group(1L, 20L);
|
||||
Classroom oversized = classroom(1L, "Актовый зал", 120);
|
||||
Classroom compact = classroom(2L, "А-204", 24);
|
||||
LocalDate date = LocalDate.of(2026, 9, 7);
|
||||
RenderedLessonDto source = lesson(11L, date, 1, 1L, oversized, group);
|
||||
stubContext(semester, group, List.of(oversized, compact), List.of(source));
|
||||
when(effectiveTimeSlotService.findEffective(date)).thenReturn(List.of(timeSlot(1L, 1)));
|
||||
|
||||
var result = service.recommendations(semester.getId(), 11L, date);
|
||||
|
||||
assertThat(result.candidates())
|
||||
.filteredOn(candidate -> "REPLACE".equals(candidate.action()))
|
||||
.singleElement()
|
||||
.satisfies(candidate -> {
|
||||
assertThat(candidate.label()).contains("А-204", "24 мест");
|
||||
assertThat(candidate.penaltyDelta()).isPositive();
|
||||
assertThat(candidate.override().newClassroomId()).isEqualTo(2L);
|
||||
});
|
||||
verify(scheduleOverrideService).validateCandidate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftQualityUsesItsOwnRulesInsteadOfPublishedSchedule() {
|
||||
Semester semester = semester();
|
||||
StudentGroup group = group(1L, 20L);
|
||||
Classroom room = classroom(1L, "А-101", 20);
|
||||
ScheduleVersion draft = new ScheduleVersion();
|
||||
draft.setId(12L);
|
||||
draft.setSemester(semester);
|
||||
draft.setVersionNumber(2);
|
||||
draft.setName("Черновик новой сетки");
|
||||
draft.setStatus(ScheduleVersionStatus.DRAFT);
|
||||
ScheduleVersionRepository versionRepository = mock(ScheduleVersionRepository.class);
|
||||
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||
service.setVersionDependencies(versionRepository, generatorService);
|
||||
when(versionRepository.findByIdWithDetails(12L)).thenReturn(Optional.of(draft));
|
||||
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
|
||||
when(generatorService.buildScheduleForVersion(12L, semester.getStartDate(), semester.getEndDate()))
|
||||
.thenReturn(List.of(lesson(11L, LocalDate.of(2026, 9, 7), 1, 1L, room, group)));
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group));
|
||||
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
|
||||
when(classroomRepository.findAll()).thenReturn(List.of(room));
|
||||
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
|
||||
|
||||
var result = service.analyze(semester.getId(), 12L);
|
||||
|
||||
assertThat(result.scheduleVersionStatus()).isEqualTo(ScheduleVersionStatus.DRAFT);
|
||||
assertThat(result.scheduleVersionId()).isEqualTo(12L);
|
||||
assertThat(result.lessonCount()).isOne();
|
||||
verify(scheduleQueryService, never()).searchForAggregation(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
private void stubContext(
|
||||
Semester semester,
|
||||
StudentGroup group,
|
||||
List<Classroom> classrooms,
|
||||
List<RenderedLessonDto> lessons
|
||||
) {
|
||||
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
|
||||
when(scheduleQueryService.searchForAggregation(
|
||||
null,
|
||||
null,
|
||||
semester.getStartDate(),
|
||||
semester.getEndDate()
|
||||
)).thenReturn(lessons);
|
||||
when(groupRepository.findAll()).thenReturn(List.of(group));
|
||||
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
|
||||
when(classroomRepository.findAll()).thenReturn(classrooms);
|
||||
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private Semester semester() {
|
||||
AcademicYear academicYear = new AcademicYear();
|
||||
academicYear.setId(1L);
|
||||
academicYear.setTitle("2026/2027");
|
||||
academicYear.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
academicYear.setEndDate(LocalDate.of(2027, 8, 31));
|
||||
Semester semester = new Semester();
|
||||
semester.setId(7L);
|
||||
semester.setAcademicYear(academicYear);
|
||||
semester.setSemesterType(SemesterType.autumn);
|
||||
semester.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
semester.setEndDate(LocalDate.of(2026, 9, 30));
|
||||
return semester;
|
||||
}
|
||||
|
||||
private StudentGroup group(Long id, Long size) {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(id);
|
||||
group.setName("ИБ-101");
|
||||
group.setGroupSize(size);
|
||||
return group;
|
||||
}
|
||||
|
||||
private Classroom classroom(Long id, String name, int capacity) {
|
||||
Classroom classroom = new Classroom();
|
||||
classroom.setId(id);
|
||||
classroom.setName(name);
|
||||
classroom.setCapacity(capacity);
|
||||
classroom.setIsAvailable(true);
|
||||
return classroom;
|
||||
}
|
||||
|
||||
private TimeSlot timeSlot(Long id, int order) {
|
||||
TimeSlot slot = new TimeSlot();
|
||||
slot.setId(id);
|
||||
slot.setOrderNumber(order);
|
||||
slot.setStartTime(LocalTime.of(8 + order, 0));
|
||||
slot.setEndTime(LocalTime.of(9 + order, 30));
|
||||
return slot;
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(
|
||||
Long ruleSlotId,
|
||||
LocalDate date,
|
||||
int order,
|
||||
Long timeSlotId,
|
||||
Classroom classroom,
|
||||
StudentGroup group
|
||||
) {
|
||||
return new RenderedLessonDto(
|
||||
100L + ruleSlotId,
|
||||
ruleSlotId,
|
||||
date,
|
||||
date.getDayOfWeek().getValue(),
|
||||
"Понедельник",
|
||||
1,
|
||||
ScheduleParity.BOTH,
|
||||
timeSlotId,
|
||||
order,
|
||||
LocalTime.of(8 + order, 0),
|
||||
LocalTime.of(9 + order, 30),
|
||||
1L,
|
||||
"Архитектура систем",
|
||||
5L,
|
||||
"Петров Пётр Петрович",
|
||||
classroom.getId(),
|
||||
classroom.getName(),
|
||||
1L,
|
||||
"Лекция",
|
||||
"Очно",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(group.getId()),
|
||||
List.of(group.getName()),
|
||||
"Т",
|
||||
36,
|
||||
0,
|
||||
34,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -267,9 +267,15 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
|
||||
RETURNING id
|
||||
""", Long.class, academicYearId);
|
||||
Long scheduleVersionId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO schedule_versions (semester_id, version_number, name, status)
|
||||
VALUES (?, 1, 'Конкурентный черновик', 'DRAFT')
|
||||
RETURNING id
|
||||
""", Long.class, semesterId);
|
||||
|
||||
return new SeedIds(
|
||||
semesterId,
|
||||
scheduleVersionId,
|
||||
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
|
||||
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
|
||||
queryId("""
|
||||
@@ -332,7 +338,8 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
1,
|
||||
List.of(ids.groupId()),
|
||||
List.of(),
|
||||
List.of(slot)
|
||||
List.of(slot),
|
||||
ids.scheduleVersionId()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,6 +362,7 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
|
||||
private record SeedIds(
|
||||
long semesterId,
|
||||
long scheduleVersionId,
|
||||
long subjectId,
|
||||
long groupId,
|
||||
long timeSlotId,
|
||||
|
||||
@@ -432,7 +432,8 @@ class ScheduleRuleServiceTest {
|
||||
1,
|
||||
List.of(GROUP_ID),
|
||||
null,
|
||||
List.of(slots)
|
||||
List.of(slots),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleVersionCreateDto;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionHistory;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.model.Subject;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ScheduleVersionServiceTest {
|
||||
|
||||
private ScheduleVersionRepository versionRepository;
|
||||
private ScheduleVersionHistoryRepository historyRepository;
|
||||
private ScheduleRuleRepository ruleRepository;
|
||||
private SemesterRepository semesterRepository;
|
||||
private ScheduleRuleService ruleService;
|
||||
private ScheduleGeneratorService generatorService;
|
||||
private ScheduleVersionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
versionRepository = mock(ScheduleVersionRepository.class);
|
||||
historyRepository = mock(ScheduleVersionHistoryRepository.class);
|
||||
ruleRepository = mock(ScheduleRuleRepository.class);
|
||||
semesterRepository = mock(SemesterRepository.class);
|
||||
ruleService = mock(ScheduleRuleService.class);
|
||||
generatorService = mock(ScheduleGeneratorService.class);
|
||||
service = new ScheduleVersionService(
|
||||
versionRepository,
|
||||
historyRepository,
|
||||
ruleRepository,
|
||||
semesterRepository,
|
||||
mock(UserRepository.class),
|
||||
ruleService,
|
||||
generatorService
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftClonesRulesWithoutChangingPublishedVersion() {
|
||||
Semester semester = semester();
|
||||
ScheduleVersion published = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
|
||||
ScheduleRule source = rule(21L, 100L, published);
|
||||
when(semesterRepository.findByIdForUpdate(semester.getId())).thenReturn(Optional.of(semester));
|
||||
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(published));
|
||||
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> {
|
||||
ScheduleVersion draft = invocation.getArgument(0);
|
||||
draft.setId(2L);
|
||||
return draft;
|
||||
});
|
||||
when(ruleRepository.findActiveByVersionIdWithDetails(1L, null)).thenReturn(List.of(source));
|
||||
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of());
|
||||
when(versionRepository.findByIdWithDetails(2L)).thenAnswer(invocation -> Optional.of(
|
||||
version(2L, 2, ScheduleVersionStatus.DRAFT, semester)
|
||||
));
|
||||
|
||||
service.createDraft(new ScheduleVersionCreateDto(semester.getId(), "Рабочая копия", 1L));
|
||||
|
||||
ArgumentCaptor<List<ScheduleRule>> copies = ArgumentCaptor.forClass(List.class);
|
||||
verify(ruleRepository).saveAllAndFlush(copies.capture());
|
||||
assertThat(copies.getValue()).singleElement().satisfies(copy -> {
|
||||
assertThat(copy.getId()).isNull();
|
||||
assertThat(copy.getVersionGroupId()).isEqualTo(100L);
|
||||
assertThat(copy.getScheduleVersion().getId()).isEqualTo(2L);
|
||||
assertThat(copy.getSubject()).isSameAs(source.getSubject());
|
||||
});
|
||||
assertThat(published.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicationArchivesCurrentVersionAndActivatesWholeDraft() {
|
||||
Semester semester = semester();
|
||||
ScheduleVersion current = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
|
||||
ScheduleVersion draft = version(2L, 2, ScheduleVersionStatus.DRAFT, semester);
|
||||
ScheduleRule draftRule = rule(22L, 100L, draft);
|
||||
when(versionRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(draft));
|
||||
when(versionRepository.findByIdWithDetails(2L)).thenReturn(Optional.of(draft));
|
||||
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(current, draft));
|
||||
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of(draftRule));
|
||||
when(ruleService.validateVersion(2L)).thenReturn(List.of());
|
||||
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
service.publish(2L, "Утверждена новая сетка");
|
||||
|
||||
assertThat(current.getStatus()).isEqualTo(ScheduleVersionStatus.ARCHIVED);
|
||||
assertThat(draft.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
|
||||
assertThat(draft.getChangeReason()).isEqualTo("Утверждена новая сетка");
|
||||
assertThat(draft.getPublishedAt()).isNotNull();
|
||||
verify(generatorService).clearCache();
|
||||
ArgumentCaptor<ScheduleVersionHistory> history = ArgumentCaptor.forClass(ScheduleVersionHistory.class);
|
||||
verify(historyRepository, org.mockito.Mockito.times(2)).save(history.capture());
|
||||
assertThat(history.getAllValues()).extracting(ScheduleVersionHistory::getAction)
|
||||
.containsExactly("ARCHIVED", "PUBLISHED");
|
||||
}
|
||||
|
||||
private Semester semester() {
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(5L);
|
||||
year.setTitle("2026-2027");
|
||||
Semester semester = new Semester();
|
||||
semester.setId(7L);
|
||||
semester.setAcademicYear(year);
|
||||
semester.setSemesterType(SemesterType.autumn);
|
||||
return semester;
|
||||
}
|
||||
|
||||
private ScheduleVersion version(Long id,
|
||||
int number,
|
||||
ScheduleVersionStatus status,
|
||||
Semester semester) {
|
||||
ScheduleVersion version = new ScheduleVersion();
|
||||
version.setId(id);
|
||||
version.setSemester(semester);
|
||||
version.setVersionNumber(number);
|
||||
version.setName("Версия " + number);
|
||||
version.setStatus(status);
|
||||
return version;
|
||||
}
|
||||
|
||||
private ScheduleRule rule(Long id, Long versionGroupId, ScheduleVersion version) {
|
||||
Subject subject = new Subject();
|
||||
subject.setId(11L);
|
||||
subject.setName("Математика");
|
||||
ScheduleRule rule = new ScheduleRule();
|
||||
rule.setId(id);
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(version.getSemester());
|
||||
rule.setScheduleVersion(version);
|
||||
rule.setVersionGroupId(versionGroupId);
|
||||
rule.setLectureAcademicHours(2);
|
||||
rule.setLaboratoryAcademicHours(0);
|
||||
rule.setPracticeAcademicHours(0);
|
||||
rule.setLectureStartWeek(1);
|
||||
rule.setLaboratoryStartWeek(1);
|
||||
rule.setPracticeStartWeek(1);
|
||||
ScheduleRuleSlot slot = new ScheduleRuleSlot();
|
||||
slot.setScheduleRule(rule);
|
||||
rule.setSlots(new LinkedHashSet<>(List.of(slot)));
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
114
docs/API.md
114
docs/API.md
@@ -623,6 +623,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduleVersionId": 7,
|
||||
"subjectId": 1,
|
||||
"semesterId": 1,
|
||||
"lectureAcademicHours": 32,
|
||||
@@ -652,7 +653,7 @@ CRUD доступен по:
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` |
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||
@@ -660,6 +661,11 @@ CRUD доступен по:
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
Создание, изменение и архивирование правил разрешены только внутри версии со статусом
|
||||
`DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
|
||||
семестру. Без `versionId` список возвращает только правила текущей опубликованной версии,
|
||||
а конструктор всегда передаёт ID выбранного черновика.
|
||||
|
||||
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит
|
||||
@@ -841,6 +847,112 @@ API возвращает `409 Conflict`; соседние интервалы и
|
||||
}
|
||||
```
|
||||
|
||||
### Версии и публикация расписания
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/edu-office/schedule/versions?semesterId=` | Версии семестра с авторами, датами и количеством правил |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}` | Одна версия |
|
||||
| `POST` | `/api/edu-office/schedule/versions` | Создать пустой черновик или копию выбранной версии |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}/validate` | Полная проверка правил черновика |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}/diff?baseVersionId=` | Сравнение правил и сформированных занятий |
|
||||
| `POST` | `/api/edu-office/schedule/versions/{id}/publish` | Атомарно опубликовать проверенный черновик |
|
||||
| `POST` | `/api/edu-office/schedule/versions/{id}/restore` | Восстановить ранее опубликованную архивную версию |
|
||||
| `GET` | `/api/edu-office/schedule/versions/history?semesterId=` | Хронология создания, публикации, архивации и восстановления |
|
||||
|
||||
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна
|
||||
версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание
|
||||
копии переносит правила, группы, слоты и подгруппы, сохраняя `versionGroupId` для diff.
|
||||
|
||||
```json
|
||||
{
|
||||
"semesterId": 3,
|
||||
"name": "Расписание после распределения аудиторий",
|
||||
"basedOnVersionId": 11
|
||||
}
|
||||
```
|
||||
|
||||
Публикация и восстановление принимают обязательную причину:
|
||||
|
||||
```json
|
||||
{ "reason": "Согласовано учебным отделом 11.08.2026" }
|
||||
```
|
||||
|
||||
Перед публикацией backend блокирует версию, повторно проверяет весь набор активных правил,
|
||||
архивирует текущую публикацию и переводит черновик в `PUBLISHED` одной транзакцией. При
|
||||
ошибке ни один статус не меняется. Обычные endpoints просмотра и генерации выбирают только
|
||||
опубликованную версию; overrides архивных версий не попадают в актуальное расписание и
|
||||
операционный реестр.
|
||||
|
||||
### Анализ качества расписания
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/edu-office/schedule/quality?semesterId=&versionId=` | Итоговая оценка, метрики и объяснимый список проблем выбранной версии |
|
||||
| `GET` | `/api/edu-office/schedule/quality/recommendations?semesterId=&versionId=&scheduleRuleSlotId=&lessonDate=` | Проверенные локальные варианты улучшения опубликованного занятия |
|
||||
|
||||
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. `versionId` необязателен: без него
|
||||
анализируется текущая публикация. Для опубликованной версии анализатор рассматривает
|
||||
фактическое расписание всего семестра, включая уже применённые `schedule_overrides`, и учитывает окна
|
||||
групп и преподавателей, перегруженные дни, вместимость и избыточный размер аудитории,
|
||||
согласованные пожелания и недоступность преподавателей, а также равномерность нагрузки.
|
||||
Длинный семестр загружается внутренними интервалами не более 120 дней, поэтому публичный
|
||||
запрос не требует диапазона дат.
|
||||
|
||||
Черновик и архивная версия генерируются напрямую по собственным правилам, без подмешивания
|
||||
override текущей публикации. Для них доступны оценка, метрики и проблемы, но не локальные
|
||||
рекомендации: исправления черновика выполняются в конструкторе до публикации, архив остаётся
|
||||
только для чтения.
|
||||
|
||||
```json
|
||||
{
|
||||
"semesterId": 3,
|
||||
"semesterLabel": "2026/2027 · осенний семестр",
|
||||
"scheduleVersionId": 12,
|
||||
"scheduleVersionNumber": 2,
|
||||
"scheduleVersionName": "Расписание после распределения аудиторий",
|
||||
"scheduleVersionStatus": "DRAFT",
|
||||
"score": 84,
|
||||
"rawPenalty": 17,
|
||||
"lessonCount": 36,
|
||||
"totalProblemCount": 5,
|
||||
"problemsTruncated": false,
|
||||
"metrics": [
|
||||
{
|
||||
"code": "GROUP_GAPS",
|
||||
"label": "Окна у групп",
|
||||
"value": 3,
|
||||
"unit": "окон",
|
||||
"penalty": 6,
|
||||
"explanation": "Промежутки между занятиями одной группы"
|
||||
}
|
||||
],
|
||||
"problems": [
|
||||
{
|
||||
"id": "GROUP_GAP:2026-09-14:31:18",
|
||||
"type": "GROUP_GAP",
|
||||
"severity": "MEDIUM",
|
||||
"title": "Окно в расписании группы",
|
||||
"penalty": 2,
|
||||
"scheduleRuleSlotId": 31,
|
||||
"lessonDate": "2026-09-14",
|
||||
"optimizable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`score` находится в диапазоне от 0 до 100 и нормализует сумму объяснимых штрафов
|
||||
относительно числа занятий. Поле `totalProblemCount` содержит полный размер результата,
|
||||
а массив `problems` ограничен 300 элементами; `problemsTruncated` сообщает об усечении.
|
||||
|
||||
Рекомендации подбирают другой эффективный временной слот того же учебного дня либо
|
||||
подходящую активную аудиторию. Занятия, уже закреплённые ручным override, исключаются.
|
||||
Каждый кандидат до выдачи проходит `ScheduleOverrideService`, содержит готовый payload
|
||||
`override`, ожидаемые `scoreDelta`, `penaltyDelta`, улучшения и компромиссы. API ничего не
|
||||
изменяет автоматически: после подтверждения пользователь применяет payload обычным
|
||||
`POST /api/edu-office/schedule/overrides`.
|
||||
|
||||
### Отсутствия преподавателей и мастер замены
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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`, изменённая контрольная сумма вызовет
|
||||
ошибку проверки.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
|
||||
@@ -261,6 +261,9 @@ public class AbsenceController {
|
||||
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
|
||||
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
|
||||
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
|
||||
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних
|
||||
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в
|
||||
проекте нет.
|
||||
|
||||
### Применение
|
||||
|
||||
|
||||
@@ -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 уже выявляет накладки и превышение вместимости, а вкладка загруженности показывает
|
||||
занятые и свободные слоты. Новый анализатор не должен дублировать эти экраны: его ценность —
|
||||
единая объяснимая оценка, мягкие критерии качества и рекомендации по улучшению.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
790
frontend/admin/css/schedule-quality.css
Normal file
790
frontend/admin/css/schedule-quality.css
Normal 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;
|
||||
}
|
||||
}
|
||||
299
frontend/admin/css/schedule-versions.css
Normal file
299
frontend/admin/css/schedule-versions.css
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
},
|
||||
|
||||
462
frontend/admin/js/views/schedule-quality.js
Normal file
462
frontend/admin/js/views/schedule-quality.js
Normal 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] || 'Версия';
|
||||
}
|
||||
423
frontend/admin/js/views/schedule-versions.js
Normal file
423
frontend/admin/js/views/schedule-versions.js
Normal 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}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
98
frontend/admin/views/schedule-quality.html
Normal file
98
frontend/admin/views/schedule-quality.html
Normal 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>
|
||||
79
frontend/admin/views/schedule-versions.html
Normal file
79
frontend/admin/views/schedule-versions.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
52
frontend/tests/schedule-quality.test.mjs
Normal file
52
frontend/tests/schedule-quality.test.mjs
Normal 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, /Применить после подтверждения/);
|
||||
});
|
||||
34
frontend/tests/schedule-versions.test.mjs
Normal file
34
frontend/tests/schedule-versions.test.mjs
Normal 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\)/);
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user