исправил качество правил и изменение расписания (не черновика)

This commit is contained in:
Zuev
2026-08-12 01:07:35 +03:00
parent 18a97b293a
commit 491e373cc3
23 changed files with 826 additions and 316 deletions

View File

@@ -47,7 +47,7 @@ public class ScheduleRuleAdminController {
@PostMapping @PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) { public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
try { try {
requireDraftVersion(request); requireScheduleVersion(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);
@@ -57,7 +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); requireScheduleVersion(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);
@@ -81,9 +81,9 @@ public class ScheduleRuleAdminController {
return ResponseEntity.status(HttpStatus.CONFLICT).body(body); return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
} }
private void requireDraftVersion(ScheduleRuleDto request) { private void requireScheduleVersion(ScheduleRuleDto request) {
if (request == null || request.scheduleVersionId() == null) { if (request == null || request.scheduleVersionId() == null) {
throw new IllegalArgumentException("Выберите черновик расписания"); throw new IllegalArgumentException("Выберите версию расписания");
} }
} }
} }

View File

@@ -38,6 +38,10 @@ public record ScheduleQualityAnalysisDto(
String title, String title,
String description, String description,
int penalty, int penalty,
Long scheduleRuleId,
int affectedLessonCount,
List<String> problemTypes,
List<String> issueLabels,
Long scheduleRuleSlotId, Long scheduleRuleSlotId,
LocalDate lessonDate, LocalDate lessonDate,
Integer timeSlotOrder, Integer timeSlotOrder,

View File

@@ -5,10 +5,23 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Collection;
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> { public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId") @Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId); boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
boolean existsByTimeSlotId(Long timeSlotId); boolean existsByTimeSlotId(Long timeSlotId);
@Query(value = """
SELECT EXISTS (
SELECT 1 FROM schedule_overrides WHERE base_rule_slot_id IN (:slotIds)
UNION ALL
SELECT 1 FROM teacher_change_requests WHERE base_rule_slot_id IN (:slotIds)
UNION ALL
SELECT 1 FROM teacher_absence_decisions WHERE base_rule_slot_id IN (:slotIds)
)
""", nativeQuery = true)
boolean hasProtectedReferences(@Param("slotIds") Collection<Long> slotIds);
} }

View File

@@ -3,8 +3,13 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto; import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto; import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
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.Semester;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository; import com.magistr.app.repository.SemesterRepository;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -12,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
@@ -26,13 +32,19 @@ public class AcademicPeriodService {
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository; private final SemesterRepository semesterRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleVersionRepository scheduleVersionRepository;
private final ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
public AcademicPeriodService(AcademicYearRepository academicYearRepository, public AcademicPeriodService(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository, SemesterRepository semesterRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService,
ScheduleVersionRepository scheduleVersionRepository,
ScheduleVersionHistoryRepository scheduleVersionHistoryRepository) {
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository; this.semesterRepository = semesterRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.scheduleVersionRepository = scheduleVersionRepository;
this.scheduleVersionHistoryRepository = scheduleVersionHistoryRepository;
} }
@Transactional @Transactional
@@ -102,6 +114,7 @@ public class AcademicPeriodService {
semester.setAcademicYear(year); semester.setAcademicYear(year);
applySemester(semester, request); applySemester(semester, request);
Semester saved = saveSemester(semester); Semester saved = saveSemester(semester);
createInitialPublishedSchedule(saved);
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
return saved; return saved;
} }
@@ -235,6 +248,28 @@ public class AcademicPeriodService {
} }
} }
private void createInitialPublishedSchedule(Semester semester) {
Instant now = Instant.now();
ScheduleVersion version = new ScheduleVersion();
version.setSemester(semester);
version.setVersionNumber(1);
version.setName("Основное расписание");
version.setStatus(ScheduleVersionStatus.PUBLISHED);
version.setPublishedAt(now);
ScheduleVersion saved = scheduleVersionRepository.saveAndFlush(version);
saveVersionHistory(saved, "CREATED", "Основное расписание создано вместе с семестром");
saveVersionHistory(saved, "PUBLISHED", "Начальная публикация расписания");
}
private void saveVersionHistory(ScheduleVersion version, String action, String reason) {
ScheduleVersionHistory history = new ScheduleVersionHistory();
history.setVersion(version);
history.setAction(action);
history.setReason(reason);
scheduleVersionHistoryRepository.save(history);
}
private void clearScheduleCacheAfterCommit() { private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive() if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) { || !TransactionSynchronizationManager.isActualTransactionActive()) {

View File

@@ -314,9 +314,7 @@ public class ScheduleQualityService {
"Насколько близко число пар в разные учебные дни каждой группы") "Насколько близко число пар в разные учебные дни каждой группы")
); );
List<ScheduleQualityAnalysisDto.Problem> problems = accumulator.problems.stream() List<ScheduleQualityAnalysisDto.Problem> problems = aggregateProblemsByRule(accumulator.problems);
.sorted(problemComparator())
.toList();
return new Evaluation(score, rawPenalty, lessonCount, metrics, problems); return new Evaluation(score, rawPenalty, lessonCount, metrics, problems);
} }
@@ -882,6 +880,10 @@ public class ScheduleQualityService {
title, title,
description, description,
penalty, penalty,
lesson.scheduleRuleId(),
1,
List.of(type),
List.of(title),
lesson.scheduleRuleSlotId(), lesson.scheduleRuleSlotId(),
lesson.date(), lesson.date(),
lesson.timeSlotOrder(), lesson.timeSlotOrder(),
@@ -894,6 +896,78 @@ public class ScheduleQualityService {
); );
} }
private List<ScheduleQualityAnalysisDto.Problem> aggregateProblemsByRule(
List<ScheduleQualityAnalysisDto.Problem> problems
) {
Map<String, List<ScheduleQualityAnalysisDto.Problem>> problemsByRule = new LinkedHashMap<>();
for (ScheduleQualityAnalysisDto.Problem problem : problems) {
String key = problem.scheduleRuleId() == null
? "PROBLEM:" + problem.id()
: "RULE:" + problem.scheduleRuleId();
problemsByRule.computeIfAbsent(key, ignored -> new ArrayList<>()).add(problem);
}
return problemsByRule.values().stream()
.map(this::aggregateRuleProblems)
.sorted(problemComparator())
.toList();
}
private ScheduleQualityAnalysisDto.Problem aggregateRuleProblems(
List<ScheduleQualityAnalysisDto.Problem> problems
) {
ScheduleQualityAnalysisDto.Problem representative = problems.stream()
.min(problemComparator())
.orElseThrow();
if (representative.scheduleRuleId() == null) {
return representative;
}
int penalty = problems.stream().mapToInt(ScheduleQualityAnalysisDto.Problem::penalty).sum();
List<String> problemTypes = problems.stream()
.flatMap(problem -> problem.problemTypes().stream())
.distinct()
.toList();
List<String> issueLabels = problems.stream()
.flatMap(problem -> problem.issueLabels().stream())
.distinct()
.toList();
int affectedLessonCount = (int) problems.stream()
.map(problem -> problem.scheduleRuleSlotId() + ":" + problem.lessonDate())
.distinct()
.count();
String subjectName = representative.subjectName();
String title = subjectName == null || subjectName.isBlank()
? "Правило расписания снижает качество"
: "Правило «" + subjectName + "» снижает качество";
String description = affectedLessonCount + " "
+ plural(affectedLessonCount, "занятие", "занятия", "занятий")
+ " этого правила "
+ plural(affectedLessonCount, "снижает", "снижают", "снижают")
+ " оценку: " + String.join("; ", issueLabels);
return new ScheduleQualityAnalysisDto.Problem(
"RULE:" + representative.scheduleRuleId(),
representative.type(),
representative.severity(),
title,
description,
penalty,
representative.scheduleRuleId(),
Math.max(affectedLessonCount, 1),
problemTypes,
issueLabels,
representative.scheduleRuleSlotId(),
representative.lessonDate(),
representative.timeSlotOrder(),
representative.subjectName(),
representative.entityId(),
representative.entityName(),
representative.requiredCapacity(),
representative.classroomCapacity(),
problems.stream().allMatch(ScheduleQualityAnalysisDto.Problem::optimizable)
);
}
private Comparator<ScheduleQualityAnalysisDto.Problem> problemComparator() { private Comparator<ScheduleQualityAnalysisDto.Problem> problemComparator() {
Map<String, Integer> severityOrder = Map.of( Map<String, Integer> severityOrder = Map.of(
"CRITICAL", 0, "CRITICAL", 0,

View File

@@ -23,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.ScheduleRuleSlotRepository;
import com.magistr.app.repository.ScheduleVersionRepository; 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;
@@ -77,6 +78,7 @@ public class ScheduleRuleService {
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService; private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
private ScheduleVersionRepository scheduleVersionRepository; private ScheduleVersionRepository scheduleVersionRepository;
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository, public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository, SubjectRepository subjectRepository,
@@ -110,6 +112,11 @@ public class ScheduleRuleService {
this.scheduleVersionRepository = scheduleVersionRepository; this.scheduleVersionRepository = scheduleVersionRepository;
} }
@Autowired(required = false)
void setScheduleRuleSlotRepository(ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) { public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null); return getAll(semesterId, groupId, null);
@@ -145,7 +152,7 @@ public class ScheduleRuleService {
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId()) Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден")); .orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId()); ScheduleVersion version = resolveEditableVersion(request.scheduleVersionId(), semester.getId());
ScheduleRule candidate = buildCandidate(request, semester, version); ScheduleRule candidate = buildCandidate(request, semester, version);
validateCandidate(candidate); validateCandidate(candidate);
@@ -168,7 +175,7 @@ public class ScheduleRuleService {
// определён по актуальному состоянию и два update одного правила не расходятся. // определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id) ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено")); .orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureDraftRule(managedRule); lockAndEnsureEditableRule(managedRule);
Long oldSemesterId = managedRule.getSemester().getId(); Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder( Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
@@ -194,7 +201,7 @@ public class ScheduleRuleService {
validateCandidate(candidate); validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId()); validateAgainstSavedRules(candidate, managedRule.getId());
applyCandidate(managedRule, candidate); applyCandidate(managedRule, candidate, request.slots());
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule); ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
scheduleGeneratorService.clearCache(); scheduleGeneratorService.clearCache();
return toDto(saved); return toDto(saved);
@@ -204,7 +211,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); lockAndEnsureEditableRule(rule);
Long semesterId = rule.getSemester().getId(); Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId) semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден")); .orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
@@ -805,7 +812,9 @@ public class ScheduleRuleService {
.toList(); .toList();
} }
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) { private void applyCandidate(ScheduleRule managedRule,
ScheduleRule candidate,
List<ScheduleRuleSlotDto> requestedSlots) {
managedRule.setSubject(candidate.getSubject()); managedRule.setSubject(candidate.getSubject());
managedRule.setSemester(candidate.getSemester()); managedRule.setSemester(candidate.getSemester());
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours()); managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
@@ -817,11 +826,54 @@ public class ScheduleRuleService {
managedRule.getGroups().clear(); managedRule.getGroups().clear();
managedRule.getGroups().addAll(candidate.getGroups()); managedRule.getGroups().addAll(candidate.getGroups());
managedRule.getSlots().clear(); List<ScheduleRuleSlot> candidateSlots = new ArrayList<>(candidate.getSlots());
for (ScheduleRuleSlot slot : candidate.getSlots()) { Map<Long, ScheduleRuleSlot> currentSlotsById = managedRule.getSlots().stream()
slot.setScheduleRule(managedRule); .filter(slot -> slot.getId() != null)
managedRule.getSlots().add(slot); .collect(Collectors.toMap(ScheduleRuleSlot::getId, Function.identity()));
Set<Long> retainedSlotIds = new HashSet<>();
Set<ScheduleRuleSlot> updatedSlots = new LinkedHashSet<>();
for (int index = 0; index < candidateSlots.size(); index++) {
ScheduleRuleSlot candidateSlot = candidateSlots.get(index);
Long requestedSlotId = requestedSlots.get(index).id();
if (requestedSlotId == null) {
candidateSlot.setScheduleRule(managedRule);
updatedSlots.add(candidateSlot);
continue;
} }
ScheduleRuleSlot existingSlot = currentSlotsById.get(requestedSlotId);
if (existingSlot == null) {
throw new IllegalArgumentException("Слот не принадлежит редактируемому правилу");
}
retainedSlotIds.add(requestedSlotId);
copySlot(existingSlot, candidateSlot);
updatedSlots.add(existingSlot);
}
List<Long> removedSlotIds = currentSlotsById.keySet().stream()
.filter(id -> !retainedSlotIds.contains(id))
.toList();
if (!removedSlotIds.isEmpty()
&& scheduleRuleSlotRepository != null
&& scheduleRuleSlotRepository.hasProtectedReferences(removedSlotIds)) {
throw new IllegalArgumentException(
"Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям"
);
}
managedRule.getSlots().clear();
managedRule.getSlots().addAll(updatedSlots);
}
private void copySlot(ScheduleRuleSlot target, ScheduleRuleSlot source) {
target.setDayOfWeek(source.getDayOfWeek());
target.setParity(source.getParity());
target.setTimeSlot(source.getTimeSlot());
target.setSubgroups(source.getSubgroups());
target.setTeacher(source.getTeacher());
target.setClassroom(source.getClassroom());
target.setLessonType(source.getLessonType());
target.setLessonFormat(source.getLessonFormat());
} }
private ScheduleRuleDto toDto(ScheduleRule rule) { private ScheduleRuleDto toDto(ScheduleRule rule) {
@@ -882,7 +934,7 @@ public class ScheduleRuleService {
return List.copyOf(issues); return List.copyOf(issues);
} }
private ScheduleVersion resolveDraftVersion(Long versionId, Long semesterId) { private ScheduleVersion resolveEditableVersion(Long versionId, Long semesterId) {
if (versionId == null) { if (versionId == null) {
return null; return null;
} }
@@ -894,27 +946,32 @@ public class ScheduleRuleService {
if (!Objects.equals(version.getSemester().getId(), semesterId)) { if (!Objects.equals(version.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Версия относится к другому семестру"); throw new IllegalArgumentException("Версия относится к другому семестру");
} }
if (version.getStatus() != ScheduleVersionStatus.DRAFT) { ensureEditableVersion(version);
throw new IllegalArgumentException("Изменять правила можно только в черновике");
}
return version; return version;
} }
private void ensureDraftRule(ScheduleRule rule) { private void ensureEditableVersion(ScheduleVersion version) {
if (rule.getScheduleVersion() != null if (version.getStatus() == ScheduleVersionStatus.ARCHIVED) {
&& rule.getScheduleVersion().getStatus() != ScheduleVersionStatus.DRAFT) { throw new IllegalArgumentException(
throw new IllegalArgumentException("Изменять правила можно только в черновике"); "Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик"
);
} }
} }
private void lockAndEnsureDraftRule(ScheduleRule rule) { private void ensureEditableRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null) {
ensureEditableVersion(rule.getScheduleVersion());
}
}
private void lockAndEnsureEditableRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) { if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
ScheduleVersion lockedVersion = scheduleVersionRepository ScheduleVersion lockedVersion = scheduleVersionRepository
.findByIdForUpdate(rule.getScheduleVersion().getId()) .findByIdForUpdate(rule.getScheduleVersion().getId())
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена")); .orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
rule.setScheduleVersion(lockedVersion); rule.setScheduleVersion(lockedVersion);
} }
ensureDraftRule(rule); ensureEditableRule(rule);
} }
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {

View File

@@ -3,9 +3,13 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto; import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto; import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear; import com.magistr.app.model.AcademicYear;
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.SemesterType; import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository; import com.magistr.app.repository.SemesterRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -42,6 +46,10 @@ class AcademicPeriodServiceTest {
private SemesterRepository semesterRepository; private SemesterRepository semesterRepository;
@Mock @Mock
private ScheduleGeneratorService scheduleGeneratorService; private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
private AcademicPeriodService service; private AcademicPeriodService service;
private AcademicYear year; private AcademicYear year;
@@ -51,13 +59,17 @@ class AcademicPeriodServiceTest {
service = new AcademicPeriodService( service = new AcademicPeriodService(
academicYearRepository, academicYearRepository,
semesterRepository, semesterRepository,
scheduleGeneratorService scheduleGeneratorService,
scheduleVersionRepository,
scheduleVersionHistoryRepository
); );
year = year(); year = year();
when(academicYearRepository.saveAndFlush(any(AcademicYear.class))) when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
when(semesterRepository.saveAndFlush(any(Semester.class))) when(semesterRepository.saveAndFlush(any(Semester.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
when(scheduleVersionRepository.saveAndFlush(any(ScheduleVersion.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
} }
@Test @Test
@@ -158,6 +170,12 @@ class AcademicPeriodServiceTest {
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1)); assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
assertThat(saved.getAcademicYear()).isSameAs(year); assertThat(saved.getAcademicYear()).isSameAs(year);
verify(scheduleVersionRepository).saveAndFlush(org.mockito.ArgumentMatchers.argThat(version ->
version.getSemester() == saved
&& version.getStatus() == ScheduleVersionStatus.PUBLISHED
&& "Основное расписание".equals(version.getName())
));
verify(scheduleVersionHistoryRepository, org.mockito.Mockito.times(2)).save(any());
verify(scheduleGeneratorService).clearCache(); verify(scheduleGeneratorService).clearCache();
} }

View File

@@ -81,8 +81,8 @@ class ScheduleQualityServiceTest {
assertThat(result.lessonCount()).isEqualTo(2); assertThat(result.lessonCount()).isEqualTo(2);
assertThat(result.score()).isLessThan(100); assertThat(result.score()).isLessThan(100);
assertThat(result.problems()) assertThat(result.problems().stream()
.extracting(problem -> problem.type()) .flatMap(problem -> problem.problemTypes().stream()))
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE"); .contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
assertThat(result.metrics()) assertThat(result.metrics())
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code())) .filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
@@ -90,6 +90,33 @@ class ScheduleQualityServiceTest {
.satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0)); .satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0));
} }
@Test
void analysisAggregatesRecurringProblemsByRuleAndSumsPenalty() {
Semester semester = semester();
StudentGroup group = group(1L, 30L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate firstDate = LocalDate.of(2026, 9, 7);
List<RenderedLessonDto> lessons = List.of(
lesson(777L, 11L, firstDate, 1, 1L, room, group),
lesson(777L, 11L, firstDate.plusWeeks(1), 1, 1L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.totalProblemCount()).isOne();
assertThat(result.rawPenalty()).isEqualTo(18);
assertThat(result.problems())
.singleElement()
.satisfies(problem -> {
assertThat(problem.scheduleRuleId()).isEqualTo(777L);
assertThat(problem.affectedLessonCount()).isEqualTo(2);
assertThat(problem.penalty()).isEqualTo(18);
assertThat(problem.problemTypes()).containsExactly("ROOM_CAPACITY_SHORTAGE");
assertThat(problem.issueLabels()).containsExactly("Аудитория меньше состава занятия");
});
}
@Test @Test
void analysisMeasuresDailyDistributionSeparatelyFromOverload() { void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
Semester semester = semester(); Semester semester = semester();
@@ -238,9 +265,21 @@ class ScheduleQualityServiceTest {
Long timeSlotId, Long timeSlotId,
Classroom classroom, Classroom classroom,
StudentGroup group StudentGroup group
) {
return lesson(100L + ruleSlotId, ruleSlotId, date, order, timeSlotId, classroom, group);
}
private RenderedLessonDto lesson(
Long ruleId,
Long ruleSlotId,
LocalDate date,
int order,
Long timeSlotId,
Classroom classroom,
StudentGroup group
) { ) {
return new RenderedLessonDto( return new RenderedLessonDto(
100L + ruleSlotId, ruleId,
ruleSlotId, ruleSlotId,
date, date,
date.getDayOfWeek().getValue(), date.getDayOfWeek().getValue(),

View File

@@ -10,6 +10,8 @@ import com.magistr.app.model.Role;
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.SemesterType; import com.magistr.app.model.SemesterType;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
@@ -22,6 +24,8 @@ 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.ScheduleRuleSlotRepository;
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;
@@ -88,6 +92,10 @@ class ScheduleRuleServiceTest {
private SubgroupRepository subgroupRepository; private SubgroupRepository subgroupRepository;
@Mock @Mock
private ScheduleGeneratorService scheduleGeneratorService; private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private ScheduleRuleService service; private ScheduleRuleService service;
private Subject subject; private Subject subject;
@@ -130,6 +138,8 @@ class ScheduleRuleServiceTest {
subgroupRepository, subgroupRepository,
scheduleGeneratorService scheduleGeneratorService
); );
service.setScheduleVersionRepository(scheduleVersionRepository);
service.setScheduleRuleSlotRepository(scheduleRuleSlotRepository);
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester)); when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID))) when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
@@ -188,6 +198,76 @@ class ScheduleRuleServiceTest {
assertThat(saved.practiceAcademicHours()).isZero(); assertThat(saved.practiceAcademicHours()).isZero();
} }
@Test
void allowsEditingPublishedVersionButRejectsArchive() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, null)).thenReturn(List.of());
ScheduleRuleDto saved = service.create(ruleRequestForVersion(90L, lectureSlot()));
assertThat(saved.scheduleVersionId()).isEqualTo(90L);
assertThat(saved.id()).isNotNull();
ScheduleVersion archived = scheduleVersion(91L, ScheduleVersionStatus.ARCHIVED);
when(scheduleVersionRepository.findByIdForUpdate(91L)).thenReturn(Optional.of(archived));
assertThatThrownBy(() -> service.create(ruleRequestForVersion(91L, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик");
}
@Test
void updatesRuleInsidePublishedVersionImmediately() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
managed.setScheduleVersion(published);
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
ScheduleRuleDto updated = service.update(
RULE_ID,
ruleRequestForVersion(90L, slot(
RULE_ID * 10,
ScheduleParity.BOTH,
TEACHER_ID,
CLASSROOM_ID,
LECTURE_TYPE_ID,
List.of(),
"Онлайн"
))
);
assertThat(updated.scheduleVersionId()).isEqualTo(90L);
assertThat(updated.slots()).singleElement()
.satisfies(slot -> {
assertThat(slot.id()).isEqualTo(RULE_ID * 10);
assertThat(slot.lessonFormat()).isEqualTo("Онлайн");
});
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsRemovingPublishedSlotReferencedByOperationalHistory() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
managed.setScheduleVersion(published);
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
when(scheduleRuleSlotRepository.hasProtectedReferences(List.of(RULE_ID * 10))).thenReturn(true);
assertThatThrownBy(() -> service.update(
RULE_ID,
ruleRequestForVersion(90L, lectureSlot())
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям");
verify(scheduleRuleRepository, never()).saveAndFlush(managed);
}
@Test @Test
void rejectsActiveUserWithoutTeacherRole() { void rejectsActiveUserWithoutTeacherRole() {
User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор"); User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор");
@@ -437,6 +517,37 @@ class ScheduleRuleServiceTest {
); );
} }
private ScheduleRuleDto ruleRequestForVersion(Long versionId, ScheduleRuleSlotDto... slots) {
return new ScheduleRuleDto(
null,
SUBJECT_ID,
null,
SEMESTER_ID,
null,
null,
2,
0,
0,
1,
1,
1,
List.of(GROUP_ID),
null,
List.of(slots),
versionId
);
}
private ScheduleVersion scheduleVersion(Long id, ScheduleVersionStatus status) {
ScheduleVersion version = new ScheduleVersion();
version.setId(id);
version.setSemester(semester);
version.setVersionNumber(1);
version.setName("Тестовая версия");
version.setStatus(status);
return version;
}
private ScheduleRuleSlotDto lectureSlot() { private ScheduleRuleSlotDto lectureSlot() {
return slot( return slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID, ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
@@ -461,8 +572,18 @@ class ScheduleRuleServiceTest {
long lessonTypeId, long lessonTypeId,
List<Long> subgroupIds, List<Long> subgroupIds,
String format) { String format) {
return slot(null, parity, teacherId, classroomId, lessonTypeId, subgroupIds, format);
}
private ScheduleRuleSlotDto slot(Long id,
ScheduleParity parity,
long teacherId,
long classroomId,
long lessonTypeId,
List<Long> subgroupIds,
String format) {
return new ScheduleRuleSlotDto( return new ScheduleRuleSlotDto(
null, id,
1, 1,
null, null,
parity, parity,

View File

@@ -529,6 +529,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
Повторное название года, повторный тип семестра или пересечение возвращаются как Повторное название года, повторный тип семестра или пересечение возвращаются как
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`. `409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
При сужении учебного года все существующие семестры должны оставаться внутри новых границ. При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
**Тело создания/обновления кода активности:** **Тело создания/обновления кода активности:**
```json ```json
@@ -661,10 +663,13 @@ CRUD доступен по:
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены только внутри версии со статусом Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
`DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному или `DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
семестру. Без `versionId` список возвращает только правила текущей опубликованной версии, семестру. Изменения `PUBLISHED`-версии сразу влияют на публичное расписание, изменения
а конструктор всегда передаёт ID выбранного черновика. `DRAFT` остаются изолированными до публикации, а `ARCHIVED` доступна только для чтения.
Без `versionId` список возвращает только правила текущей опубликованной версии. При update
ID существующих слотов сохраняются; удаление слота, на который ссылаются точечные изменения,
заявки преподавателей или решения по отсутствиям, отклоняется явной ошибкой `400`.
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы. `subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
@@ -862,7 +867,8 @@ API возвращает `409 Conflict`; соседние интервалы и
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна
версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание
копии переносит правила, группы, слоты и подгруппы, сохраняя `versionGroupId` для diff. копии может использовать опубликованную версию или другой черновик и переносит правила,
группы, слоты и подгруппы, сохраняя `versionGroupId` для diff.
```json ```json
{ {

View File

@@ -267,23 +267,27 @@ constraint. При чтении API разворачивает периоды о
- **Доступность аудитории:** `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 не может дописать правило после валидации черновика.
- **Связанные слоты:** update сохраняет ID переданных существующих слотов, чтобы не разрывать точечные изменения и операционный аудит. Удаление слота отклоняется, если на него ссылаются override, заявка преподавателя или решение по отсутствию.
### Черновики, версии и публикация ### Черновики, версии и публикация
Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии: Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии:
1. `DRAFT` создаётся пустым или как полная копия выбранной версии. 1. При создании семестра автоматически появляется пустая версия 1 `PUBLISHED` — основное расписание.
2. Конструктор добавляет, изменяет и архивирует правила только в выбранном черновике. 2. Конструктор по умолчанию открывает опубликованное расписание: изменения его правил сразу
3. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по видны конечным пользователям. Пользователь может переключиться на существующий `DRAFT`.
3. `DRAFT` создаётся пустым через API или прямо в конструкторе как полная копия выбранной
опубликованной либо черновой версии; после создания он сразу становится текущим.
4. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра. стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра.
4. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем 5. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
переводит проверенный черновик в `PUBLISHED`. переводит проверенный черновик в `PUBLISHED`.
5. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же 6. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же
атомарной операцией с обязательной причиной. атомарной операцией с обязательной причиной.
На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на
семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с
редактированием черновика или конкурентной публикацией. Каждое создание, архивирование, редактированием расписания или конкурентной публикацией. Каждое создание, архивирование,
публикация и восстановление записывается в неизменяемый журнал с автором, временем и публикация и восстановление записывается в неизменяемый журнал с автором, временем и
причиной. причиной.

View File

@@ -950,7 +950,9 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
Пара `semester_id + version_number` уникальна. Частичный индекс Пара `semester_id + version_number` уникальна. Частичный индекс
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на `uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
привязывает к ней существующие seed-правила. привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
`Основное расписание` и две записи истории: создание и публикацию.
#### `schedule_version_history` — Аудит версий расписания #### `schedule_version_history` — Аудит версий расписания

View File

@@ -115,7 +115,8 @@
**Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`, **Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`,
полное копирование правил, валидация и diff правил/занятий, атомарная публикация, полное копирование правил, валидация и diff правил/занятий, атомарная публикация,
восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет
проверять черновики до публикации; изменения в них выполняются через конструктор правил. проверять черновики до публикации. Конструктор по умолчанию редактирует опубликованное
расписание, позволяет переключиться на черновик и создать новую копию выбранной версии.
### Проблема и пользователи ### Проблема и пользователи
@@ -128,8 +129,9 @@
### Пользовательский сценарий ### Пользовательский сценарий
1. Пользователь создаёт черновик расписания для выбранного семестра. 1. Пользователь редактирует основное опубликованное расписание напрямую либо создаёт
2. Все изменения правил применяются только к черновику и не видны обычным пользователям. черновик для выбранного семестра на основе открытой версии.
2. Изменения опубликованной версии видны сразу, а изменения черновика изолированы до публикации.
3. Перед публикацией система проверяет конфликты и показывает сравнение с текущей версией: 3. Перед публикацией система проверяет конфликты и показывает сравнение с текущей версией:
добавленные, удалённые и изменённые занятия. добавленные, удалённые и изменённые занятия.
4. Ответственный сотрудник указывает причину и публикует версию одной операцией. 4. Ответственный сотрудник указывает причину и публикует версию одной операцией.
@@ -149,8 +151,9 @@
### Связь с текущей системой ### Связь с текущей системой
Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет
только черновик, кабинеты просмотра используют только опубликованную версию, а анализатор выбранную опубликованную или черновую версию, кабинеты просмотра используют только
качества позволяет выбрать опубликованную, черновую или архивную версию. Точечные изменения опубликованную, а анализатор качества позволяет выбрать опубликованную, черновую или
архивную версию. Точечные изменения
остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически. остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически.
### Дальнейшее развитие ### Дальнейшее развитие

View File

@@ -180,7 +180,7 @@ frontend/
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` |
| `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/edu-office/schedule/versions`, `/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-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` | | `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` |
@@ -219,9 +219,12 @@ frontend/
- Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как - Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как
отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные
пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал. пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал.
Новый черновик копирует выбранную опубликованную версию. Перед публикацией интерфейс В конструкторе опубликованная версия текущего семестра выбирается по умолчанию и выделяется
зелёным контуром; её изменения применяются сразу. Селект позволяет перейти в медный режим
черновика. Кнопка `Создать черновик` копирует выбранное опубликованное расписание или
черновик и сразу открывает новую рабочую копию. Перед публикацией интерфейс
одновременно запрашивает полную валидацию и diff, требует причину и блокирует действие одновременно запрашивает полную валидацию и diff, требует причину и блокирует действие
при ошибках. Кнопки черновика сохраняют его ID в `localStorage` и открывают конструктор при ошибках. Кнопки версий передают их 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 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.

View File

@@ -119,13 +119,18 @@
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1.15rem 1.3rem; padding: 1.15rem 1.3rem;
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card)); background: linear-gradient(105deg, color-mix(in srgb, var(--version-mint) 9%, var(--bg-card)), var(--bg-card));
border: 1px solid color-mix(in srgb, var(--version-copper) 30%, var(--bg-card-border)); border: 1px solid color-mix(in srgb, var(--version-mint) 34%, var(--bg-card-border));
border-radius: var(--radius-lg, 20px); border-radius: var(--radius-lg, 20px);
transition: background .2s ease, border-color .2s ease;
}
.version-constructor-bar[data-status="DRAFT"] {
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
} }
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; } .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-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 { display: grid; grid-template-columns: minmax(0, 1fr) auto 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 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-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); }
@@ -279,10 +284,12 @@
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; } .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 form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; } .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-card textarea,
.version-modal-card input { 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-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-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; } .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; }
.version-modal-summary[data-tone="error"] { color: var(--error); border: 1px solid color-mix(in srgb, var(--error) 40%, transparent); }
@media (max-width: 900px) { @media (max-width: 900px) {
.version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; } .version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; }
@@ -296,4 +303,6 @@
.version-live-mark { width: 54px; } .version-live-mark { width: 54px; }
.version-diff-score { grid-template-columns: 1fr; } .version-diff-score { grid-template-columns: 1fr; }
.version-history-row { grid-template-columns: 1fr; gap: .25rem; } .version-history-row { grid-template-columns: 1fr; gap: .25rem; }
.version-constructor-controls { grid-template-columns: 1fr; }
.version-constructor-controls label { grid-column: auto; }
} }

View File

@@ -12,7 +12,7 @@ export async function initScheduleQuality() {
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems); root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems); root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick); root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick); root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
await loadSemesters(); await loadSemesters();
} }
@@ -102,8 +102,8 @@ async function runAnalysis() {
renderAnalysis(); renderAnalysis();
qualityStatus( qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT' qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Исправления вносите в его правила до публикации.' ? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.', : 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
'success' 'success'
); );
} catch (error) { } catch (error) {
@@ -131,7 +131,7 @@ function renderAnalysis() {
qualityState.root.querySelector('#quality-score-meta').innerHTML = ` qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span> <span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span> <span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span> <span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span> <span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`; `;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || []) qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
@@ -145,11 +145,11 @@ function renderAnalysis() {
</article> </article>
`).join(''); `).join('');
const totalLabel = analysis.problemsTruncated const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}` ? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`; : `Правил с проблемами: ${analysis.totalProblemCount}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel; qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems(); renderProblems();
renderRecommendationsEmpty(); renderRulePanelEmpty();
} }
function renderProblems() { function renderProblems() {
@@ -171,6 +171,9 @@ function renderProblems() {
} }
container.innerHTML = qualityState.visibleProblems.map((problem, index) => { container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
const selected = problem.id === qualityState.selectedProblemId; const selected = problem.id === qualityState.selectedProblemId;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
return ` return `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}"> <article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}"> <button type="button" class="quality-problem-main" data-problem-index="${index}">
@@ -178,199 +181,92 @@ function renderProblems() {
<span class="quality-problem-copy"> <span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong> <strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span> <span>${escapeHtml(problem.description)}</span>
<span class="quality-rule-issues">${issueLabels.map(label => `
<span>${escapeHtml(label)}</span>
`).join('')}</span>
<small> <small>
${escapeHtml(formatDate(problem.lessonDate))} Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· ${escapeHtml(problem.subjectName || 'Занятие')} · ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''} · суммарный штраф
</small> </small>
</span> </span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span> <span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button> </button>
<div class="quality-problem-action"> <div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED' ${canEdit
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}"> ? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
Подобрать улучшения Изменить правило
</button>` </button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`} : '<span>Только просмотр</span>'}
</div> </div>
</article> </article>
`; `;
}).join(''); }).join('');
} }
async function handleProblemClick(event) { function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]'); const button = event.target.closest('[data-problem-index]');
if (!button) return; if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)]; const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return; if (!problem) return;
if (button.dataset.action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
qualityState.selectedProblemId = problem.id; qualityState.selectedProblemId = problem.id;
renderProblems(); renderProblems();
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') { renderRuleDetails(problem);
renderRecommendationsMessage(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик остаётся изолированным'
: 'Архивная версия доступна только для проверки',
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
);
return;
}
if (!problem.optimizable) {
renderRecommendationsMessage(
'Ручное решение сохранено',
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
);
return;
}
await loadRecommendations(problem);
} }
async function loadRecommendations(problem) { function renderRulePanelEmpty() {
const panel = qualityState.root.querySelector('#quality-recommendations'); const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
panel.innerHTML = ` renderRulePanelMessage(
<div class="quality-recommendations-loading"> archived ? 'Архивная версия' : 'Работа с правилом',
<span class="quality-loader"></span> archived
<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) { function renderRuleDetails(problem) {
const panel = qualityState.root.querySelector('#quality-recommendations'); const panel = qualityState?.root.querySelector('#quality-recommendations');
const candidates = result.candidates || []; if (!panel) return;
if (!candidates.length) { const affectedLessonCount = Number(problem.affectedLessonCount || 1);
renderRecommendationsMessage( const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
'Свободных улучшений не найдено', const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
);
return;
}
panel.innerHTML = ` panel.innerHTML = `
<div class="quality-recommendations-head"> <div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span> <span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.title)}</h3> <h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p> <p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div> </div>
<div class="quality-candidates"> <div class="quality-rule-detail-list">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')} ${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
</div> </div>
<p class="quality-recommendations-note"> ${canEdit ? `
Применение создаёт обычное точечное изменение и не переписывает правило семестра. <button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
</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> </button>
</article> ` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
`; `;
} }
function changeList(label, items, tone) { function handleRulePanelClick(event) {
if (!items?.length) return ''; const button = event.target.closest('[data-edit-rule-id]');
return ` if (!button) return;
<div class="quality-change-list is-${tone}"> const problem = (qualityState?.analysis?.problems || [])
<strong>${escapeHtml(label)}</strong> .find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')} if (problem) openRuleInConstructor(problem);
</div>
`;
} }
async function handleRecommendationClick(event) { function openRuleInConstructor(problem) {
const button = event.target.closest('[data-candidate-index]'); if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
if (!button || qualityState?.applying) return; localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
const panel = qualityState.root.querySelector('#quality-recommendations'); localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)]; document.querySelector('.nav-item[data-tab="schedule"]')?.click();
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() { function renderRulePanelMessage(title, message) {
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'); const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return; if (!panel) return;
panel.innerHTML = ` panel.innerHTML = `
@@ -380,7 +276,6 @@ function renderRecommendationsMessage(title, message) {
<p>${escapeHtml(message)}</p> <p>${escapeHtml(message)}</p>
</div> </div>
`; `;
panel._qualityCandidates = [];
} }
function qualityStatus(message, tone = '') { function qualityStatus(message, tone = '') {
@@ -393,7 +288,8 @@ function qualityStatus(message, tone = '') {
export function filterQualityProblems(problems, severity = '', category = '') { export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => { return (problems || []).filter(problem => {
const severityMatches = !severity || problem.severity === severity; const severityMatches = !severity || problem.severity === severity;
const categoryMatches = !category || problemCategory(problem.type) === category; const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches; return severityMatches && categoryMatches;
}); });
} }
@@ -448,13 +344,13 @@ function localIsoDate(date) {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
function problemWord(count) { function lessonWord(count) {
const mod100 = count % 100; const mod100 = count % 100;
const mod10 = count % 10; const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'проблем'; if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'проблема'; if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'проблемы'; if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'проблем'; return 'занятий';
} }
export function versionStatusLabel(status) { export function versionStatusLabel(status) {

View File

@@ -113,6 +113,7 @@ function renderCurrent(version) {
</div> </div>
</div> </div>
<div class="version-card-actions"> <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="quality" data-id="${version.id}">Проверить качество</button> <button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
</div> </div>
`; `;
@@ -196,7 +197,7 @@ async function handleVersionAction(event) {
if (!version) return; if (!version) return;
const action = button.dataset.action; const action = button.dataset.action;
if (action === 'edit') { if (action === 'edit') {
localStorage.setItem('magistr.schedule.versionId', String(version.id)); localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
navigateTo('schedule'); navigateTo('schedule');
return; return;
} }

View File

@@ -34,6 +34,17 @@ export function scheduleSlotActionLabel(slotCount) {
return slotCount === 1 ? 'Очистить' : 'Удалить'; return slotCount === 1 ? 'Очистить' : 'Удалить';
} }
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
return available.find(version => String(version.id) === String(requestedVersionId))
|| available.find(version => version.status === 'PUBLISHED'
&& String(version.semesterId) === String(semesterId))
|| available.find(version => version.status === 'PUBLISHED')
|| available.find(version => String(version.semesterId) === String(semesterId))
|| available[0]
|| null;
}
const LESSON_TYPE_LIMITS = [ const LESSON_TYPE_LIMITS = [
{ {
key: 'lecture', key: 'lecture',
@@ -68,8 +79,20 @@ export async function initSchedule() {
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 scheduleVersionSelect = document.getElementById('schedule-version-select');
const scheduleVersionContext = document.getElementById('schedule-version-context');
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
const scheduleVersionTitle = document.getElementById('schedule-version-title');
const scheduleVersionNote = document.getElementById('schedule-version-note'); const scheduleVersionNote = document.getElementById('schedule-version-note');
const scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft');
const scheduleVersionManage = document.getElementById('schedule-version-manage'); const scheduleVersionManage = document.getElementById('schedule-version-manage');
const draftModal = document.getElementById('schedule-draft-modal');
const draftForm = document.getElementById('schedule-draft-form');
const draftNameInput = document.getElementById('schedule-draft-name');
const draftCopy = document.getElementById('schedule-draft-copy');
const draftSummary = document.getElementById('schedule-draft-summary');
const draftCloseButton = document.getElementById('schedule-draft-close');
const draftCancelButton = document.getElementById('schedule-draft-cancel');
const draftSubmitButton = document.getElementById('schedule-draft-submit');
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)
@@ -138,6 +161,7 @@ export async function initSchedule() {
let pendingConflictResolution = null; let pendingConflictResolution = null;
let visualContextTarget = null; let visualContextTarget = null;
let pendingSlotMove = null; let pendingSlotMove = null;
let creatingDraft = false;
bindEvents(); bindEvents();
@@ -159,13 +183,21 @@ export async function initSchedule() {
if (scheduleVersionSelect.value) { if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value); localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
} }
updateVersionContext();
resetRuleForm(); resetRuleForm();
syncRuleSemesterWithVersion(); syncRuleSemesterWithVersion();
await loadRules(); await loadRules();
}); });
scheduleVersionCreateDraft?.addEventListener('click', openDraftModal);
scheduleVersionManage?.addEventListener('click', () => { scheduleVersionManage?.addEventListener('click', () => {
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click(); document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
}); });
draftCloseButton?.addEventListener('click', closeDraftModal);
draftCancelButton?.addEventListener('click', closeDraftModal);
draftModal?.addEventListener('click', event => {
if (event.target === draftModal) closeDraftModal();
});
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
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);
@@ -211,6 +243,10 @@ export async function initSchedule() {
}); });
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
if (draftModal && !draftModal.hidden) {
closeDraftModal();
return;
}
if (moveSlotModal?.classList.contains('open')) { if (moveSlotModal?.classList.contains('open')) {
closeMoveSlotModal(); closeMoveSlotModal();
return; return;
@@ -302,31 +338,123 @@ export async function initSchedule() {
updatePreferenceHints(); updatePreferenceHints();
} }
async function loadScheduleVersions() { async function loadScheduleVersions(preferredVersionId = null) {
const responses = await Promise.all(semesters.map(semester => const responses = await Promise.all(semesters.map(semester =>
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`) api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
)); ));
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT'); scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const storedId = localStorage.getItem('magistr.schedule.versionId'); const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
const stored = scheduleVersions.find(version => String(version.id) === storedId); localStorage.removeItem('magistr.schedule.openVersionId');
const selected = stored || scheduleVersions[0] || null; const selected = chooseScheduleVersion(
scheduleVersions,
ruleSemesterSelect.value || defaultScheduleSemesterId(),
requestedVersionId
);
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
const drafts = scheduleVersions.filter(version => version.status === 'DRAFT');
scheduleVersionSelect.innerHTML = scheduleVersions.length scheduleVersionSelect.innerHTML = scheduleVersions.length
? scheduleVersions.map(version => ` ? [
<option value="${version.id}"> versionOptionGroup('Опубликованные расписания', published),
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)} versionOptionGroup('Черновики', drafts)
</option> ].filter(Boolean).join('')
`).join('') : '<option value="">Расписания не созданы</option>';
: '<option value="">Черновики не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : ''; scheduleVersionSelect.value = selected ? String(selected.id) : '';
if (selected) { if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id)); localStorage.setItem('magistr.schedule.versionId', String(selected.id));
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
} else {
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
} }
updateVersionContext();
setRuleEditorEnabled(Boolean(selected)); setRuleEditorEnabled(Boolean(selected));
} }
function versionOptionGroup(label, versions) {
if (!versions.length) return '';
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
<option value="${version.id}">
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
</option>
`).join('')}</optgroup>`;
}
function defaultScheduleSemesterId() {
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
if (saved) return saved.id;
const today = localIsoDate(new Date());
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
return current?.id || semesters[0]?.id || '';
}
function updateVersionContext() {
const version = selectedScheduleVersion();
const isDraft = version?.status === 'DRAFT';
if (scheduleVersionContext) scheduleVersionContext.dataset.status = isDraft ? 'DRAFT' : 'PUBLISHED';
if (scheduleVersionEyebrow) scheduleVersionEyebrow.textContent = isDraft ? 'Безопасная рабочая копия' : 'Основной режим';
if (scheduleVersionTitle) scheduleVersionTitle.textContent = isDraft ? 'Редактирование черновика' : 'Опубликованное расписание';
if (!version) {
scheduleVersionNote.textContent = 'Расписание для редактирования не найдено.';
return;
}
scheduleVersionNote.textContent = isDraft
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`;
}
function openDraftModal() {
const base = selectedScheduleVersion();
if (!base || creatingDraft) return;
draftForm?.reset();
if (draftCopy) {
draftCopy.textContent = `За основу будет взято «${base.name}» — все его правила и слоты будут скопированы.`;
}
if (draftSummary) {
draftSummary.textContent = `${base.semesterLabel} · версия ${base.versionNumber}`;
draftSummary.dataset.tone = '';
}
draftModal.hidden = false;
draftNameInput?.focus();
}
function closeDraftModal() {
if (!draftModal || creatingDraft) return;
draftModal.hidden = true;
}
async function createDraftFromSelectedVersion(event) {
event.preventDefault();
const base = selectedScheduleVersion();
const name = draftNameInput?.value.trim();
if (!base || !name || creatingDraft) return;
creatingDraft = true;
if (draftSubmitButton) {
draftSubmitButton.disabled = true;
draftSubmitButton.textContent = 'Создаём…';
}
try {
const created = await api.post('/api/edu-office/schedule/versions', {
semesterId: Number(base.semesterId),
name,
basedOnVersionId: Number(base.id)
});
draftModal.hidden = true;
await loadScheduleVersions(created.id);
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
showAlert('schedule-rule-alert', `Черновик «${created.name}» создан и открыт.`, 'success');
} catch (error) {
if (draftSummary) {
draftSummary.textContent = error.message || 'Не удалось создать черновик.';
draftSummary.dataset.tone = 'error';
}
} finally {
creatingDraft = false;
if (draftSubmitButton) {
draftSubmitButton.disabled = false;
draftSubmitButton.textContent = 'Создать и открыть';
}
}
}
function selectedScheduleVersion() { function selectedScheduleVersion() {
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null; return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
} }
@@ -352,8 +480,8 @@ export async function initSchedule() {
const version = selectedScheduleVersion(); const version = selectedScheduleVersion();
if (!version) { if (!version) {
rules = []; rules = [];
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет расписания для редактирования</td></tr>';
setVisualState('Нет выбранного черновика'); setVisualState('Нет выбранного расписания');
return; return;
} }
try { try {
@@ -1390,7 +1518,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 (!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);
@@ -1456,7 +1584,7 @@ export async function initSchedule() {
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length); const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger'; const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => ` ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}"> <div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}" data-slot-id="${escapeHtml(String(slot.id || ''))}">
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div> <div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div> <div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div> <div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
@@ -1521,8 +1649,9 @@ export async function initSchedule() {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => { return rows.map((row, index) => {
const subgroupIds = readSlotSubgroupIds(row); const subgroupIds = readSlotSubgroupIds(row);
const slotId = Number(row.dataset.slotId);
const slot = { const slot = {
id: null, id: slotId || null,
dayOfWeek: Number(row.querySelector('.slot-day').value), dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value, parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value), timeSlotId: Number(row.querySelector('.slot-time').value),

View File

@@ -1,18 +1,38 @@
<section class="version-constructor-bar" id="schedule-version-context"> <section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
<div> <div>
<span class="version-eyebrow">Изолированный контекст</span> <span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
<h2>Рабочий черновик</h2> <h2 id="schedule-version-title">Опубликованное расписание</h2>
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p> <p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
</div> </div>
<div class="version-constructor-controls"> <div class="version-constructor-controls">
<label for="schedule-version-select">Версия расписания</label> <label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true"> <select id="schedule-version-select" data-native-select="true">
<option value="">Загрузка черновиков…</option> <option value="">Загрузка расписаний…</option>
</select> </select>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Управление версиями</button> <button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
</div> </div>
</section> </section>
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
<button type="button" class="version-modal-close" id="schedule-draft-close" aria-label="Закрыть">×</button>
<span class="version-eyebrow">Новая рабочая копия</span>
<h3 id="schedule-draft-title">Создать черновик</h3>
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
<form id="schedule-draft-form">
<label for="schedule-draft-name">Название черновика</label>
<input id="schedule-draft-name" type="text" maxlength="160" required
placeholder="Например, расписание после распределения аудиторий">
<div class="version-modal-summary" id="schedule-draft-summary"></div>
<div class="version-modal-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-draft-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-draft-submit">Создать и открыть</button>
</div>
</form>
</div>
</div>
<div class="card create-card" id="schedule-rule-editor"> <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>

View File

@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { historyActionLabel } from '../admin/js/views/schedule-versions.js'; import { historyActionLabel } from '../admin/js/views/schedule-versions.js';
import { versionStatusLabel } from '../admin/js/views/schedule-quality.js'; import { versionStatusLabel } from '../admin/js/views/schedule-quality.js';
import { chooseScheduleVersion } from '../admin/js/views/schedule.js';
test('журнал версий переводит системные действия на русский язык', () => { test('журнал версий переводит системные действия на русский язык', () => {
assert.equal(historyActionLabel('CREATED'), 'Создан черновик'); assert.equal(historyActionLabel('CREATED'), 'Создан черновик');
@@ -17,18 +18,37 @@ test('анализ качества различает черновик, пуб
assert.equal(versionStatusLabel('ARCHIVED'), 'Архив'); assert.equal(versionStatusLabel('ARCHIVED'), 'Архив');
}); });
test('контур версий подключён к SPA, а конструктор сохраняет правила в выбранный черновик', async () => { test('конструктор по умолчанию открывает опубликованное расписание выбранного семестра', () => {
const [main, roles, constructor, quality, index] = await Promise.all([ const versions = [
{ id: 1, semesterId: 10, status: 'DRAFT' },
{ id: 2, semesterId: 10, status: 'PUBLISHED' },
{ id: 3, semesterId: 20, status: 'PUBLISHED' },
{ id: 4, semesterId: 10, status: 'ARCHIVED' }
];
assert.equal(chooseScheduleVersion(versions, 10).id, 2);
assert.equal(chooseScheduleVersion(versions, 20).id, 3);
assert.equal(chooseScheduleVersion(versions, 10, 1).id, 1);
assert.equal(chooseScheduleVersion(versions, 10, 4).id, 2);
});
test('контур версий подключён к SPA, а конструктор работает с выбранной версией и создаёт черновик из неё', async () => {
const [main, roles, constructor, quality, index, view] = await Promise.all([
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'), 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/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.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule-quality.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') readFile(new URL('../admin/index.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule.html', import.meta.url), 'utf8')
]); ]);
assert.match(main, /'schedule-versions'/); assert.match(main, /'schedule-versions'/);
assert.match(roles, /schedule-versions/); assert.match(roles, /schedule-versions/);
assert.match(index, /Версии расписания/); assert.match(index, /Версии расписания/);
assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/); assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/);
assert.match(constructor, /basedOnVersionId:\s*Number\(base\.id\)/);
assert.match(constructor, /magistr\.schedule\.openVersionId/);
assert.match(constructor, /data-slot-id/);
assert.match(view, /id="schedule-version-create-draft"/);
assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/); assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/);
}); });

View File

@@ -1,5 +1,5 @@
{ {
"generated_at": "2026-07-31T00:26:15.582806+00:00", "generated_at": "2026-08-11T21:13:26.923984+00:00",
"nodes": { "nodes": {
"_gitea_workflows_docker_build_backend_image_pipeline": { "_gitea_workflows_docker_build_backend_image_pipeline": {
"code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077", "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
@@ -22,7 +22,7 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
} }
], ],
"score": 2.92268101, "score": 2.221841026,
"source_file": ".gitea/workflows/docker-build.yaml", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -59,7 +59,7 @@
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" "q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
} }
], ],
"score": 6.81633329, "score": 5.181820698,
"source_file": ".gitea/workflows/docker-build.yaml", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "contested", "status": "contested",
"uses": 8, "uses": 8,
@@ -87,7 +87,7 @@
"q": "сам можешь выполнить эти действия?" "q": "сам можешь выполнить эти действия?"
} }
], ],
"score": 0.972977818, "score": 0.739664036,
"source_file": ".gitea/workflows/docker-build.yaml", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "contested", "status": "contested",
"uses": 2, "uses": 2,
@@ -114,7 +114,7 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
} }
], ],
"score": 2.923540074, "score": 2.222494092,
"source_file": ".gitea/workflows/docker-build.yaml", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -140,7 +140,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 1.810960536, "score": 1.376703924,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -156,7 +156,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.643405092, "score": 0.489120716,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -172,7 +172,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 0.541345879, "score": 0.411534642,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -188,7 +188,7 @@
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?" "q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 0.658422513, "score": 0.500537057,
"source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java", "source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -204,7 +204,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 0.626209565, "score": 0.476048566,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -220,7 +220,7 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 0.658310876, "score": 0.50045219,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -246,7 +246,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 1.942942954, "score": 1.477037813,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -272,7 +272,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 1.960138481, "score": 1.490109964,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -288,7 +288,7 @@
"q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается." "q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается."
} }
], ],
"score": 0.999825381, "score": 0.760073728,
"source_file": "backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -304,7 +304,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?" "q": "Почему в выборе аудитории показаны корпус и этаж?"
} }
], ],
"score": 0.809436264, "score": 0.615338689,
"source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -320,7 +320,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.643405092, "score": 0.489120716,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -341,13 +341,13 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 1.316733389, "score": 1.000989247,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
}, },
"backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": { "backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": {
"code_fingerprint": "a637ea110ec1c5fef0c0a1d88cccbc54d56b3b6ca66a6c1d3659871bd5ff7f4e", "code_fingerprint": "327129c8d189e5d65b254235ef5207cb000bc6783162774e6da99128be0310a1",
"label": "GlobalExceptionHandler", "label": "GlobalExceptionHandler",
"last": "2026-07-10T18:14:03.213067+00:00", "last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [ "provenance": [
@@ -357,7 +357,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 0.626209565, "score": 0.476048566,
"source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java", "source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -378,7 +378,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 1.452093958, "score": 1.103891228,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -394,16 +394,21 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 0.653704248, "score": 0.496950202,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_controller_scheduleruleadmincontroller_scheduleruleadmincontroller": { "backend_src_main_java_com_magistr_app_controller_scheduleruleadmincontroller_scheduleruleadmincontroller": {
"code_fingerprint": "88244c8ce05d55ac3581c4295a7213294389d1245b56c73a0ad5df2da4ecf000", "code_fingerprint": "544765eadda64b60f2e6fdb8e0c305b30cc9130e8e57dbd287806ee2acd84940",
"label": "ScheduleRuleAdminController", "label": "ScheduleRuleAdminController",
"last": "2026-07-12T21:48:38.060218+00:00", "last": "2026-08-11T21:06:37.878825+00:00",
"provenance": [ "provenance": [
{
"date": "2026-08-11T21:06:37.878825+00:00",
"outcome": "useful",
"q": "хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано."
},
{ {
"date": "2026-07-12T21:48:38.060218+00:00", "date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful", "outcome": "useful",
@@ -415,10 +420,10 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
} }
], ],
"score": 1.284297038, "score": 1.976221543,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 3
}, },
"backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": { "backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": {
"code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d", "code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d",
@@ -441,7 +446,7 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 2.827859657, "score": 2.149757219,
"source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java", "source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -457,7 +462,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?" "q": "Почему в выборе аудитории показаны корпус и этаж?"
} }
], ],
"score": 0.809436264, "score": 0.615338689,
"source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java", "source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -473,16 +478,21 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 0.828623782, "score": 0.629925164,
"source_file": "backend/src/main/java/com/magistr/app/model/Department.java", "source_file": "backend/src/main/java/com/magistr/app/model/Department.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"backend_src_main_java_com_magistr_app_model_schedulerule_schedulerule": { "backend_src_main_java_com_magistr_app_model_schedulerule_schedulerule": {
"code_fingerprint": "692d61c1bfd5b46f5865174dde8cf2d962d2ccd124b5e4320e2ba8535bb29605", "code_fingerprint": "9a47408d331e8a8e6d815732385c9463555bd7c013eeea01cdf6988db229ca27",
"label": "ScheduleRule", "label": "ScheduleRule",
"last": "2026-07-12T21:48:38.060218+00:00", "last": "2026-08-11T21:06:37.878825+00:00",
"provenance": [ "provenance": [
{
"date": "2026-08-11T21:06:37.878825+00:00",
"outcome": "useful",
"q": "хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано."
},
{ {
"date": "2026-07-12T21:48:38.060218+00:00", "date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful", "outcome": "useful",
@@ -494,10 +504,10 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 1.200037579, "score": 1.912166957,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 3
}, },
"backend_src_main_java_com_magistr_app_model_scheduleruleslot_scheduleruleslot": { "backend_src_main_java_com_magistr_app_model_scheduleruleslot_scheduleruleslot": {
"code_fingerprint": "0b1cef627b98558752cc70624d74cd54eb09d9dbff8ca985f81f56d5b3dd34ff", "code_fingerprint": "0b1cef627b98558752cc70624d74cd54eb09d9dbff8ca985f81f56d5b3dd34ff",
@@ -510,7 +520,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.658087473, "score": 0.500282357,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -531,7 +541,7 @@
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра" "q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
} }
], ],
"score": 1.637312649, "score": 1.244695676,
"source_file": "backend/src/main/java/com/magistr/app/model/Semester.java", "source_file": "backend/src/main/java/com/magistr/app/model/Semester.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -547,7 +557,7 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 0.828623782, "score": 0.629925164,
"source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java", "source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -568,7 +578,7 @@
"q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело" "q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело"
} }
], ],
"score": 1.999235874, "score": 1.519832055,
"source_file": "backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java", "source_file": "backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -584,7 +594,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.658087473, "score": 0.500282357,
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java", "source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -605,7 +615,7 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 1.462393114, "score": 1.111720714,
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java", "source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -621,13 +631,13 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 0.541950106, "score": 0.41199398,
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java", "source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"compose_backend_service": { "compose_backend_service": {
"code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9", "code_fingerprint": "3945e5ea72f627e060f6f0cbbdf9a8ff4315aa39b3639faffbf8d3ddc419de2d",
"label": "Backend-сервис Docker Compose", "label": "Backend-сервис Docker Compose",
"last": "2026-07-11T22:22:23.259230+00:00", "last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [ "provenance": [
@@ -637,29 +647,34 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.643405092, "score": 0.489120716,
"source_file": "compose.yaml", "source_file": "compose.yaml",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"compose_db_service": { "compose_db_service": {
"code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9", "code_fingerprint": "3945e5ea72f627e060f6f0cbbdf9a8ff4315aa39b3639faffbf8d3ddc419de2d",
"label": "PostgreSQL-сервис Docker Compose", "label": "PostgreSQL-сервис Docker Compose",
"last": "2026-07-29T19:59:04.551881+00:00", "last": "2026-07-31T00:27:24.418402+00:00",
"provenance": [ "provenance": [
{
"date": "2026-07-31T00:27:24.418402+00:00",
"outcome": "useful",
"q": "отлично, как мне подключиться к этой бд через dbeaver?"
},
{ {
"date": "2026-07-29T19:59:04.551881+00:00", "date": "2026-07-29T19:59:04.551881+00:00",
"outcome": "useful", "outcome": "useful",
"q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше" "q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше"
} }
], ],
"score": 0.972979865, "score": 1.499886061,
"source_file": "compose.yaml", "source_file": "compose.yaml",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 2
}, },
"docs_database_flyway_migrations": { "docs_database_flyway_migrations": {
"code_fingerprint": "2b50799351b008ee1b60bba24a352e8cb6c0faf710e5af8ed780a5c2d3a1fc87", "code_fingerprint": "5a9768f9a9599414fe94ff758c9bfc42a599007aa83a0f14ac2e797d9de74476",
"label": "Flyway-миграции", "label": "Flyway-миграции",
"last": "2026-07-29T19:26:44.188240+00:00", "last": "2026-07-29T19:26:44.188240+00:00",
"provenance": [ "provenance": [
@@ -669,11 +684,27 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 0.972475129, "score": 0.739281889,
"source_file": "docs/DATABASE.md", "source_file": "docs/DATABASE.md",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"docs_infrastructure_docker_compose": {
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "Docker Compose инфраструктура",
"last": "2026-07-31T00:27:24.418402+00:00",
"provenance": [
{
"date": "2026-07-31T00:27:24.418402+00:00",
"outcome": "useful",
"q": "отлично, как мне подключиться к этой бд через dbeaver?"
}
],
"score": 0.760220468,
"source_file": "docs/INFRASTRUCTURE.md",
"status": "tentative",
"uses": 1
},
"docs_infrastructure_gitea_actions_cicd": { "docs_infrastructure_gitea_actions_cicd": {
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc", "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "CI/CD Gitea Actions в инфраструктуре", "label": "CI/CD Gitea Actions в инфраструктуре",
@@ -700,7 +731,7 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
} }
], ],
"score": 3.897900479, "score": 2.96320918,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred", "status": "preferred",
"uses": 4 "uses": 4
@@ -726,7 +757,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 2.922437367, "score": 2.221655807,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -763,7 +794,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 2.918432812, "score": 2.218611518,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "contested", "status": "contested",
"uses": 4, "uses": 4,
@@ -780,7 +811,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 0.972475129, "score": 0.739281889,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -796,7 +827,7 @@
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" "q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
} }
], ],
"score": 0.974360405, "score": 0.740715088,
"source_file": "", "source_file": "",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-08-11T21:06:37.878825+00:00"
question: "хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано."
contributor: "graphify"
outcome: "useful"
source_nodes: ["ScheduleRule", "ScheduleRuleAdminController", "ScheduleGeneratorService"]
---
# Q: хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано.
## Answer
Expanded from original query via vocab: [schedule, rule, constructor, create, update, semester, admin, generator]. Реализовано редактирование PUBLISHED и DRAFT через ScheduleRuleService; ARCHIVED оставлена только для чтения. Конструктор по умолчанию выбирает PUBLISHED текущего семестра, позволяет переключаться на DRAFT и создаёт новый DRAFT копированием выбранной версии. Новый семестр получает пустую PUBLISHED-версию автоматически. Сохранены ID существующих слотов и добавлена защита связанных точечных изменений и заявок. Проверено 328 backend- и 76 frontend-тестами.
## Outcome
- Signal: useful
## Source Nodes
- ScheduleRule
- ScheduleRuleAdminController
- ScheduleGeneratorService

View File

@@ -1,22 +1,25 @@
# Lessons # Lessons
_Auto-generated by `graphify reflect` from 23 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._ _Auto-generated by `graphify reflect` from 26 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
## Summary ## Summary
- 21 useful · 1 dead ends · 1 corrected · 0 unmarked - 24 useful · 1 dead ends · 1 corrected · 0 unmarked
## Lessons ## Lessons
**Preferred sources** — corroborated by ≥2 useful results; start here. **Preferred sources** — corroborated by ≥2 useful results; start here.
- `ScheduleGeneratorService` (5× useful) - `ScheduleGeneratorService` (7× useful)
- `CI/CD Gitea Actions в инфраструктуре` (4× useful) - `CI/CD Gitea Actions в инфраструктуре` (4× useful)
- `Pipeline frontend-образа` (3× useful) - `Pipeline frontend-образа` (3× useful)
- `Pipeline backend-образа` (3× useful) - `Pipeline backend-образа` (3× useful)
- `Политика JWT-секрета` (3× useful) - `Политика JWT-секрета` (3× useful)
- `AcademicCalendar` (3× useful) - `AcademicCalendar` (3× useful)
- `ScheduleRuleAdminController` (3× useful)
- `ScheduleRule` (3× useful)
- `AcademicCalendarDayRepository` (2× useful) - `AcademicCalendarDayRepository` (2× useful)
- `PostgreSQL-сервис Docker Compose` (2× useful)
- `TenantRoutingDataSource` (3× useful) - `TenantRoutingDataSource` (3× useful)
- `TenantConfigWatcher` (3× useful) - `TenantConfigWatcher` (3× useful)
- `AuthorizationInterceptor` (3× useful) - `AuthorizationInterceptor` (3× useful)
@@ -24,15 +27,13 @@ _Auto-generated by `graphify reflect` from 23 session memories in graphify-out/m
- `ScheduleQueryService` (2× useful) - `ScheduleQueryService` (2× useful)
- `ScheduleController.java` (2× useful) - `ScheduleController.java` (2× useful)
- `DatabaseController` (2× useful) - `DatabaseController` (2× useful)
- `ScheduleRuleAdminController` (2× useful)
- `ScheduleRule` (2× useful)
**Tentative** — useful in fewer than 2 results; verify before relying. **Tentative** — useful in fewer than 2 results; verify before relying.
- `Docker Compose инфраструктура` (1× useful)
- `AcademicCalendarController` (1× useful) - `AcademicCalendarController` (1× useful)
- `AcademicDateService` (1× useful) - `AcademicDateService` (1× useful)
- `SpringBootApplication` (1× useful) - `SpringBootApplication` (1× useful)
- `PostgreSQL-сервис Docker Compose` (1× useful)
- `Flyway-миграции` (1× useful) - `Flyway-миграции` (1× useful)
- `Tenants ConfigMap` (1× useful) - `Tenants ConfigMap` (1× useful)
- `Department` (1× useful) - `Department` (1× useful)