задачи 2 и 8
This commit is contained in:
@@ -60,6 +60,8 @@ final class DatabaseConstraintViolationMapper {
|
|||||||
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
|
||||||
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
|
||||||
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
|
||||||
|
conflict("uq_schedule_versions_number", "Версия с таким номером уже существует в семестре"),
|
||||||
|
conflict("uq_schedule_versions_published_semester", "Для семестра уже опубликована другая версия расписания"),
|
||||||
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
|
||||||
conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
|
conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
|
||||||
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
|
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
|
@GetMapping
|
||||||
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
|
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
|
||||||
@RequestParam(name = "groupId", required = false) Long groupId) {
|
@RequestParam(name = "groupId", required = false) Long groupId,
|
||||||
return scheduleRuleService.getAll(semesterId, groupId);
|
@RequestParam(name = "versionId", required = false) Long versionId) {
|
||||||
|
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@@ -46,6 +47,7 @@ public class ScheduleRuleAdminController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
|
||||||
try {
|
try {
|
||||||
|
requireDraftVersion(request);
|
||||||
return ResponseEntity.ok(scheduleRuleService.create(request));
|
return ResponseEntity.ok(scheduleRuleService.create(request));
|
||||||
} catch (ScheduleRuleConflictException exception) {
|
} catch (ScheduleRuleConflictException exception) {
|
||||||
return conflictResponse(exception);
|
return conflictResponse(exception);
|
||||||
@@ -55,6 +57,7 @@ public class ScheduleRuleAdminController {
|
|||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
|
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
|
||||||
try {
|
try {
|
||||||
|
requireDraftVersion(request);
|
||||||
return ResponseEntity.ok(scheduleRuleService.update(id, request));
|
return ResponseEntity.ok(scheduleRuleService.update(id, request));
|
||||||
} catch (ScheduleRuleConflictException exception) {
|
} catch (ScheduleRuleConflictException exception) {
|
||||||
return conflictResponse(exception);
|
return conflictResponse(exception);
|
||||||
@@ -77,4 +80,10 @@ public class ScheduleRuleAdminController {
|
|||||||
body.put("conflictReasons", exception.getConflictReasons());
|
body.put("conflictReasons", exception.getConflictReasons());
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
|
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,
|
Integer practiceStartWeek,
|
||||||
List<Long> groupIds,
|
List<Long> groupIds,
|
||||||
List<String> groupNames,
|
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 jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -23,6 +24,23 @@ public class ScheduleRule {
|
|||||||
@JoinColumn(name = "semester_id", nullable = false)
|
@JoinColumn(name = "semester_id", nullable = false)
|
||||||
private Semester semester;
|
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)
|
@Column(nullable = false, length = 20)
|
||||||
private String status = LifecycleEntity.STATUS_ACTIVE;
|
private String status = LifecycleEntity.STATUS_ACTIVE;
|
||||||
|
|
||||||
@@ -85,6 +103,42 @@ public class ScheduleRule {
|
|||||||
this.semester = semester;
|
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() {
|
public String getStatus() {
|
||||||
return status;
|
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
|
from ScheduleOverride o
|
||||||
left join fetch o.baseRuleSlot baseSlot
|
left join fetch o.baseRuleSlot baseSlot
|
||||||
left join fetch baseSlot.scheduleRule rule
|
left join fetch baseSlot.scheduleRule rule
|
||||||
|
join rule.scheduleVersion version
|
||||||
left join fetch rule.semester
|
left join fetch rule.semester
|
||||||
left join fetch o.newTimeSlot
|
left join fetch o.newTimeSlot
|
||||||
left join fetch o.newClassroom
|
left join fetch o.newClassroom
|
||||||
left join fetch o.newTeacher
|
left join fetch o.newTeacher
|
||||||
where o.lessonDate between :startDate and :endDate
|
where (o.lessonDate between :startDate and :endDate
|
||||||
or o.targetLessonDate between :startDate and :endDate
|
or o.targetLessonDate between :startDate and :endDate)
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
|
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
|
||||||
@Param("startDate") LocalDate startDate,
|
@Param("startDate") LocalDate startDate,
|
||||||
@@ -38,6 +40,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
|||||||
from ScheduleOverride o
|
from ScheduleOverride o
|
||||||
join fetch o.baseRuleSlot baseSlot
|
join fetch o.baseRuleSlot baseSlot
|
||||||
join fetch baseSlot.scheduleRule rule
|
join fetch baseSlot.scheduleRule rule
|
||||||
|
join fetch rule.scheduleVersion version
|
||||||
join fetch rule.subject
|
join fetch rule.subject
|
||||||
left join fetch rule.groups
|
left join fetch rule.groups
|
||||||
join fetch rule.semester
|
join fetch rule.semester
|
||||||
@@ -48,6 +51,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
|||||||
left join fetch o.newTimeSlot
|
left join fetch o.newTimeSlot
|
||||||
left join fetch o.newClassroom
|
left join fetch o.newClassroom
|
||||||
left join fetch o.newTeacher
|
left join fetch o.newTeacher
|
||||||
|
where version.status = 'PUBLISHED'
|
||||||
order by o.lessonDate, o.id
|
order by o.lessonDate, o.id
|
||||||
""")
|
""")
|
||||||
List<ScheduleOverride> findAllWithRegistryDetails();
|
List<ScheduleOverride> findAllWithRegistryDetails();
|
||||||
@@ -57,6 +61,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
|||||||
from ScheduleOverride o
|
from ScheduleOverride o
|
||||||
join fetch o.baseRuleSlot baseSlot
|
join fetch o.baseRuleSlot baseSlot
|
||||||
join fetch baseSlot.scheduleRule rule
|
join fetch baseSlot.scheduleRule rule
|
||||||
|
join fetch rule.scheduleVersion version
|
||||||
join fetch rule.subject
|
join fetch rule.subject
|
||||||
left join fetch rule.groups
|
left join fetch rule.groups
|
||||||
join fetch rule.semester
|
join fetch rule.semester
|
||||||
@@ -67,8 +72,9 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
|||||||
left join fetch o.newTimeSlot
|
left join fetch o.newTimeSlot
|
||||||
left join fetch o.newClassroom
|
left join fetch o.newClassroom
|
||||||
left join fetch o.newTeacher
|
left join fetch o.newTeacher
|
||||||
where o.lessonDate between :startDate and :endDate
|
where (o.lessonDate between :startDate and :endDate
|
||||||
or o.targetLessonDate between :startDate and :endDate
|
or o.targetLessonDate between :startDate and :endDate)
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
order by o.lessonDate, o.id
|
order by o.lessonDate, o.id
|
||||||
""")
|
""")
|
||||||
List<ScheduleOverride> findForRegistry(
|
List<ScheduleOverride> findForRegistry(
|
||||||
@@ -81,12 +87,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
|
|||||||
from ScheduleOverride o
|
from ScheduleOverride o
|
||||||
left join fetch o.baseRuleSlot baseSlot
|
left join fetch o.baseRuleSlot baseSlot
|
||||||
left join fetch baseSlot.scheduleRule rule
|
left join fetch baseSlot.scheduleRule rule
|
||||||
|
join rule.scheduleVersion version
|
||||||
left join fetch rule.semester
|
left join fetch rule.semester
|
||||||
left join fetch o.newTimeSlot
|
left join fetch o.newTimeSlot
|
||||||
left join fetch o.newClassroom
|
left join fetch o.newClassroom
|
||||||
left join fetch o.newTeacher
|
left join fetch o.newTeacher
|
||||||
where o.lessonDate in :dates
|
where (o.lessonDate in :dates
|
||||||
or o.targetLessonDate in :dates
|
or o.targetLessonDate in :dates)
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
|
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
join r.groups g
|
join r.groups g
|
||||||
|
join r.scheduleVersion version
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
left join fetch sem.academicYear
|
||||||
|
left join fetch r.scheduleVersion
|
||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
left join fetch slots.timeSlot
|
||||||
@@ -30,6 +32,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
where g.id = :groupId
|
where g.id = :groupId
|
||||||
and sem.id = :semesterId
|
and sem.id = :semesterId
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
and r.status <> 'ARCHIVED'
|
and r.status <> 'ARCHIVED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleRule> findByGroupIdAndSemesterId(
|
List<ScheduleRule> findByGroupIdAndSemesterId(
|
||||||
@@ -41,6 +44,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
join r.slots teacherSlot
|
join r.slots teacherSlot
|
||||||
|
join r.scheduleVersion version
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
left join fetch sem.academicYear
|
||||||
@@ -54,6 +58,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
where teacherSlot.teacher.id = :teacherId
|
where teacherSlot.teacher.id = :teacherId
|
||||||
and sem.id = :semesterId
|
and sem.id = :semesterId
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
and r.status <> 'ARCHIVED'
|
and r.status <> 'ARCHIVED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
List<ScheduleRule> findByTeacherIdAndSemesterId(
|
||||||
@@ -65,6 +70,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
join r.groups matchingGroup
|
join r.groups matchingGroup
|
||||||
|
join r.scheduleVersion version
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
left join fetch sem.academicYear
|
||||||
@@ -78,6 +84,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
where matchingGroup.id in :groupIds
|
where matchingGroup.id in :groupIds
|
||||||
and sem.id in :semesterIds
|
and sem.id in :semesterIds
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
and r.status <> 'ARCHIVED'
|
and r.status <> 'ARCHIVED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
List<ScheduleRule> findByGroupIdsAndSemesterIds(
|
||||||
@@ -89,6 +96,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
join r.slots teacherSlot
|
join r.slots teacherSlot
|
||||||
|
join r.scheduleVersion version
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
left join fetch sem.academicYear
|
||||||
@@ -102,6 +110,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
where teacherSlot.teacher.id = :teacherId
|
where teacherSlot.teacher.id = :teacherId
|
||||||
and sem.id in :semesterIds
|
and sem.id in :semesterIds
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
and r.status <> 'ARCHIVED'
|
and r.status <> 'ARCHIVED'
|
||||||
""")
|
""")
|
||||||
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
List<ScheduleRule> findByTeacherIdAndSemesterIds(
|
||||||
@@ -112,6 +121,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
@Query("""
|
@Query("""
|
||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
|
join r.scheduleVersion version
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
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.classroom
|
||||||
left join fetch slots.lessonType
|
left join fetch slots.lessonType
|
||||||
where r.status <> 'ARCHIVED'
|
where r.status <> 'ARCHIVED'
|
||||||
|
and version.status = 'PUBLISHED'
|
||||||
order by r.id desc
|
order by r.id desc
|
||||||
""")
|
""")
|
||||||
List<ScheduleRule> findAllWithDetails();
|
List<ScheduleRule> findAllWithDetails();
|
||||||
@@ -156,12 +167,38 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
@Param("excludeRuleId") Long excludeRuleId
|
@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("""
|
@Query("""
|
||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
left join fetch r.subject
|
left join fetch r.subject
|
||||||
left join fetch r.semester sem
|
left join fetch r.semester sem
|
||||||
left join fetch sem.academicYear
|
left join fetch sem.academicYear
|
||||||
|
left join fetch r.scheduleVersion
|
||||||
left join fetch r.groups groups
|
left join fetch r.groups groups
|
||||||
left join fetch r.slots slots
|
left join fetch r.slots slots
|
||||||
left join fetch slots.timeSlot
|
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
|
@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("""
|
@Query("""
|
||||||
select count(preference) > 0
|
select count(preference) > 0
|
||||||
from TeacherPreference preference
|
from TeacherPreference preference
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ public class ScheduleGeneratorService {
|
|||||||
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
|
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,
|
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
LocalDate endDate) {
|
LocalDate endDate) {
|
||||||
@@ -113,6 +137,24 @@ public class ScheduleGeneratorService {
|
|||||||
.map(StudentGroup::getId)
|
.map(StudentGroup::getId)
|
||||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
|
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
|
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
|
||||||
.createScheduleSnapshot(groups, semesters, endDate);
|
.createScheduleSnapshot(groups, semesters, endDate);
|
||||||
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
|
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.ScheduleRuleDto;
|
||||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||||
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.model.Classroom;
|
import com.magistr.app.model.Classroom;
|
||||||
import com.magistr.app.model.LessonType;
|
import com.magistr.app.model.LessonType;
|
||||||
import com.magistr.app.model.LifecycleEntity;
|
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.ScheduleParity;
|
||||||
import com.magistr.app.model.ScheduleRule;
|
import com.magistr.app.model.ScheduleRule;
|
||||||
import com.magistr.app.model.ScheduleRuleSlot;
|
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.Semester;
|
||||||
import com.magistr.app.model.StudentGroup;
|
import com.magistr.app.model.StudentGroup;
|
||||||
import com.magistr.app.model.Subgroup;
|
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.GroupRepository;
|
||||||
import com.magistr.app.repository.LessonTypesRepository;
|
import com.magistr.app.repository.LessonTypesRepository;
|
||||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||||
|
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||||
import com.magistr.app.repository.SemesterRepository;
|
import com.magistr.app.repository.SemesterRepository;
|
||||||
import com.magistr.app.repository.SubjectRepository;
|
import com.magistr.app.repository.SubjectRepository;
|
||||||
import com.magistr.app.repository.SubgroupRepository;
|
import com.magistr.app.repository.SubgroupRepository;
|
||||||
@@ -72,6 +76,7 @@ public class ScheduleRuleService {
|
|||||||
private final SubgroupRepository subgroupRepository;
|
private final SubgroupRepository subgroupRepository;
|
||||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||||
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||||
|
private ScheduleVersionRepository scheduleVersionRepository;
|
||||||
|
|
||||||
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
||||||
SubjectRepository subjectRepository,
|
SubjectRepository subjectRepository,
|
||||||
@@ -100,9 +105,22 @@ public class ScheduleRuleService {
|
|||||||
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
void setScheduleVersionRepository(ScheduleVersionRepository scheduleVersionRepository) {
|
||||||
|
this.scheduleVersionRepository = scheduleVersionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
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(ScheduleRule::isActiveRecord)
|
||||||
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
|
||||||
.filter(rule -> groupId == null || rule.getGroups().stream()
|
.filter(rule -> groupId == null || rule.getGroups().stream()
|
||||||
@@ -127,11 +145,17 @@ public class ScheduleRuleService {
|
|||||||
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||||
|
|
||||||
ScheduleRule candidate = buildCandidate(request, semester);
|
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId());
|
||||||
|
|
||||||
|
ScheduleRule candidate = buildCandidate(request, semester, version);
|
||||||
validateCandidate(candidate);
|
validateCandidate(candidate);
|
||||||
validateAgainstSavedRules(candidate, null);
|
validateAgainstSavedRules(candidate, null);
|
||||||
|
|
||||||
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
|
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
|
||||||
|
if (saved.getVersionGroupId() == null && saved.getId() != null) {
|
||||||
|
saved.setVersionGroupId(saved.getId());
|
||||||
|
saved = scheduleRuleRepository.saveAndFlush(saved);
|
||||||
|
}
|
||||||
scheduleGeneratorService.clearCache();
|
scheduleGeneratorService.clearCache();
|
||||||
return toDto(saved);
|
return toDto(saved);
|
||||||
}
|
}
|
||||||
@@ -144,6 +168,7 @@ public class ScheduleRuleService {
|
|||||||
// определён по актуальному состоянию и два update одного правила не расходятся.
|
// определён по актуальному состоянию и два update одного правила не расходятся.
|
||||||
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
|
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||||
|
lockAndEnsureDraftRule(managedRule);
|
||||||
Long oldSemesterId = managedRule.getSemester().getId();
|
Long oldSemesterId = managedRule.getSemester().getId();
|
||||||
|
|
||||||
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
|
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
|
||||||
@@ -154,7 +179,18 @@ public class ScheduleRuleService {
|
|||||||
throw new IllegalArgumentException("Семестр не найден");
|
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);
|
validateCandidate(candidate);
|
||||||
validateAgainstSavedRules(candidate, managedRule.getId());
|
validateAgainstSavedRules(candidate, managedRule.getId());
|
||||||
|
|
||||||
@@ -168,6 +204,7 @@ public class ScheduleRuleService {
|
|||||||
public void archive(Long id) {
|
public void archive(Long id) {
|
||||||
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
|
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||||
|
lockAndEnsureDraftRule(rule);
|
||||||
Long semesterId = rule.getSemester().getId();
|
Long semesterId = rule.getSemester().getId();
|
||||||
semesterRepository.findByIdForUpdate(semesterId)
|
semesterRepository.findByIdForUpdate(semesterId)
|
||||||
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
|
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
|
||||||
@@ -274,7 +311,7 @@ public class ScheduleRuleService {
|
|||||||
normalizeLessonFormat(slot.lessonFormat());
|
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())
|
Subject subject = subjectRepository.findById(request.subjectId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
|
||||||
if (subject.isArchivedRecord()) {
|
if (subject.isArchivedRecord()) {
|
||||||
@@ -293,6 +330,11 @@ public class ScheduleRuleService {
|
|||||||
ScheduleRule candidate = new ScheduleRule();
|
ScheduleRule candidate = new ScheduleRule();
|
||||||
candidate.setSubject(subject);
|
candidate.setSubject(subject);
|
||||||
candidate.setSemester(semester);
|
candidate.setSemester(semester);
|
||||||
|
candidate.setScheduleVersion(version);
|
||||||
|
Long currentUserId = AuthContext.currentUserId();
|
||||||
|
if (currentUserId != null) {
|
||||||
|
userRepository.findById(currentUserId).ifPresent(candidate::setCreatedBy);
|
||||||
|
}
|
||||||
candidate.setLectureAcademicHours(request.lectureAcademicHours());
|
candidate.setLectureAcademicHours(request.lectureAcademicHours());
|
||||||
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||||
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
|
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||||
@@ -505,8 +547,12 @@ public class ScheduleRuleService {
|
|||||||
|
|
||||||
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
|
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
|
||||||
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
|
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
|
||||||
ScheduleRuleConflict conflict = scheduleRuleRepository
|
List<ScheduleRule> savedRules = candidate.getScheduleVersion() == null
|
||||||
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
|
? scheduleRuleRepository.findActiveBySemesterIdWithDetails(
|
||||||
|
candidate.getSemester().getId(), excludeRuleId)
|
||||||
|
: scheduleRuleRepository.findActiveByVersionIdWithDetails(
|
||||||
|
candidate.getScheduleVersion().getId(), excludeRuleId);
|
||||||
|
ScheduleRuleConflict conflict = savedRules
|
||||||
.stream()
|
.stream()
|
||||||
.filter(ScheduleRule::isActiveRecord)
|
.filter(ScheduleRule::isActiveRecord)
|
||||||
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
|
||||||
@@ -805,10 +851,72 @@ public class ScheduleRuleService {
|
|||||||
rule.getPracticeStartWeek(),
|
rule.getPracticeStartWeek(),
|
||||||
groups.stream().map(StudentGroup::getId).toList(),
|
groups.stream().map(StudentGroup::getId).toList(),
|
||||||
groups.stream().map(StudentGroup::getName).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) {
|
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||||
TimeSlot timeSlot = slot.getTimeSlot();
|
TimeSlot timeSlot = slot.getTimeSlot();
|
||||||
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
|
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
|
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
|
||||||
ON teacher_department_assignments
|
ON teacher_department_assignments
|
||||||
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
|
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,
|
1,
|
||||||
List.of(3L),
|
List.of(3L),
|
||||||
List.of("Группа"),
|
List.of("Группа"),
|
||||||
List.of()
|
List.of(),
|
||||||
|
9L
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ class ScheduleRuleAdminControllerTest {
|
|||||||
{
|
{
|
||||||
"subjectId": 1,
|
"subjectId": 1,
|
||||||
"semesterId": 2,
|
"semesterId": 2,
|
||||||
|
"scheduleVersionId": 9,
|
||||||
"lectureAcademicHours": 2,
|
"lectureAcademicHours": 2,
|
||||||
"laboratoryAcademicHours": 0,
|
"laboratoryAcademicHours": 0,
|
||||||
"practiceAcademicHours": 0,
|
"practiceAcademicHours": 0,
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
|
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);
|
insertScheduleRule(connection, seedIds, 2, 4, 6);
|
||||||
|
|
||||||
@@ -83,7 +90,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
private SeedIds loadSeedIds(Connection connection) throws SQLException {
|
private SeedIds loadSeedIds(Connection connection) throws SQLException {
|
||||||
return new SeedIds(
|
return new SeedIds(
|
||||||
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
|
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 (
|
INSERT INTO schedule_rules (
|
||||||
subject_id,
|
subject_id,
|
||||||
semester_id,
|
semester_id,
|
||||||
|
schedule_version_id,
|
||||||
lecture_academic_hours,
|
lecture_academic_hours,
|
||||||
laboratory_academic_hours,
|
laboratory_academic_hours,
|
||||||
practice_academic_hours,
|
practice_academic_hours,
|
||||||
lecture_start_week,
|
lecture_start_week,
|
||||||
laboratory_start_week,
|
laboratory_start_week,
|
||||||
practice_start_week
|
practice_start_week
|
||||||
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
|
) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 1)
|
||||||
""")) {
|
""")) {
|
||||||
statement.setLong(1, seedIds.subjectId());
|
statement.setLong(1, seedIds.subjectId());
|
||||||
statement.setLong(2, seedIds.semesterId());
|
statement.setLong(2, seedIds.semesterId());
|
||||||
statement.setInt(3, lectureHours);
|
statement.setLong(3, seedIds.scheduleVersionId());
|
||||||
statement.setInt(4, laboratoryHours);
|
statement.setInt(4, lectureHours);
|
||||||
statement.setInt(5, practiceHours);
|
statement.setInt(5, laboratoryHours);
|
||||||
|
statement.setInt(6, practiceHours);
|
||||||
statement.executeUpdate();
|
statement.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +153,23 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
.contains(HOURS_CONSTRAINT);
|
.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) {
|
private void assertExactSlotDuplicateRejected(Connection connection) {
|
||||||
Throwable failure = catchThrowable(() -> duplicateExistingSlot(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 teacherId = seedTeacherId(connection);
|
||||||
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||||
long secondDepartmentId = queryLong(connection, "SELECT max(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(
|
insertAssignment(
|
||||||
connection,
|
connection,
|
||||||
teacherId,
|
teacherId,
|
||||||
secondDepartmentId,
|
secondDepartmentId,
|
||||||
LocalDate.of(2026, 8, 1),
|
transferStart,
|
||||||
null,
|
null,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -59,7 +62,7 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
connection,
|
connection,
|
||||||
teacherId,
|
teacherId,
|
||||||
firstDepartmentId,
|
firstDepartmentId,
|
||||||
LocalDate.of(2026, 8, 1),
|
transferStart,
|
||||||
null,
|
null,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -69,8 +72,8 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
connection,
|
connection,
|
||||||
teacherId,
|
teacherId,
|
||||||
firstDepartmentId,
|
firstDepartmentId,
|
||||||
LocalDate.of(2026, 8, 15),
|
transferStart.plusDays(14),
|
||||||
LocalDate.of(2026, 9, 1),
|
transferStart.plusDays(31),
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
"23P01",
|
"23P01",
|
||||||
@@ -94,11 +97,14 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
long teacherId;
|
long teacherId;
|
||||||
long firstDepartmentId;
|
long firstDepartmentId;
|
||||||
long secondDepartmentId;
|
long secondDepartmentId;
|
||||||
|
LocalDate firstCandidateStart;
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
teacherId = seedTeacherId(connection);
|
teacherId = seedTeacherId(connection);
|
||||||
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
|
||||||
secondDepartmentId = queryLong(connection, "SELECT max(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);
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
@@ -109,16 +115,16 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
executor.submit(() -> concurrentInsert(
|
executor.submit(() -> concurrentInsert(
|
||||||
teacherId,
|
teacherId,
|
||||||
firstDepartmentId,
|
firstDepartmentId,
|
||||||
LocalDate.of(2090, 1, 1),
|
firstCandidateStart,
|
||||||
LocalDate.of(2090, 12, 31),
|
firstCandidateStart.plusDays(364),
|
||||||
ready,
|
ready,
|
||||||
start
|
start
|
||||||
)),
|
)),
|
||||||
executor.submit(() -> concurrentInsert(
|
executor.submit(() -> concurrentInsert(
|
||||||
teacherId,
|
teacherId,
|
||||||
secondDepartmentId,
|
secondDepartmentId,
|
||||||
LocalDate.of(2090, 6, 1),
|
firstCandidateStart.plusDays(151),
|
||||||
LocalDate.of(2091, 5, 31),
|
firstCandidateStart.plusDays(515),
|
||||||
ready,
|
ready,
|
||||||
start
|
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,
|
private void updateOpenPrimaryEnd(Connection connection,
|
||||||
long teacherId,
|
long teacherId,
|
||||||
LocalDate validTo) throws SQLException {
|
LocalDate validTo) throws SQLException {
|
||||||
|
|||||||
@@ -18,36 +18,38 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@DisplayName("Миграция пожеланий и заявок преподавателей")
|
@DisplayName("Преподавательские процессы в базовой схеме")
|
||||||
class TeacherPreferencesMigrationIntegrationTest {
|
class TeacherPreferencesMigrationIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
|
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("V3 создаёт три таблицы и отклоняет противоречивое пожелание")
|
@DisplayName("V1 содержит таблицы прежних V2/V3 и отклоняет противоречивое пожелание")
|
||||||
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
|
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
|
||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("3"))
|
.target(MigrationVersion.fromVersion("1"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("3");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
assertThat(queryLong(connection, """
|
assertThat(queryLong(connection, """
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name IN (
|
AND table_name IN (
|
||||||
|
'teacher_absences',
|
||||||
|
'teacher_absence_decisions',
|
||||||
'teacher_preferences',
|
'teacher_preferences',
|
||||||
'teacher_change_requests',
|
'teacher_change_requests',
|
||||||
'teacher_change_request_history'
|
'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 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");
|
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')
|
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", Long.class, academicYearId);
|
""", 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(
|
return new SeedIds(
|
||||||
semesterId,
|
semesterId,
|
||||||
|
scheduleVersionId,
|
||||||
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
|
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
|
||||||
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
|
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
|
||||||
queryId("""
|
queryId("""
|
||||||
@@ -332,7 +338,8 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
|||||||
1,
|
1,
|
||||||
List.of(ids.groupId()),
|
List.of(ids.groupId()),
|
||||||
List.of(),
|
List.of(),
|
||||||
List.of(slot)
|
List.of(slot),
|
||||||
|
ids.scheduleVersionId()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +362,7 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
|||||||
|
|
||||||
private record SeedIds(
|
private record SeedIds(
|
||||||
long semesterId,
|
long semesterId,
|
||||||
|
long scheduleVersionId,
|
||||||
long subjectId,
|
long subjectId,
|
||||||
long groupId,
|
long groupId,
|
||||||
long timeSlotId,
|
long timeSlotId,
|
||||||
|
|||||||
@@ -432,7 +432,8 @@ class ScheduleRuleServiceTest {
|
|||||||
1,
|
1,
|
||||||
List.of(GROUP_ID),
|
List.of(GROUP_ID),
|
||||||
null,
|
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
|
```json
|
||||||
{
|
{
|
||||||
|
"scheduleVersionId": 7,
|
||||||
"subjectId": 1,
|
"subjectId": 1,
|
||||||
"semesterId": 1,
|
"semesterId": 1,
|
||||||
"lectureAcademicHours": 32,
|
"lectureAcademicHours": 32,
|
||||||
@@ -652,7 +653,7 @@ CRUD доступен по:
|
|||||||
|
|
||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` |
|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||||
@@ -660,6 +661,11 @@ CRUD доступен по:
|
|||||||
|
|
||||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
|
Создание, изменение и архивирование правил разрешены только внутри версии со статусом
|
||||||
|
`DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
|
||||||
|
семестру. Без `versionId` список возвращает только правила текущей опубликованной версии,
|
||||||
|
а конструктор всегда передаёт ID выбранного черновика.
|
||||||
|
|
||||||
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
`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 | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|
|||||||
@@ -265,8 +265,32 @@ constraint. При чтении API разворачивает периоды о
|
|||||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||||
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||||
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
|
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила той же версии. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
|
||||||
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание сначала блокирует строку семестра, а update блокирует правило и старый/новый семестры в стабильном порядке, поэтому два backend-pod не могут одновременно пройти проверку одного семестра по устаревшему снимку.
|
- **Конкурентная запись:** публичные методы `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`:
|
Связь Many-to-Many через таблицу `teacher_subjects`:
|
||||||
|
|||||||
@@ -286,10 +286,37 @@ erDiagram
|
|||||||
BIGINT calendar_id FK
|
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 {
|
schedule_rules {
|
||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
BIGINT subject_id FK
|
BIGINT subject_id FK
|
||||||
BIGINT semester_id FK
|
BIGINT semester_id FK
|
||||||
|
BIGINT schedule_version_id FK
|
||||||
|
BIGINT version_group_id
|
||||||
VARCHAR status
|
VARCHAR status
|
||||||
DATE valid_from
|
DATE valid_from
|
||||||
DATE valid_to
|
DATE valid_to
|
||||||
@@ -442,6 +469,12 @@ erDiagram
|
|||||||
academic_years ||--o{ semesters : "academic_year_id"
|
academic_years ||--o{ semesters : "academic_year_id"
|
||||||
academic_years ||--o{ academic_calendars : "academic_year_id"
|
academic_years ||--o{ academic_calendars : "academic_year_id"
|
||||||
academic_years ||--o{ student_group_calendar_assignments : "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"
|
semesters ||--o{ schedule_rules : "semester_id"
|
||||||
specialties ||--o{ academic_calendars : "specialty_id"
|
specialties ||--o{ academic_calendars : "specialty_id"
|
||||||
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
||||||
@@ -896,12 +929,50 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки
|
триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки
|
||||||
ссылочных строк закрывают конкурентные записи между несколькими backend-pod.
|
ссылочных строк закрывают конкурентные записи между несколькими 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` — Правила расписания
|
#### `schedule_rules` — Правила расписания
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|---------|-----|----------|
|
|---------|-----|----------|
|
||||||
| `id` | BIGSERIAL PK | ID |
|
| `id` | BIGSERIAL PK | ID |
|
||||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||||
|
| `schedule_version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания |
|
||||||
| `lecture_academic_hours` | INT | Лимит академических часов лекций |
|
| `lecture_academic_hours` | INT | Лимит академических часов лекций |
|
||||||
| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ |
|
| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ |
|
||||||
| `practice_academic_hours` | INT | Лимит академических часов практик |
|
| `practice_academic_hours` | INT | Лимит академических часов практик |
|
||||||
@@ -914,7 +985,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||||
| `change_reason` | TEXT | Причина изменения |
|
| `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 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
|
||||||
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
|
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
|
||||||
@@ -1111,19 +1182,19 @@ CHECK фиксирует допустимую форму каждого типа
|
|||||||
|
|
||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
| `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` | Пожелания преподавателей на семестр, строгая и мягкая доступность, заявки на изменение занятия и неизменяемая история решений |
|
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
Исторические разработческие миграции V2–V7 по прямому решению владельца проекта были
|
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
|
||||||
объединены в baseline `V1`. После фиксации baseline нумерация начата заново: `V2`
|
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
|
||||||
добавляет отсутствия и мастер замены, а `V3` — пожелания преподавателей и заявки на
|
проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл
|
||||||
изменение занятий, не изменяя контрольную сумму `V1`.
|
`V1__init.sql`.
|
||||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
||||||
непосредственно в V1.
|
непосредственно в V1.
|
||||||
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где
|
||||||
|
предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет
|
||||||
|
ошибку проверки.
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ public class AbsenceController {
|
|||||||
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
|
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца
|
||||||
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
|
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о
|
||||||
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
|
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД.
|
||||||
|
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних
|
||||||
|
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в
|
||||||
|
проекте нет.
|
||||||
|
|
||||||
### Применение
|
### Применение
|
||||||
|
|
||||||
|
|||||||
@@ -112,15 +112,19 @@
|
|||||||
|
|
||||||
## 2. Черновики, версии, публикация и аудит расписания
|
## 2. Черновики, версии, публикация и аудит расписания
|
||||||
|
|
||||||
|
**Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`,
|
||||||
|
полное копирование правил, валидация и diff правил/занятий, атомарная публикация,
|
||||||
|
восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет
|
||||||
|
проверять черновики до публикации; изменения в них выполняются через конструктор правил.
|
||||||
|
|
||||||
### Проблема и пользователи
|
### Проблема и пользователи
|
||||||
|
|
||||||
Изменение активного правила сейчас сразу влияет на следующий запрос расписания. При большой
|
Изменение активного правила сейчас сразу влияет на следующий запрос расписания. При большой
|
||||||
переработке семестра это создаёт риск показать студентам и преподавателям промежуточное
|
переработке семестра это создаёт риск показать студентам и преподавателям промежуточное
|
||||||
состояние. Учебному отделу нужен безопасный цикл «подготовить — проверить — опубликовать».
|
состояние. Учебному отделу нужен безопасный цикл «подготовить — проверить — опубликовать».
|
||||||
|
|
||||||
В таблице `schedule_rules` уже предусмотрены поля `version_group_id`, `change_reason`,
|
Поля `version_group_id`, `change_reason`, `created_by` и `created_at` в `schedule_rules`
|
||||||
`created_by` и `created_at`, которые можно использовать как основу, но полноценный жизненный
|
используются вместе с отдельными таблицами версий и истории публикаций.
|
||||||
цикл версий пока не реализован.
|
|
||||||
|
|
||||||
### Пользовательский сценарий
|
### Пользовательский сценарий
|
||||||
|
|
||||||
@@ -144,9 +148,10 @@
|
|||||||
|
|
||||||
### Связь с текущей системой
|
### Связь с текущей системой
|
||||||
|
|
||||||
Текущая динамическая генерация сохраняется, но получает явный контекст версии. Конструктор
|
Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет
|
||||||
правил работает как с черновиком, так и с опубликованной версией, а кабинеты просмотра — только с опубликованной версией. Точечные изменения можно вести отдельным аудитом либо включать в пакет публикации в зависимости от
|
только черновик, кабинеты просмотра используют только опубликованную версию, а анализатор
|
||||||
будущих продуктовых требований.
|
качества позволяет выбрать опубликованную, черновую или архивную версию. Точечные изменения
|
||||||
|
остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически.
|
||||||
|
|
||||||
### Дальнейшее развитие
|
### Дальнейшее развитие
|
||||||
|
|
||||||
@@ -396,7 +401,10 @@ feed должен отдавать только опубликованное р
|
|||||||
**Ожидаемый результат:** заметное сокращение ручного заполнения и ошибок ввода.
|
**Ожидаемый результат:** заметное сокращение ручного заполнения и ошибок ввода.
|
||||||
**Сложность:** M. **Демонстрационный эффект:** высокий благодаря наглядному предпросмотру.
|
**Сложность:** M. **Демонстрационный эффект:** высокий благодаря наглядному предпросмотру.
|
||||||
|
|
||||||
## 8. (отдельным запросом) Анализ качества и автоматическая оптимизация расписания
|
## 8. Анализ качества и автоматическая оптимизация расписания
|
||||||
|
|
||||||
|
**Статус:** реализован MVP анализатора и локальных рекомендаций. Полный solver, фоновые
|
||||||
|
черновики и сравнение нескольких глобальных вариантов остаются дальнейшим этапом.
|
||||||
|
|
||||||
### Проблема и пользователи
|
### Проблема и пользователи
|
||||||
|
|
||||||
@@ -436,6 +444,12 @@ feed должен отдавать только опубликованное р
|
|||||||
конфликтного или неудобного занятия. Такой MVP позволяет проверить метрики качества,
|
конфликтного или неудобного занятия. Такой MVP позволяет проверить метрики качества,
|
||||||
интерфейс объяснений и производительность на реальных данных.
|
интерфейс объяснений и производительность на реальных данных.
|
||||||
|
|
||||||
|
Реализованный MVP анализирует фактическое расписание выбранного семестра с учётом ручных
|
||||||
|
правок, формирует оценку и список проблем, а для конкретного занятия предлагает проверенные
|
||||||
|
перестановки времени в пределах дня или аудитории. Кандидат применяется только после
|
||||||
|
подтверждения пользователя через существующий `schedule_override`; ручные правки считаются
|
||||||
|
закреплёнными.
|
||||||
|
|
||||||
Red Zone уже выявляет накладки и превышение вместимости, а вкладка загруженности показывает
|
Red Zone уже выявляет накладки и превышение вместимости, а вкладка загруженности показывает
|
||||||
занятые и свободные слоты. Новый анализатор не должен дублировать эти экраны: его ценность —
|
занятые и свободные слоты. Новый анализатор не должен дублировать эти экраны: его ценность —
|
||||||
единая объяснимая оценка, мягкие критерии качества и рекомендации по улучшению.
|
единая объяснимая оценка, мягкие критерии качества и рекомендации по улучшению.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ frontend/
|
|||||||
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
│ ├── auth-session.test.mjs # Login/refresh/reload/logout и single-flight refresh
|
||||||
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
│ ├── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||||
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
│ ├── schedule-overrides.test.mjs # Действия, роли, недельный выбор и подбор времени разовой правки
|
||||||
|
│ ├── schedule-quality.test.mjs # Метрики качества, фильтры проблем и доступность вкладки
|
||||||
|
│ ├── schedule-versions.test.mjs # Статусы версий, журнал и навигация в черновик/качество
|
||||||
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
│ ├── schedule-view-semesters.test.mjs # Выбор семестра и расчёт двухнедельного диапазона просмотра
|
||||||
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
│ ├── teacher-absences.test.mjs # Payload мастера замены и доступность вкладки по ролям
|
||||||
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
|
│ ├── teacher-preferences.test.mjs # Календарь пожеланий, заявки и подсказки конструктора
|
||||||
@@ -52,7 +54,9 @@ frontend/
|
|||||||
│ │ ├── modals.css # Модальные окна
|
│ │ ├── modals.css # Модальные окна
|
||||||
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
│ │ ├── auditorium-workload.css # Таблицы расписаний и загруженности
|
||||||
│ │ ├── departments-data.css # Стили создания кафедры/специальности
|
│ │ ├── departments-data.css # Стили создания кафедры/специальности
|
||||||
│ │ └── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей
|
│ │ ├── teacher-absences.css # Отсутствия, пожелания и очередь заявок преподавателей
|
||||||
|
│ │ ├── schedule-quality.css # Диагностический экран качества расписания
|
||||||
|
│ │ └── schedule-versions.css # Контур публикации, diff и журнал версий
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||||
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
│ │ ├── role-capabilities.js # Единая матрица вкладок admin/settings по ролям
|
||||||
@@ -72,6 +76,8 @@ frontend/
|
|||||||
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
│ │ ├── schedule-view.js # Просмотр расписаний и запуск разовой правки из карточки
|
||||||
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
│ │ ├── schedule-override-panel.js # Боковая панель и реестр разовых изменений
|
||||||
│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение
|
│ │ ├── teacher-absences.js # Отсутствия, согласование пожеланий и заявок на изменение
|
||||||
|
│ │ ├── schedule-quality.js # Оценка, фильтры и локальные рекомендации
|
||||||
|
│ │ ├── schedule-versions.js # Черновики, публикация, восстановление, diff и аудит
|
||||||
│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей
|
│ │ ├── schedule.js # Конструктор правил и подсказки пожеланий преподавателей
|
||||||
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
│ │ ├── academic-calendar-grid.js # Расчёт ISO-недели дневной сетки
|
||||||
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
│ │ ├── academic-calendar-title.js # Название из кода, профиля, формы и года
|
||||||
@@ -88,6 +94,8 @@ frontend/
|
|||||||
│ │ ├── department-workspace.html
|
│ │ ├── department-workspace.html
|
||||||
│ │ ├── schedule-view.html
|
│ │ ├── schedule-view.html
|
||||||
│ │ ├── teacher-absences.html
|
│ │ ├── teacher-absences.html
|
||||||
|
│ │ ├── schedule-quality.html
|
||||||
|
│ │ ├── schedule-versions.html
|
||||||
│ │ ├── schedule.html
|
│ │ ├── schedule.html
|
||||||
│ │ ├── academic-calendar.html
|
│ │ ├── academic-calendar.html
|
||||||
│ │ └── auditorium-workload.html
|
│ │ └── auditorium-workload.html
|
||||||
@@ -147,7 +155,7 @@ frontend/
|
|||||||
| Роль | Доступные вкладки |
|
| Роль | Доступные вкладки |
|
||||||
|------|-------------------|
|
|------|-------------------|
|
||||||
| `ADMIN` | Все вкладки |
|
| `ADMIN` | Все вкладки |
|
||||||
| `EDUCATION_OFFICE` | Просмотр и конструктор расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
|
| `EDUCATION_OFFICE` | Просмотр, конструктор и анализ качества расписаний, отсутствия и замены, календарный график, загруженность и аудитории; в settings — временные слоты и формы обучения |
|
||||||
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей |
|
| `DEPARTMENT` | Кабинет кафедры, просмотр расписаний, отсутствия и подтверждение заявок своих преподавателей |
|
||||||
| `SCHEDULE_VIEWER` | Только просмотр расписаний |
|
| `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` |
|
| `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` |
|
| `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` | Конструктор правил динамического расписания с подсказками согласованных пожеланий и выезжающей визуальной матрицей групп | `/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` |
|
| `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` |
|
| `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`; кафедра видит записи своих преподавателей без управляющих
|
`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`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
|
||||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||||
- Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
|
- Для `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/departments-data.css">
|
||||||
<link rel="stylesheet" href="css/auditorium-workload.css">
|
<link rel="stylesheet" href="css/auditorium-workload.css">
|
||||||
<link rel="stylesheet" href="css/teacher-absences.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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -87,6 +89,27 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Конструктор правил</span>
|
<span>Конструктор правил</span>
|
||||||
</a>
|
</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">
|
<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">
|
<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>
|
<path d="M3 3h18v18H3z"></path>
|
||||||
|
|||||||
@@ -101,6 +101,22 @@ const ROUTES = {
|
|||||||
await module.initSchedule();
|
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': {
|
'academic-calendar': {
|
||||||
title: 'Календарный учебный график',
|
title: 'Календарный учебный график',
|
||||||
file: '/admin/views/academic-calendar.html',
|
file: '/admin/views/academic-calendar.html',
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
const capabilities = {
|
const capabilities = {
|
||||||
ADMIN: {
|
ADMIN: {
|
||||||
defaultAdminTab: 'dashboard',
|
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',
|
defaultSettingsTab: 'general',
|
||||||
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
|
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
|
||||||
},
|
},
|
||||||
EDUCATION_OFFICE: {
|
EDUCATION_OFFICE: {
|
||||||
defaultAdminTab: 'dashboard',
|
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',
|
defaultSettingsTab: 'time-slots',
|
||||||
settingsTabs: ['time-slots', 'edu-forms']
|
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 ruleIdInput = document.getElementById('schedule-rule-id');
|
||||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
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, {
|
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||||||
hours: document.getElementById(config.hoursInputId),
|
hours: document.getElementById(config.hoursInputId),
|
||||||
startWeek: document.getElementById(config.startWeekInputId)
|
startWeek: document.getElementById(config.startWeekInputId)
|
||||||
@@ -125,6 +128,7 @@ export async function initSchedule() {
|
|||||||
let academicYears = [];
|
let academicYears = [];
|
||||||
let semesters = [];
|
let semesters = [];
|
||||||
let approvedTeacherPreferences = [];
|
let approvedTeacherPreferences = [];
|
||||||
|
let scheduleVersions = [];
|
||||||
let selectedVisualYearId = '';
|
let selectedVisualYearId = '';
|
||||||
let selectedVisualSemesterId = '';
|
let selectedVisualSemesterId = '';
|
||||||
let selectedVisualGroupIds = new Set();
|
let selectedVisualGroupIds = new Set();
|
||||||
@@ -139,8 +143,11 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
|
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
|
||||||
|
await loadScheduleVersions();
|
||||||
renderRuleGroups([]);
|
renderRuleGroups([]);
|
||||||
renderRuleSlots([{}]);
|
renderRuleSlots([{}]);
|
||||||
|
syncRuleSemesterWithVersion();
|
||||||
|
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
|
||||||
await loadRules();
|
await loadRules();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||||||
@@ -148,6 +155,17 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
refreshRulesButton?.addEventListener('click', loadRules);
|
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);
|
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||||
@@ -284,10 +302,62 @@ export async function initSchedule() {
|
|||||||
updatePreferenceHints();
|
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() {
|
async function loadRules() {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
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 {
|
try {
|
||||||
rules = await api.get('/api/admin/schedule-rules');
|
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||||
if (!visualPeriodTouched) {
|
if (!visualPeriodTouched) {
|
||||||
selectDefaultVisualPeriod();
|
selectDefaultVisualPeriod();
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1314,7 @@ export async function initSchedule() {
|
|||||||
id: Number(rule.id),
|
id: Number(rule.id),
|
||||||
subjectId: Number(rule.subjectId),
|
subjectId: Number(rule.subjectId),
|
||||||
semesterId: Number(rule.semesterId),
|
semesterId: Number(rule.semesterId),
|
||||||
|
scheduleVersionId: Number(rule.scheduleVersionId || scheduleVersionSelect.value),
|
||||||
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
||||||
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
||||||
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
||||||
@@ -1319,6 +1390,7 @@ export async function initSchedule() {
|
|||||||
const groupIds = selectedRuleGroupIds();
|
const groupIds = selectedRuleGroupIds();
|
||||||
const slots = readRuleSlots(true);
|
const slots = readRuleSlots(true);
|
||||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||||
|
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
|
||||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||||
const typeLimits = readTypeLimits();
|
const typeLimits = readTypeLimits();
|
||||||
validateTypeLimits(typeLimits, slots);
|
validateTypeLimits(typeLimits, slots);
|
||||||
@@ -1328,6 +1400,7 @@ export async function initSchedule() {
|
|||||||
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
||||||
subjectId: Number(ruleSubjectSelect.value),
|
subjectId: Number(ruleSubjectSelect.value),
|
||||||
semesterId: Number(ruleSemesterSelect.value),
|
semesterId: Number(ruleSemesterSelect.value),
|
||||||
|
scheduleVersionId: Number(scheduleVersionSelect.value),
|
||||||
...typeLimits,
|
...typeLimits,
|
||||||
groupIds,
|
groupIds,
|
||||||
slots
|
slots
|
||||||
@@ -1343,6 +1416,7 @@ export async function initSchedule() {
|
|||||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||||
renderRuleGroups(rule.groupIds || []);
|
renderRuleGroups(rule.groupIds || []);
|
||||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||||
|
syncRuleSemesterWithVersion();
|
||||||
hideAlert('schedule-rule-alert');
|
hideAlert('schedule-rule-alert');
|
||||||
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
@@ -1354,6 +1428,7 @@ export async function initSchedule() {
|
|||||||
fillTypeLimits({});
|
fillTypeLimits({});
|
||||||
renderRuleGroups([]);
|
renderRuleGroups([]);
|
||||||
renderRuleSlots([{}]);
|
renderRuleSlots([{}]);
|
||||||
|
syncRuleSemesterWithVersion();
|
||||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||||
hideAlert('schedule-rule-alert');
|
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">
|
<div class="card-header-row">
|
||||||
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
||||||
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
|
<button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"build": "npm run build:vendor",
|
"build": "npm run build:vendor",
|
||||||
"build:vendor": "node scripts/build-vendor.mjs",
|
"build:vendor": "node scripts/build-vendor.mjs",
|
||||||
"test": "node --test tests/*.test.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": {
|
"devDependencies": {
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@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