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

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

View File

@@ -38,6 +38,10 @@ public record ScheduleQualityAnalysisDto(
String title,
String description,
int penalty,
Long scheduleRuleId,
int affectedLessonCount,
List<String> problemTypes,
List<String> issueLabels,
Long scheduleRuleSlotId,
LocalDate lessonDate,
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.repository.query.Param;
import java.util.Collection;
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")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
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.SemesterDto;
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.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import org.springframework.dao.DataIntegrityViolationException;
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.TransactionSynchronizationManager;
import java.time.Instant;
import java.time.LocalDate;
import java.util.NoSuchElementException;
@@ -26,13 +32,19 @@ public class AcademicPeriodService {
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleVersionRepository scheduleVersionRepository;
private final ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
ScheduleVersionRepository scheduleVersionRepository,
ScheduleVersionHistoryRepository scheduleVersionHistoryRepository) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.scheduleVersionRepository = scheduleVersionRepository;
this.scheduleVersionHistoryRepository = scheduleVersionHistoryRepository;
}
@Transactional
@@ -102,6 +114,7 @@ public class AcademicPeriodService {
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
createInitialPublishedSchedule(saved);
clearScheduleCacheAfterCommit();
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() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {

View File

@@ -314,9 +314,7 @@ public class ScheduleQualityService {
"Насколько близко число пар в разные учебные дни каждой группы")
);
List<ScheduleQualityAnalysisDto.Problem> problems = accumulator.problems.stream()
.sorted(problemComparator())
.toList();
List<ScheduleQualityAnalysisDto.Problem> problems = aggregateProblemsByRule(accumulator.problems);
return new Evaluation(score, rawPenalty, lessonCount, metrics, problems);
}
@@ -882,6 +880,10 @@ public class ScheduleQualityService {
title,
description,
penalty,
lesson.scheduleRuleId(),
1,
List.of(type),
List.of(title),
lesson.scheduleRuleSlotId(),
lesson.date(),
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() {
Map<String, Integer> severityOrder = Map.of(
"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.LessonTypesRepository;
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.SubjectRepository;
@@ -77,6 +78,7 @@ public class ScheduleRuleService {
private final ScheduleGeneratorService scheduleGeneratorService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
private ScheduleVersionRepository scheduleVersionRepository;
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
@@ -110,6 +112,11 @@ public class ScheduleRuleService {
this.scheduleVersionRepository = scheduleVersionRepository;
}
@Autowired(required = false)
void setScheduleRuleSlotRepository(ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null);
@@ -145,7 +152,7 @@ public class ScheduleRuleService {
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId());
ScheduleVersion version = resolveEditableVersion(request.scheduleVersionId(), semester.getId());
ScheduleRule candidate = buildCandidate(request, semester, version);
validateCandidate(candidate);
@@ -168,7 +175,7 @@ public class ScheduleRuleService {
// определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureDraftRule(managedRule);
lockAndEnsureEditableRule(managedRule);
Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
@@ -194,7 +201,7 @@ public class ScheduleRuleService {
validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId());
applyCandidate(managedRule, candidate);
applyCandidate(managedRule, candidate, request.slots());
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
scheduleGeneratorService.clearCache();
return toDto(saved);
@@ -204,7 +211,7 @@ public class ScheduleRuleService {
public void archive(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureDraftRule(rule);
lockAndEnsureEditableRule(rule);
Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
@@ -805,7 +812,9 @@ public class ScheduleRuleService {
.toList();
}
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
private void applyCandidate(ScheduleRule managedRule,
ScheduleRule candidate,
List<ScheduleRuleSlotDto> requestedSlots) {
managedRule.setSubject(candidate.getSubject());
managedRule.setSemester(candidate.getSemester());
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
@@ -817,11 +826,54 @@ public class ScheduleRuleService {
managedRule.getGroups().clear();
managedRule.getGroups().addAll(candidate.getGroups());
managedRule.getSlots().clear();
for (ScheduleRuleSlot slot : candidate.getSlots()) {
slot.setScheduleRule(managedRule);
managedRule.getSlots().add(slot);
List<ScheduleRuleSlot> candidateSlots = new ArrayList<>(candidate.getSlots());
Map<Long, ScheduleRuleSlot> currentSlotsById = managedRule.getSlots().stream()
.filter(slot -> slot.getId() != null)
.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) {
@@ -882,7 +934,7 @@ public class ScheduleRuleService {
return List.copyOf(issues);
}
private ScheduleVersion resolveDraftVersion(Long versionId, Long semesterId) {
private ScheduleVersion resolveEditableVersion(Long versionId, Long semesterId) {
if (versionId == null) {
return null;
}
@@ -894,27 +946,32 @@ public class ScheduleRuleService {
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Версия относится к другому семестру");
}
if (version.getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Изменять правила можно только в черновике");
}
ensureEditableVersion(version);
return version;
}
private void ensureDraftRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null
&& rule.getScheduleVersion().getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Изменять правила можно только в черновике");
private void ensureEditableVersion(ScheduleVersion version) {
if (version.getStatus() == ScheduleVersionStatus.ARCHIVED) {
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) {
ScheduleVersion lockedVersion = scheduleVersionRepository
.findByIdForUpdate(rule.getScheduleVersion().getId())
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
rule.setScheduleVersion(lockedVersion);
}
ensureDraftRule(rule);
ensureEditableRule(rule);
}
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.SemesterDto;
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.SemesterType;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -42,6 +46,10 @@ class AcademicPeriodServiceTest {
private SemesterRepository semesterRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
private AcademicPeriodService service;
private AcademicYear year;
@@ -51,13 +59,17 @@ class AcademicPeriodServiceTest {
service = new AcademicPeriodService(
academicYearRepository,
semesterRepository,
scheduleGeneratorService
scheduleGeneratorService,
scheduleVersionRepository,
scheduleVersionHistoryRepository
);
year = year();
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(semesterRepository.saveAndFlush(any(Semester.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(scheduleVersionRepository.saveAndFlush(any(ScheduleVersion.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
@@ -158,6 +170,12 @@ class AcademicPeriodServiceTest {
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
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();
}

View File

@@ -81,8 +81,8 @@ class ScheduleQualityServiceTest {
assertThat(result.lessonCount()).isEqualTo(2);
assertThat(result.score()).isLessThan(100);
assertThat(result.problems())
.extracting(problem -> problem.type())
assertThat(result.problems().stream()
.flatMap(problem -> problem.problemTypes().stream()))
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
assertThat(result.metrics())
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
@@ -90,6 +90,33 @@ class ScheduleQualityServiceTest {
.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
void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
Semester semester = semester();
@@ -238,9 +265,21 @@ class ScheduleQualityServiceTest {
Long timeSlotId,
Classroom classroom,
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(
100L + ruleSlotId,
ruleId,
ruleSlotId,
date,
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.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
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.LessonTypesRepository;
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.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
@@ -88,6 +92,10 @@ class ScheduleRuleServiceTest {
private SubgroupRepository subgroupRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private ScheduleRuleService service;
private Subject subject;
@@ -130,6 +138,8 @@ class ScheduleRuleServiceTest {
subgroupRepository,
scheduleGeneratorService
);
service.setScheduleVersionRepository(scheduleVersionRepository);
service.setScheduleRuleSlotRepository(scheduleRuleSlotRepository);
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
@@ -188,6 +198,76 @@ class ScheduleRuleServiceTest {
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
void rejectsActiveUserWithoutTeacherRole() {
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() {
return slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
@@ -461,8 +572,18 @@ class ScheduleRuleServiceTest {
long lessonTypeId,
List<Long> subgroupIds,
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(
null,
id,
1,
null,
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`.
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
**Тело создания/обновления кода активности:**
```json
@@ -661,10 +663,13 @@ CRUD доступен по:
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены только внутри версии со статусом
`DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
семестру. Без `versionId` список возвращает только правила текущей опубликованной версии,
а конструктор всегда передаёт ID выбранного черновика.
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
или `DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
семестру. Изменения `PUBLISHED`-версии сразу влияют на публичное расписание, изменения
`DRAFT` остаются изолированными до публикации, а `ARCHIVED` доступна только для чтения.
Без `versionId` список возвращает только правила текущей опубликованной версии. При update
ID существующих слотов сохраняются; удаление слота, на который ссылаются точечные изменения,
заявки преподавателей или решения по отсутствиям, отклоняется явной ошибкой `400`.
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
@@ -862,7 +867,8 @@ API возвращает `409 Conflict`; соседние интервалы и
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна
версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание
копии переносит правила, группы, слоты и подгруппы, сохраняя `versionGroupId` для diff.
копии может использовать опубликованную версию или другой черновик и переносит правила,
группы, слоты и подгруппы, сохраняя `versionGroupId` для diff.
```json
{

View File

@@ -267,23 +267,27 @@ constraint. При чтении API разворачивает периоды о
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила той же версии. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание блокирует строку семестра и выбранную версию, а update — правило, версию и старый/новый семестры в стабильном порядке. Публикация блокирует версию до завершения полной проверки, поэтому другой backend-pod не может дописать правило после валидации черновика.
- **Связанные слоты:** update сохраняет ID переданных существующих слотов, чтобы не разрывать точечные изменения и операционный аудит. Удаление слота отклоняется, если на него ссылаются override, заявка преподавателя или решение по отсутствию.
### Черновики, версии и публикация
Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии:
1. `DRAFT` создаётся пустым или как полная копия выбранной версии.
2. Конструктор добавляет, изменяет и архивирует правила только в выбранном черновике.
3. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
1. При создании семестра автоматически появляется пустая версия 1 `PUBLISHED` — основное расписание.
2. Конструктор по умолчанию открывает опубликованное расписание: изменения его правил сразу
видны конечным пользователям. Пользователь может переключиться на существующий `DRAFT`.
3. `DRAFT` создаётся пустым через API или прямо в конструкторе как полная копия выбранной
опубликованной либо черновой версии; после создания он сразу становится текущим.
4. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра.
4. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
5. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
переводит проверенный черновик в `PUBLISHED`.
5. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же
6. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же
атомарной операцией с обязательной причиной.
На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на
семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с
редактированием черновика или конкурентной публикацией. Каждое создание, архивирование,
редактированием расписания или конкурентной публикацией. Каждое создание, архивирование,
публикация и восстановление записывается в неизменяемый журнал с автором, временем и
причиной.

View File

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

View File

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

View File

@@ -180,7 +180,7 @@ frontend/
| `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` |
| `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-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` |
@@ -219,9 +219,12 @@ frontend/
- Вкладка `schedule-versions` доступна администратору и учебному отделу и оформлена как
отдельный контур публикации. Верхняя карточка показывает версию, которую видят конечные
пользователи; ниже расположены черновики, сравнение правил и занятий, архив и журнал.
Новый черновик копирует выбранную опубликованную версию. Перед публикацией интерфейс
В конструкторе опубликованная версия текущего семестра выбирается по умолчанию и выделяется
зелёным контуром; её изменения применяются сразу. Селект позволяет перейти в медный режим
черновика. Кнопка `Создать черновик` копирует выбранное опубликованное расписание или
черновик и сразу открывает новую рабочую копию. Перед публикацией интерфейс
одновременно запрашивает полную валидацию и diff, требует причину и блокирует действие
при ошибках. Кнопки черновика сохраняют его ID в `localStorage` и открывают конструктор
при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор
или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. Для текущего семестра сохраняется текущая двухнедельная точка просмотра, а при выборе другого семестра диапазон начинается с понедельника его первой недели. Frontend запрашивает две недели и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.

View File

@@ -119,13 +119,18 @@
align-items: center;
margin-bottom: 1rem;
padding: 1.15rem 1.3rem;
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
border: 1px solid color-mix(in srgb, var(--version-copper) 30%, var(--bg-card-border));
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-mint) 34%, var(--bg-card-border));
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 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 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 form { display: grid; gap: .65rem; }
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
.version-modal-card textarea { width: 100%; resize: vertical; padding: .8rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); font: inherit; }
.version-modal-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-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[data-tone="error"] { color: var(--error); border: 1px solid color-mix(in srgb, var(--error) 40%, transparent); }
@media (max-width: 900px) {
.version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; }
@@ -296,4 +303,6 @@
.version-live-mark { width: 54px; }
.version-diff-score { grid-template-columns: 1fr; }
.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-type-filter')?.addEventListener('change', renderProblems);
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
await loadSemesters();
}
@@ -102,8 +102,8 @@ async function runAnalysis() {
renderAnalysis();
qualityStatus(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
'success'
);
} catch (error) {
@@ -131,7 +131,7 @@ function renderAnalysis() {
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
`;
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
@@ -145,11 +145,11 @@ function renderAnalysis() {
</article>
`).join('');
const totalLabel = analysis.problemsTruncated
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
: `Правил с проблемами: ${analysis.totalProblemCount}`;
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
renderProblems();
renderRecommendationsEmpty();
renderRulePanelEmpty();
}
function renderProblems() {
@@ -171,6 +171,9 @@ function renderProblems() {
}
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
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 `
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
<button type="button" class="quality-problem-main" data-problem-index="${index}">
@@ -178,199 +181,92 @@ function renderProblems() {
<span class="quality-problem-copy">
<strong>${escapeHtml(problem.title)}</strong>
<span>${escapeHtml(problem.description)}</span>
<span class="quality-rule-issues">${issueLabels.map(label => `
<span>${escapeHtml(label)}</span>
`).join('')}</span>
<small>
${escapeHtml(formatDate(problem.lessonDate))}
· ${escapeHtml(problem.subjectName || 'Занятие')}
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
· ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку
· суммарный штраф
</small>
</span>
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
</button>
<div class="quality-problem-action">
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
Подобрать улучшения
${canEdit
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
Изменить правило
</button>`
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
: '<span>Только просмотр</span>'}
</div>
</article>
`;
}).join('');
}
async function handleProblemClick(event) {
function handleProblemClick(event) {
const button = event.target.closest('[data-problem-index]');
if (!button) return;
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
if (!problem) return;
if (button.dataset.action === 'edit-rule') {
openRuleInConstructor(problem);
return;
}
qualityState.selectedProblemId = problem.id;
renderProblems();
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
renderRecommendationsMessage(
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Черновик остаётся изолированным'
: 'Архивная версия доступна только для проверки',
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
);
return;
}
if (!problem.optimizable) {
renderRecommendationsMessage(
'Ручное решение сохранено',
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
);
return;
}
await loadRecommendations(problem);
renderRuleDetails(problem);
}
async function loadRecommendations(problem) {
const panel = qualityState.root.querySelector('#quality-recommendations');
panel.innerHTML = `
<div class="quality-recommendations-loading">
<span class="quality-loader"></span>
<strong>Проверяем варианты</strong>
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
</div>
`;
try {
const params = new URLSearchParams({
semesterId: String(qualityState.analysis.semesterId),
versionId: String(qualityState.analysis.scheduleVersionId),
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
lessonDate: problem.lessonDate
});
const result = await api.get(
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
);
renderRecommendations(problem, result);
} catch (error) {
renderRecommendationsMessage(
'Не удалось подобрать варианты',
error.message || 'Повторите попытку после обновления анализа.'
);
}
function renderRulePanelEmpty() {
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
renderRulePanelMessage(
archived ? 'Архивная версия' : 'Работа с правилом',
archived
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
);
}
function renderRecommendations(problem, result) {
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidates = result.candidates || [];
if (!candidates.length) {
renderRecommendationsMessage(
'Свободных улучшений не найдено',
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
);
return;
}
function renderRuleDetails(problem) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
panel.innerHTML = `
<div class="quality-recommendations-head">
<span class="quality-kicker">Проверенные варианты</span>
<h3>${escapeHtml(problem.title)}</h3>
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
<p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
</div>
<div class="quality-candidates">
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
<div class="quality-rule-detail-list">
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
</div>
<p class="quality-recommendations-note">
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
</p>
`;
panel._qualityCandidates = candidates;
}
function recommendationCard(candidate, index) {
const impactClass = candidate.penaltyDelta > 0
? 'is-positive'
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
const impact = candidate.scoreDelta > 0
? `+${candidate.scoreDelta} к оценке`
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
return `
<article class="quality-candidate ${impactClass}">
<div class="quality-candidate-top">
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
<strong>${escapeHtml(candidate.label)}</strong>
</div>
<p>${escapeHtml(candidate.description)}</p>
<div class="quality-candidate-impact">
<span>${escapeHtml(impact)}</span>
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
</div>
${changeList('Улучшится', candidate.improvements, 'positive')}
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
Применить после подтверждения
${canEdit ? `
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
Открыть правило в конструкторе
</button>
</article>
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
`;
}
function changeList(label, items, tone) {
if (!items?.length) return '';
return `
<div class="quality-change-list is-${tone}">
<strong>${escapeHtml(label)}</strong>
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
function handleRulePanelClick(event) {
const button = event.target.closest('[data-edit-rule-id]');
if (!button) return;
const problem = (qualityState?.analysis?.problems || [])
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
if (problem) openRuleInConstructor(problem);
}
async function handleRecommendationClick(event) {
const button = event.target.closest('[data-candidate-index]');
if (!button || qualityState?.applying) return;
const panel = qualityState.root.querySelector('#quality-recommendations');
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
if (!candidate) return;
const confirmed = window.confirm(
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
);
if (!confirmed) return;
qualityState.applying = true;
button.disabled = true;
button.textContent = 'Применяем…';
try {
await api.post('/api/edu-office/schedule/overrides', candidate.override);
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
await runAnalysis();
} catch (error) {
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
button.disabled = false;
button.textContent = 'Применить после подтверждения';
} finally {
qualityState.applying = false;
}
function openRuleInConstructor(problem) {
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
}
function renderRecommendationsEmpty() {
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
renderRecommendationsMessage(
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
isDraft
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
);
return;
}
renderRecommendationsMessage(
'Локальные улучшения',
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
);
}
function problemActionLabel(problem) {
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
return 'Исправляется в конструкторе';
}
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
return 'Только просмотр';
}
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
}
function renderRecommendationsMessage(title, message) {
function renderRulePanelMessage(title, message) {
const panel = qualityState?.root.querySelector('#quality-recommendations');
if (!panel) return;
panel.innerHTML = `
@@ -380,7 +276,6 @@ function renderRecommendationsMessage(title, message) {
<p>${escapeHtml(message)}</p>
</div>
`;
panel._qualityCandidates = [];
}
function qualityStatus(message, tone = '') {
@@ -393,7 +288,8 @@ function qualityStatus(message, tone = '') {
export function filterQualityProblems(problems, severity = '', category = '') {
return (problems || []).filter(problem => {
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;
});
}
@@ -448,13 +344,13 @@ function localIsoDate(date) {
return `${year}-${month}-${day}`;
}
function problemWord(count) {
function lessonWord(count) {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
if (mod10 === 1) return 'проблема';
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
return 'проблем';
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
if (mod10 === 1) return 'занятие';
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
return 'занятий';
}
export function versionStatusLabel(status) {

View File

@@ -113,6 +113,7 @@ function renderCurrent(version) {
</div>
</div>
<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>
</div>
`;
@@ -196,7 +197,7 @@ async function handleVersionAction(event) {
if (!version) return;
const action = button.dataset.action;
if (action === 'edit') {
localStorage.setItem('magistr.schedule.versionId', String(version.id));
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
navigateTo('schedule');
return;
}

View File

@@ -34,6 +34,17 @@ export function scheduleSlotActionLabel(slotCount) {
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 = [
{
key: 'lecture',
@@ -68,8 +79,20 @@ export async function initSchedule() {
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
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 scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft');
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, {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
@@ -138,6 +161,7 @@ export async function initSchedule() {
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
let creatingDraft = false;
bindEvents();
@@ -159,13 +183,21 @@ export async function initSchedule() {
if (scheduleVersionSelect.value) {
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
}
updateVersionContext();
resetRuleForm();
syncRuleSemesterWithVersion();
await loadRules();
});
scheduleVersionCreateDraft?.addEventListener('click', openDraftModal);
scheduleVersionManage?.addEventListener('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);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
@@ -211,6 +243,10 @@ export async function initSchedule() {
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (draftModal && !draftModal.hidden) {
closeDraftModal();
return;
}
if (moveSlotModal?.classList.contains('open')) {
closeMoveSlotModal();
return;
@@ -302,31 +338,123 @@ export async function initSchedule() {
updatePreferenceHints();
}
async function loadScheduleVersions() {
async function loadScheduleVersions(preferredVersionId = null) {
const responses = await Promise.all(semesters.map(semester =>
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
));
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT');
const storedId = localStorage.getItem('magistr.schedule.versionId');
const stored = scheduleVersions.find(version => String(version.id) === storedId);
const selected = stored || scheduleVersions[0] || null;
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
localStorage.removeItem('magistr.schedule.openVersionId');
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
? scheduleVersions.map(version => `
<option value="${version.id}">
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
</option>
`).join('')
: '<option value="">Черновики не созданы</option>';
? [
versionOptionGroup('Опубликованные расписания', published),
versionOptionGroup('Черновики', drafts)
].filter(Boolean).join('')
: '<option value="">Расписания не созданы</option>';
scheduleVersionSelect.value = selected ? String(selected.id) : '';
if (selected) {
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
} else {
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
}
updateVersionContext();
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() {
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
}
@@ -352,8 +480,8 @@ export async function initSchedule() {
const version = selectedScheduleVersion();
if (!version) {
rules = [];
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
setVisualState('Нет выбранного черновика');
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет расписания для редактирования</td></tr>';
setVisualState('Нет выбранного расписания');
return;
}
try {
@@ -1390,7 +1518,7 @@ export async function initSchedule() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
const typeLimits = readTypeLimits();
validateTypeLimits(typeLimits, slots);
@@ -1456,7 +1584,7 @@ export async function initSchedule() {
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
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-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>
@@ -1521,8 +1649,9 @@ export async function initSchedule() {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupIds = readSlotSubgroupIds(row);
const slotId = Number(row.dataset.slotId);
const slot = {
id: null,
id: slotId || null,
dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').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>
<span class="version-eyebrow">Изолированный контекст</span>
<h2>Рабочий черновик</h2>
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p>
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
<h2 id="schedule-version-title">Опубликованное расписание</h2>
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
</div>
<div class="version-constructor-controls">
<label for="schedule-version-select">Версия расписания</label>
<select id="schedule-version-select" data-native-select="true">
<option value="">Загрузка черновиков…</option>
<option value="">Загрузка расписаний…</option>
</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>
</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-header-row">
<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 { versionStatusLabel } from '../admin/js/views/schedule-quality.js';
import { chooseScheduleVersion } from '../admin/js/views/schedule.js';
test('журнал версий переводит системные действия на русский язык', () => {
assert.equal(historyActionLabel('CREATED'), 'Создан черновик');
@@ -17,18 +18,37 @@ test('анализ качества различает черновик, пуб
assert.equal(versionStatusLabel('ARCHIVED'), 'Архив');
});
test('контур версий подключён к SPA, а конструктор сохраняет правила в выбранный черновик', async () => {
const [main, roles, constructor, quality, index] = await Promise.all([
test('конструктор по умолчанию открывает опубликованное расписание выбранного семестра', () => {
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/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8')
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(roles, /schedule-versions/);
assert.match(index, /Версии расписания/);
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\)/);
});

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": {
"_gitea_workflows_docker_build_backend_image_pipeline": {
"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)."
}
],
"score": 2.92268101,
"score": 2.221841026,
"source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred",
"uses": 3
@@ -59,7 +59,7 @@
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
}
],
"score": 6.81633329,
"score": 5.181820698,
"source_file": ".gitea/workflows/docker-build.yaml",
"status": "contested",
"uses": 8,
@@ -87,7 +87,7 @@
"q": "сам можешь выполнить эти действия?"
}
],
"score": 0.972977818,
"score": 0.739664036,
"source_file": ".gitea/workflows/docker-build.yaml",
"status": "contested",
"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)."
}
],
"score": 2.923540074,
"score": 2.222494092,
"source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred",
"uses": 3
@@ -140,7 +140,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
}
],
"score": 1.810960536,
"score": 1.376703924,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
"status": "preferred",
"uses": 3
@@ -156,7 +156,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.643405092,
"score": 0.489120716,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
"status": "tentative",
"uses": 1
@@ -172,7 +172,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
}
],
"score": 0.541345879,
"score": 0.411534642,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
"status": "tentative",
"uses": 1
@@ -188,7 +188,7 @@
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
}
],
"score": 0.658422513,
"score": 0.500537057,
"source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java",
"status": "tentative",
"uses": 1
@@ -204,7 +204,7 @@
"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",
"status": "tentative",
"uses": 1
@@ -220,7 +220,7 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
}
],
"score": 0.658310876,
"score": 0.50045219,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java",
"status": "tentative",
"uses": 1
@@ -246,7 +246,7 @@
"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",
"status": "preferred",
"uses": 3
@@ -272,7 +272,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 1.960138481,
"score": 1.490109964,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
"status": "preferred",
"uses": 3
@@ -288,7 +288,7 @@
"q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается."
}
],
"score": 0.999825381,
"score": 0.760073728,
"source_file": "backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java",
"status": "tentative",
"uses": 1
@@ -304,7 +304,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?"
}
],
"score": 0.809436264,
"score": 0.615338689,
"source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java",
"status": "tentative",
"uses": 1
@@ -320,7 +320,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.643405092,
"score": 0.489120716,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "tentative",
"uses": 1
@@ -341,13 +341,13 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
}
],
"score": 1.316733389,
"score": 1.000989247,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "preferred",
"uses": 2
},
"backend_src_main_java_com_magistr_app_controller_globalexceptionhandler_globalexceptionhandler": {
"code_fingerprint": "a637ea110ec1c5fef0c0a1d88cccbc54d56b3b6ca66a6c1d3659871bd5ff7f4e",
"code_fingerprint": "327129c8d189e5d65b254235ef5207cb000bc6783162774e6da99128be0310a1",
"label": "GlobalExceptionHandler",
"last": "2026-07-10T18:14:03.213067+00:00",
"provenance": [
@@ -357,7 +357,7 @@
"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",
"status": "tentative",
"uses": 1
@@ -378,7 +378,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 1.452093958,
"score": 1.103891228,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
"status": "preferred",
"uses": 2
@@ -394,16 +394,21 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
}
],
"score": 0.653704248,
"score": 0.496950202,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_controller_scheduleruleadmincontroller_scheduleruleadmincontroller": {
"code_fingerprint": "88244c8ce05d55ac3581c4295a7213294389d1245b56c73a0ad5df2da4ecf000",
"code_fingerprint": "544765eadda64b60f2e6fdb8e0c305b30cc9130e8e57dbd287806ee2acd84940",
"label": "ScheduleRuleAdminController",
"last": "2026-07-12T21:48:38.060218+00:00",
"last": "2026-08-11T21:06:37.878825+00:00",
"provenance": [
{
"date": "2026-08-11T21:06:37.878825+00:00",
"outcome": "useful",
"q": "хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано."
},
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
@@ -415,10 +420,10 @@
"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",
"status": "preferred",
"uses": 2
"uses": 3
},
"backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": {
"code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d",
@@ -441,7 +446,7 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
}
],
"score": 2.827859657,
"score": 2.149757219,
"source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java",
"status": "preferred",
"uses": 3
@@ -457,7 +462,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?"
}
],
"score": 0.809436264,
"score": 0.615338689,
"source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java",
"status": "tentative",
"uses": 1
@@ -473,16 +478,21 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
}
],
"score": 0.828623782,
"score": 0.629925164,
"source_file": "backend/src/main/java/com/magistr/app/model/Department.java",
"status": "tentative",
"uses": 1
},
"backend_src_main_java_com_magistr_app_model_schedulerule_schedulerule": {
"code_fingerprint": "692d61c1bfd5b46f5865174dde8cf2d962d2ccd124b5e4320e2ba8535bb29605",
"code_fingerprint": "9a47408d331e8a8e6d815732385c9463555bd7c013eeea01cdf6988db229ca27",
"label": "ScheduleRule",
"last": "2026-07-12T21:48:38.060218+00:00",
"last": "2026-08-11T21:06:37.878825+00:00",
"provenance": [
{
"date": "2026-08-11T21:06:37.878825+00:00",
"outcome": "useful",
"q": "хочу чтобы изменять можно было не только черновик, но и само по себе расписание. и даже больше, чтобы по умолчанию можно было менять обычные расписнаия, либо переключаться на черновик. чтобы черновик можно было создать прямо по кнопке в конструкторе правил и чтобы за основу он брал то расписание которое там выбрано."
},
{
"date": "2026-07-12T21:48:38.060218+00:00",
"outcome": "useful",
@@ -494,10 +504,10 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
}
],
"score": 1.200037579,
"score": 1.912166957,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
"status": "preferred",
"uses": 2
"uses": 3
},
"backend_src_main_java_com_magistr_app_model_scheduleruleslot_scheduleruleslot": {
"code_fingerprint": "0b1cef627b98558752cc70624d74cd54eb09d9dbff8ca985f81f56d5b3dd34ff",
@@ -510,7 +520,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
}
],
"score": 0.658087473,
"score": 0.500282357,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
"status": "tentative",
"uses": 1
@@ -531,7 +541,7 @@
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
}
],
"score": 1.637312649,
"score": 1.244695676,
"source_file": "backend/src/main/java/com/magistr/app/model/Semester.java",
"status": "preferred",
"uses": 2
@@ -547,7 +557,7 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
}
],
"score": 0.828623782,
"score": 0.629925164,
"source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java",
"status": "tentative",
"uses": 1
@@ -568,7 +578,7 @@
"q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело"
}
],
"score": 1.999235874,
"score": 1.519832055,
"source_file": "backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java",
"status": "preferred",
"uses": 2
@@ -584,7 +594,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
}
],
"score": 0.658087473,
"score": 0.500282357,
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
"status": "tentative",
"uses": 1
@@ -605,7 +615,7 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
}
],
"score": 1.462393114,
"score": 1.111720714,
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
"status": "preferred",
"uses": 2
@@ -621,13 +631,13 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
}
],
"score": 0.541950106,
"score": 0.41199398,
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
"status": "tentative",
"uses": 1
},
"compose_backend_service": {
"code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9",
"code_fingerprint": "3945e5ea72f627e060f6f0cbbdf9a8ff4315aa39b3639faffbf8d3ddc419de2d",
"label": "Backend-сервис Docker Compose",
"last": "2026-07-11T22:22:23.259230+00:00",
"provenance": [
@@ -637,29 +647,34 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
}
],
"score": 0.643405092,
"score": 0.489120716,
"source_file": "compose.yaml",
"status": "tentative",
"uses": 1
},
"compose_db_service": {
"code_fingerprint": "944ed05aa3bb66491e0622baa9659fc2db3115c79076325ebe243aeb84ca09f9",
"code_fingerprint": "3945e5ea72f627e060f6f0cbbdf9a8ff4315aa39b3639faffbf8d3ddc419de2d",
"label": "PostgreSQL-сервис Docker Compose",
"last": "2026-07-29T19:59:04.551881+00:00",
"last": "2026-07-31T00:27:24.418402+00:00",
"provenance": [
{
"date": "2026-07-31T00:27:24.418402+00:00",
"outcome": "useful",
"q": "отлично, как мне подключиться к этой бд через dbeaver?"
},
{
"date": "2026-07-29T19:59:04.551881+00:00",
"outcome": "useful",
"q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше"
}
],
"score": 0.972979865,
"score": 1.499886061,
"source_file": "compose.yaml",
"status": "tentative",
"uses": 1
"status": "preferred",
"uses": 2
},
"docs_database_flyway_migrations": {
"code_fingerprint": "2b50799351b008ee1b60bba24a352e8cb6c0faf710e5af8ed780a5c2d3a1fc87",
"code_fingerprint": "5a9768f9a9599414fe94ff758c9bfc42a599007aa83a0f14ac2e797d9de74476",
"label": "Flyway-миграции",
"last": "2026-07-29T19:26:44.188240+00:00",
"provenance": [
@@ -669,11 +684,27 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
}
],
"score": 0.972475129,
"score": 0.739281889,
"source_file": "docs/DATABASE.md",
"status": "tentative",
"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": {
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"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)."
}
],
"score": 3.897900479,
"score": 2.96320918,
"source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred",
"uses": 4
@@ -726,7 +757,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
}
],
"score": 2.922437367,
"score": 2.221655807,
"source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred",
"uses": 3
@@ -763,7 +794,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
}
],
"score": 2.918432812,
"score": 2.218611518,
"source_file": "docs/INFRASTRUCTURE.md",
"status": "contested",
"uses": 4,
@@ -780,7 +811,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
}
],
"score": 0.972475129,
"score": 0.739281889,
"source_file": "docs/INFRASTRUCTURE.md",
"status": "tentative",
"uses": 1
@@ -796,7 +827,7 @@
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
}
],
"score": 0.974360405,
"score": 0.740715088,
"source_file": "",
"status": "tentative",
"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
_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
- 21 useful · 1 dead ends · 1 corrected · 0 unmarked
- 24 useful · 1 dead ends · 1 corrected · 0 unmarked
## Lessons
**Preferred sources** — corroborated by ≥2 useful results; start here.
- `ScheduleGeneratorService` (5× useful)
- `ScheduleGeneratorService` (7× useful)
- `CI/CD Gitea Actions в инфраструктуре` (4× useful)
- `Pipeline frontend-образа` (3× useful)
- `Pipeline backend-образа` (3× useful)
- `Политика JWT-секрета` (3× useful)
- `AcademicCalendar` (3× useful)
- `ScheduleRuleAdminController` (3× useful)
- `ScheduleRule` (3× useful)
- `AcademicCalendarDayRepository` (2× useful)
- `PostgreSQL-сервис Docker Compose` (2× useful)
- `TenantRoutingDataSource` (3× useful)
- `TenantConfigWatcher` (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)
- `ScheduleController.java` (2× useful)
- `DatabaseController` (2× useful)
- `ScheduleRuleAdminController` (2× useful)
- `ScheduleRule` (2× useful)
**Tentative** — useful in fewer than 2 results; verify before relying.
- `Docker Compose инфраструктура` (1× useful)
- `AcademicCalendarController` (1× useful)
- `AcademicDateService` (1× useful)
- `SpringBootApplication` (1× useful)
- `PostgreSQL-сервис Docker Compose` (1× useful)
- `Flyway-миграции` (1× useful)
- `Tenants ConfigMap` (1× useful)
- `Department` (1× useful)