From 491e373cc391484cd229ed927e8089d09d5491c7 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 12 Aug 2026 01:07:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20(=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 8 +- .../app/dto/ScheduleQualityAnalysisDto.java | 4 + .../ScheduleRuleSlotRepository.java | 13 + .../app/service/AcademicPeriodService.java | 37 ++- .../app/service/ScheduleQualityService.java | 80 +++++- .../app/service/ScheduleRuleService.java | 95 +++++-- .../service/AcademicPeriodServiceTest.java | 20 +- .../service/ScheduleQualityServiceTest.java | 45 +++- .../app/service/ScheduleRuleServiceTest.java | 123 ++++++++- docs/API.md | 16 +- docs/BUSINESS_LOGIC.md | 16 +- docs/DATABASE.md | 4 +- docs/FEATURE_IDEAS.md | 13 +- docs/FRONTEND.md | 9 +- frontend/admin/css/schedule-versions.css | 17 +- frontend/admin/js/views/schedule-quality.js | 240 +++++------------- frontend/admin/js/views/schedule-versions.js | 3 +- frontend/admin/js/views/schedule.js | 167 ++++++++++-- frontend/admin/views/schedule.html | 32 ++- frontend/tests/schedule-versions.test.mjs | 26 +- graphify-out/.graphify_learning.json | 137 ++++++---- ...‡тобы_изменять_можно_было_не_только_черновик.md | 24 ++ graphify-out/reflections/LESSONS.md | 13 +- 23 files changed, 826 insertions(+), 316 deletions(-) create mode 100644 graphify-out/memory/query_20260811_210637_хочу_чтобы_изменять_можно_было_не_только_черновик.md diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 6958a21..fed6191 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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("Выберите версию расписания"); } } } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java index 3efcfd0..6ccfba5 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleQualityAnalysisDto.java @@ -38,6 +38,10 @@ public record ScheduleQualityAnalysisDto( String title, String description, int penalty, + Long scheduleRuleId, + int affectedLessonCount, + List problemTypes, + List issueLabels, Long scheduleRuleSlotId, LocalDate lessonDate, Integer timeSlotOrder, diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java index b024477..edbd7c6 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleSlotRepository.java @@ -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 { @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 slotIds); } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java b/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java index 099a373..835a833 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicPeriodService.java @@ -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()) { diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java index b8c95bc..9a60d1b 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQualityService.java @@ -314,9 +314,7 @@ public class ScheduleQualityService { "Насколько близко число пар в разные учебные дни каждой группы") ); - List problems = accumulator.problems.stream() - .sorted(problemComparator()) - .toList(); + List 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 aggregateProblemsByRule( + List problems + ) { + Map> 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 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 problemTypes = problems.stream() + .flatMap(problem -> problem.problemTypes().stream()) + .distinct() + .toList(); + List 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 problemComparator() { Map severityOrder = Map.of( "CRITICAL", 0, diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java index 8646df4..2e51525 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java @@ -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 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 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 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 candidateSlots = new ArrayList<>(candidate.getSlots()); + Map currentSlotsById = managedRule.getSlots().stream() + .filter(slot -> slot.getId() != null) + .collect(Collectors.toMap(ScheduleRuleSlot::getId, Function.identity())); + Set retainedSlotIds = new HashSet<>(); + Set 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 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) { diff --git a/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java index 531d425..edc5e52 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicPeriodServiceTest.java @@ -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(); } diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java index e4a9142..55abd0d 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQualityServiceTest.java @@ -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 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(), diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java index d49ecfa..05c81e0 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceTest.java @@ -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 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 subgroupIds, + String format) { return new ScheduleRuleSlotDto( - null, + id, 1, null, parity, diff --git a/docs/API.md b/docs/API.md index cfb3937..42cf3bd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 { diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 25c238c..f3564b3 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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`-версию на семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с -редактированием черновика или конкурентной публикацией. Каждое создание, архивирование, +редактированием расписания или конкурентной публикацией. Каждое создание, архивирование, публикация и восстановление записывается в неизменяемый журнал с автором, временем и причиной. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 81406c2..38c5fb5 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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` — Аудит версий расписания diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index 52525c6..9a27ea8 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -115,7 +115,8 @@ **Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`, полное копирование правил, валидация и diff правил/занятий, атомарная публикация, восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет -проверять черновики до публикации; изменения в них выполняются через конструктор правил. +проверять черновики до публикации. Конструктор по умолчанию редактирует опубликованное +расписание, позволяет переключиться на черновик и создать новую копию выбранной версии. ### Проблема и пользователи @@ -128,8 +129,9 @@ ### Пользовательский сценарий -1. Пользователь создаёт черновик расписания для выбранного семестра. -2. Все изменения правил применяются только к черновику и не видны обычным пользователям. +1. Пользователь редактирует основное опубликованное расписание напрямую либо создаёт + черновик для выбранного семестра на основе открытой версии. +2. Изменения опубликованной версии видны сразу, а изменения черновика изолированы до публикации. 3. Перед публикацией система проверяет конфликты и показывает сравнение с текущей версией: добавленные, удалённые и изменённые занятия. 4. Ответственный сотрудник указывает причину и публикует версию одной операцией. @@ -149,8 +151,9 @@ ### Связь с текущей системой Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет -только черновик, кабинеты просмотра используют только опубликованную версию, а анализатор -качества позволяет выбрать опубликованную, черновую или архивную версию. Точечные изменения +выбранную опубликованную или черновую версию, кабинеты просмотра используют только +опубликованную, а анализатор качества позволяет выбрать опубликованную, черновую или +архивную версию. Точечные изменения остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически. ### Дальнейшее развитие diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index f7c7b9d..ebec528 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. diff --git a/frontend/admin/css/schedule-versions.css b/frontend/admin/css/schedule-versions.css index 3d5fad5..ef2deeb 100644 --- a/frontend/admin/css/schedule-versions.css +++ b/frontend/admin/css/schedule-versions.css @@ -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; } } diff --git a/frontend/admin/js/views/schedule-quality.js b/frontend/admin/js/views/schedule-quality.js index 7ab4659..4314eb0 100644 --- a/frontend/admin/js/views/schedule-quality.js +++ b/frontend/admin/js/views/schedule-quality.js @@ -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 = ` ${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))} ${escapeHtml(String(analysis.lessonCount))} занятий - ${escapeHtml(String(analysis.totalProblemCount))} проблем + правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))} штраф ${escapeHtml(String(analysis.rawPenalty))} `; qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || []) @@ -145,11 +145,11 @@ function renderAnalysis() { `).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 `
- ${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED' - ? `` - : `${escapeHtml(problemActionLabel(problem))}`} + : 'Только просмотр'}
`; }).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 = ` -
- - Проверяем варианты -

Каждый кандидат проходит полную проверку конфликтов.

-
- `; - 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 = `
- Проверенные варианты -

${escapeHtml(problem.title)}

-

${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}

+ Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))} +

${escapeHtml(problem.subjectName || problem.title)}

+

${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.

-
- ${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')} +
+ ${issueLabels.map(label => `${escapeHtml(label)}`).join('')}
-

- Применение создаёт обычное точечное изменение и не переписывает правило семестра. -

- `; - 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 ` -
-
- ${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'} - ${escapeHtml(candidate.label)} -
-

${escapeHtml(candidate.description)}

-
- ${escapeHtml(impact)} - результат ${escapeHtml(String(candidate.resultingScore))}/100 -
- ${changeList('Улучшится', candidate.improvements, 'positive')} - ${changeList('Компромиссы', candidate.tradeoffs, 'negative')} - -
+ ` : '

Архивное расписание нельзя изменить.

'} `; } -function changeList(label, items, tone) { - if (!items?.length) return ''; - return ` -
- ${escapeHtml(label)} - ${items.map(item => `${escapeHtml(item)}`).join('')} -
- `; +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) {

${escapeHtml(message)}

`; - 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) { diff --git a/frontend/admin/js/views/schedule-versions.js b/frontend/admin/js/views/schedule-versions.js index fadcb7f..68e2adc 100644 --- a/frontend/admin/js/views/schedule-versions.js +++ b/frontend/admin/js/views/schedule-versions.js @@ -113,6 +113,7 @@ function renderCurrent(version) {
+
`; @@ -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; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index df4c876..bee4a44 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -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 => ` - - `).join('') - : ''; + ? [ + versionOptionGroup('Опубликованные расписания', published), + versionOptionGroup('Черновики', drafts) + ].filter(Boolean).join('') + : ''; 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 `${versions.map(version => ` + + `).join('')}`; + } + + 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 = 'Создайте черновик в разделе «Версии расписания»'; - setVisualState('Нет выбранного черновика'); + rulesTbody.innerHTML = 'Нет расписания для редактирования'; + 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) => ` -
+
@@ -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), diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index dcfd8f7..4e9084a 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -1,18 +1,38 @@ -
+
- Изолированный контекст -

Рабочий черновик

-

Выберите черновик: только его правила можно изменять в конструкторе.

+ Основной режим +

Опубликованное расписание

+

Изменения применяются сразу. Для безопасной подготовки можно создать черновик.

- + +
+ +

Новое правило расписания

diff --git a/frontend/tests/schedule-versions.test.mjs b/frontend/tests/schedule-versions.test.mjs index c9ca997..c4e864f 100644 --- a/frontend/tests/schedule-versions.test.mjs +++ b/frontend/tests/schedule-versions.test.mjs @@ -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\)/); }); diff --git a/graphify-out/.graphify_learning.json b/graphify-out/.graphify_learning.json index 9f739be..03b0da5 100644 --- a/graphify-out/.graphify_learning.json +++ b/graphify-out/.graphify_learning.json @@ -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 diff --git a/graphify-out/memory/query_20260811_210637_хочу_чтобы_изменять_можно_было_не_только_черновик.md b/graphify-out/memory/query_20260811_210637_хочу_чтобы_изменять_можно_было_не_только_черновик.md new file mode 100644 index 0000000..3811fbb --- /dev/null +++ b/graphify-out/memory/query_20260811_210637_хочу_чтобы_изменять_можно_было_не_только_черновик.md @@ -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 \ No newline at end of file diff --git a/graphify-out/reflections/LESSONS.md b/graphify-out/reflections/LESSONS.md index e0b5eef..4b7eff7 100644 --- a/graphify-out/reflections/LESSONS.md +++ b/graphify-out/reflections/LESSONS.md @@ -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)