исправил качество правил и изменение расписания (не черновика)
This commit is contained in:
@@ -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("Выберите версию расписания");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ public record ScheduleQualityAnalysisDto(
|
||||
String title,
|
||||
String description,
|
||||
int penalty,
|
||||
Long scheduleRuleId,
|
||||
int affectedLessonCount,
|
||||
List<String> problemTypes,
|
||||
List<String> issueLabels,
|
||||
Long scheduleRuleSlotId,
|
||||
LocalDate lessonDate,
|
||||
Integer timeSlotOrder,
|
||||
|
||||
@@ -5,10 +5,23 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
|
||||
|
||||
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
|
||||
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
|
||||
|
||||
boolean existsByTimeSlotId(Long timeSlotId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM schedule_overrides WHERE base_rule_slot_id IN (:slotIds)
|
||||
UNION ALL
|
||||
SELECT 1 FROM teacher_change_requests WHERE base_rule_slot_id IN (:slotIds)
|
||||
UNION ALL
|
||||
SELECT 1 FROM teacher_absence_decisions WHERE base_rule_slot_id IN (:slotIds)
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
boolean hasProtectedReferences(@Param("slotIds") Collection<Long> slotIds);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -314,9 +314,7 @@ public class ScheduleQualityService {
|
||||
"Насколько близко число пар в разные учебные дни каждой группы")
|
||||
);
|
||||
|
||||
List<ScheduleQualityAnalysisDto.Problem> problems = accumulator.problems.stream()
|
||||
.sorted(problemComparator())
|
||||
.toList();
|
||||
List<ScheduleQualityAnalysisDto.Problem> problems = aggregateProblemsByRule(accumulator.problems);
|
||||
return new Evaluation(score, rawPenalty, lessonCount, metrics, problems);
|
||||
}
|
||||
|
||||
@@ -882,6 +880,10 @@ public class ScheduleQualityService {
|
||||
title,
|
||||
description,
|
||||
penalty,
|
||||
lesson.scheduleRuleId(),
|
||||
1,
|
||||
List.of(type),
|
||||
List.of(title),
|
||||
lesson.scheduleRuleSlotId(),
|
||||
lesson.date(),
|
||||
lesson.timeSlotOrder(),
|
||||
@@ -894,6 +896,78 @@ public class ScheduleQualityService {
|
||||
);
|
||||
}
|
||||
|
||||
private List<ScheduleQualityAnalysisDto.Problem> aggregateProblemsByRule(
|
||||
List<ScheduleQualityAnalysisDto.Problem> problems
|
||||
) {
|
||||
Map<String, List<ScheduleQualityAnalysisDto.Problem>> problemsByRule = new LinkedHashMap<>();
|
||||
for (ScheduleQualityAnalysisDto.Problem problem : problems) {
|
||||
String key = problem.scheduleRuleId() == null
|
||||
? "PROBLEM:" + problem.id()
|
||||
: "RULE:" + problem.scheduleRuleId();
|
||||
problemsByRule.computeIfAbsent(key, ignored -> new ArrayList<>()).add(problem);
|
||||
}
|
||||
return problemsByRule.values().stream()
|
||||
.map(this::aggregateRuleProblems)
|
||||
.sorted(problemComparator())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ScheduleQualityAnalysisDto.Problem aggregateRuleProblems(
|
||||
List<ScheduleQualityAnalysisDto.Problem> problems
|
||||
) {
|
||||
ScheduleQualityAnalysisDto.Problem representative = problems.stream()
|
||||
.min(problemComparator())
|
||||
.orElseThrow();
|
||||
if (representative.scheduleRuleId() == null) {
|
||||
return representative;
|
||||
}
|
||||
|
||||
int penalty = problems.stream().mapToInt(ScheduleQualityAnalysisDto.Problem::penalty).sum();
|
||||
List<String> problemTypes = problems.stream()
|
||||
.flatMap(problem -> problem.problemTypes().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> issueLabels = problems.stream()
|
||||
.flatMap(problem -> problem.issueLabels().stream())
|
||||
.distinct()
|
||||
.toList();
|
||||
int affectedLessonCount = (int) problems.stream()
|
||||
.map(problem -> problem.scheduleRuleSlotId() + ":" + problem.lessonDate())
|
||||
.distinct()
|
||||
.count();
|
||||
String subjectName = representative.subjectName();
|
||||
String title = subjectName == null || subjectName.isBlank()
|
||||
? "Правило расписания снижает качество"
|
||||
: "Правило «" + subjectName + "» снижает качество";
|
||||
String description = affectedLessonCount + " "
|
||||
+ plural(affectedLessonCount, "занятие", "занятия", "занятий")
|
||||
+ " этого правила "
|
||||
+ plural(affectedLessonCount, "снижает", "снижают", "снижают")
|
||||
+ " оценку: " + String.join("; ", issueLabels);
|
||||
|
||||
return new ScheduleQualityAnalysisDto.Problem(
|
||||
"RULE:" + representative.scheduleRuleId(),
|
||||
representative.type(),
|
||||
representative.severity(),
|
||||
title,
|
||||
description,
|
||||
penalty,
|
||||
representative.scheduleRuleId(),
|
||||
Math.max(affectedLessonCount, 1),
|
||||
problemTypes,
|
||||
issueLabels,
|
||||
representative.scheduleRuleSlotId(),
|
||||
representative.lessonDate(),
|
||||
representative.timeSlotOrder(),
|
||||
representative.subjectName(),
|
||||
representative.entityId(),
|
||||
representative.entityName(),
|
||||
representative.requiredCapacity(),
|
||||
representative.classroomCapacity(),
|
||||
problems.stream().allMatch(ScheduleQualityAnalysisDto.Problem::optimizable)
|
||||
);
|
||||
}
|
||||
|
||||
private Comparator<ScheduleQualityAnalysisDto.Problem> problemComparator() {
|
||||
Map<String, Integer> severityOrder = Map.of(
|
||||
"CRITICAL", 0,
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
@@ -77,6 +78,7 @@ public class ScheduleRuleService {
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
|
||||
private ScheduleVersionRepository scheduleVersionRepository;
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
|
||||
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
@@ -110,6 +112,11 @@ public class ScheduleRuleService {
|
||||
this.scheduleVersionRepository = scheduleVersionRepository;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
void setScheduleRuleSlotRepository(ScheduleRuleSlotRepository scheduleRuleSlotRepository) {
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||
return getAll(semesterId, groupId, null);
|
||||
@@ -145,7 +152,7 @@ public class ScheduleRuleService {
|
||||
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
|
||||
|
||||
ScheduleVersion version = resolveDraftVersion(request.scheduleVersionId(), semester.getId());
|
||||
ScheduleVersion version = resolveEditableVersion(request.scheduleVersionId(), semester.getId());
|
||||
|
||||
ScheduleRule candidate = buildCandidate(request, semester, version);
|
||||
validateCandidate(candidate);
|
||||
@@ -168,7 +175,7 @@ public class ScheduleRuleService {
|
||||
// определён по актуальному состоянию и два update одного правила не расходятся.
|
||||
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureDraftRule(managedRule);
|
||||
lockAndEnsureEditableRule(managedRule);
|
||||
Long oldSemesterId = managedRule.getSemester().getId();
|
||||
|
||||
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
|
||||
@@ -194,7 +201,7 @@ public class ScheduleRuleService {
|
||||
validateCandidate(candidate);
|
||||
validateAgainstSavedRules(candidate, managedRule.getId());
|
||||
|
||||
applyCandidate(managedRule, candidate);
|
||||
applyCandidate(managedRule, candidate, request.slots());
|
||||
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
|
||||
scheduleGeneratorService.clearCache();
|
||||
return toDto(saved);
|
||||
@@ -204,7 +211,7 @@ public class ScheduleRuleService {
|
||||
public void archive(Long id) {
|
||||
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
|
||||
lockAndEnsureDraftRule(rule);
|
||||
lockAndEnsureEditableRule(rule);
|
||||
Long semesterId = rule.getSemester().getId();
|
||||
semesterRepository.findByIdForUpdate(semesterId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
|
||||
@@ -805,7 +812,9 @@ public class ScheduleRuleService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
|
||||
private void applyCandidate(ScheduleRule managedRule,
|
||||
ScheduleRule candidate,
|
||||
List<ScheduleRuleSlotDto> requestedSlots) {
|
||||
managedRule.setSubject(candidate.getSubject());
|
||||
managedRule.setSemester(candidate.getSemester());
|
||||
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
|
||||
@@ -817,11 +826,54 @@ public class ScheduleRuleService {
|
||||
|
||||
managedRule.getGroups().clear();
|
||||
managedRule.getGroups().addAll(candidate.getGroups());
|
||||
managedRule.getSlots().clear();
|
||||
for (ScheduleRuleSlot slot : candidate.getSlots()) {
|
||||
slot.setScheduleRule(managedRule);
|
||||
managedRule.getSlots().add(slot);
|
||||
List<ScheduleRuleSlot> candidateSlots = new ArrayList<>(candidate.getSlots());
|
||||
Map<Long, ScheduleRuleSlot> currentSlotsById = managedRule.getSlots().stream()
|
||||
.filter(slot -> slot.getId() != null)
|
||||
.collect(Collectors.toMap(ScheduleRuleSlot::getId, Function.identity()));
|
||||
Set<Long> retainedSlotIds = new HashSet<>();
|
||||
Set<ScheduleRuleSlot> updatedSlots = new LinkedHashSet<>();
|
||||
|
||||
for (int index = 0; index < candidateSlots.size(); index++) {
|
||||
ScheduleRuleSlot candidateSlot = candidateSlots.get(index);
|
||||
Long requestedSlotId = requestedSlots.get(index).id();
|
||||
if (requestedSlotId == null) {
|
||||
candidateSlot.setScheduleRule(managedRule);
|
||||
updatedSlots.add(candidateSlot);
|
||||
continue;
|
||||
}
|
||||
|
||||
ScheduleRuleSlot existingSlot = currentSlotsById.get(requestedSlotId);
|
||||
if (existingSlot == null) {
|
||||
throw new IllegalArgumentException("Слот не принадлежит редактируемому правилу");
|
||||
}
|
||||
retainedSlotIds.add(requestedSlotId);
|
||||
copySlot(existingSlot, candidateSlot);
|
||||
updatedSlots.add(existingSlot);
|
||||
}
|
||||
|
||||
List<Long> removedSlotIds = currentSlotsById.keySet().stream()
|
||||
.filter(id -> !retainedSlotIds.contains(id))
|
||||
.toList();
|
||||
if (!removedSlotIds.isEmpty()
|
||||
&& scheduleRuleSlotRepository != null
|
||||
&& scheduleRuleSlotRepository.hasProtectedReferences(removedSlotIds)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям"
|
||||
);
|
||||
}
|
||||
managedRule.getSlots().clear();
|
||||
managedRule.getSlots().addAll(updatedSlots);
|
||||
}
|
||||
|
||||
private void copySlot(ScheduleRuleSlot target, ScheduleRuleSlot source) {
|
||||
target.setDayOfWeek(source.getDayOfWeek());
|
||||
target.setParity(source.getParity());
|
||||
target.setTimeSlot(source.getTimeSlot());
|
||||
target.setSubgroups(source.getSubgroups());
|
||||
target.setTeacher(source.getTeacher());
|
||||
target.setClassroom(source.getClassroom());
|
||||
target.setLessonType(source.getLessonType());
|
||||
target.setLessonFormat(source.getLessonFormat());
|
||||
}
|
||||
|
||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||
@@ -882,7 +934,7 @@ public class ScheduleRuleService {
|
||||
return List.copyOf(issues);
|
||||
}
|
||||
|
||||
private ScheduleVersion resolveDraftVersion(Long versionId, Long semesterId) {
|
||||
private ScheduleVersion resolveEditableVersion(Long versionId, Long semesterId) {
|
||||
if (versionId == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -894,27 +946,32 @@ public class ScheduleRuleService {
|
||||
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
|
||||
throw new IllegalArgumentException("Версия относится к другому семестру");
|
||||
}
|
||||
if (version.getStatus() != ScheduleVersionStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("Изменять правила можно только в черновике");
|
||||
}
|
||||
ensureEditableVersion(version);
|
||||
return version;
|
||||
}
|
||||
|
||||
private void ensureDraftRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null
|
||||
&& rule.getScheduleVersion().getStatus() != ScheduleVersionStatus.DRAFT) {
|
||||
throw new IllegalArgumentException("Изменять правила можно только в черновике");
|
||||
private void ensureEditableVersion(ScheduleVersion version) {
|
||||
if (version.getStatus() == ScheduleVersionStatus.ARCHIVED) {
|
||||
throw new IllegalArgumentException(
|
||||
"Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void lockAndEnsureDraftRule(ScheduleRule rule) {
|
||||
private void ensureEditableRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null) {
|
||||
ensureEditableVersion(rule.getScheduleVersion());
|
||||
}
|
||||
}
|
||||
|
||||
private void lockAndEnsureEditableRule(ScheduleRule rule) {
|
||||
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
|
||||
ScheduleVersion lockedVersion = scheduleVersionRepository
|
||||
.findByIdForUpdate(rule.getScheduleVersion().getId())
|
||||
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
|
||||
rule.setScheduleVersion(lockedVersion);
|
||||
}
|
||||
ensureDraftRule(rule);
|
||||
ensureEditableRule(rule);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ class ScheduleQualityServiceTest {
|
||||
|
||||
assertThat(result.lessonCount()).isEqualTo(2);
|
||||
assertThat(result.score()).isLessThan(100);
|
||||
assertThat(result.problems())
|
||||
.extracting(problem -> problem.type())
|
||||
assertThat(result.problems().stream()
|
||||
.flatMap(problem -> problem.problemTypes().stream()))
|
||||
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
|
||||
assertThat(result.metrics())
|
||||
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
|
||||
@@ -90,6 +90,33 @@ class ScheduleQualityServiceTest {
|
||||
.satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void analysisAggregatesRecurringProblemsByRuleAndSumsPenalty() {
|
||||
Semester semester = semester();
|
||||
StudentGroup group = group(1L, 30L);
|
||||
Classroom room = classroom(1L, "А-101", 20);
|
||||
LocalDate firstDate = LocalDate.of(2026, 9, 7);
|
||||
List<RenderedLessonDto> lessons = List.of(
|
||||
lesson(777L, 11L, firstDate, 1, 1L, room, group),
|
||||
lesson(777L, 11L, firstDate.plusWeeks(1), 1, 1L, room, group)
|
||||
);
|
||||
stubContext(semester, group, List.of(room), lessons);
|
||||
|
||||
var result = service.analyze(semester.getId());
|
||||
|
||||
assertThat(result.totalProblemCount()).isOne();
|
||||
assertThat(result.rawPenalty()).isEqualTo(18);
|
||||
assertThat(result.problems())
|
||||
.singleElement()
|
||||
.satisfies(problem -> {
|
||||
assertThat(problem.scheduleRuleId()).isEqualTo(777L);
|
||||
assertThat(problem.affectedLessonCount()).isEqualTo(2);
|
||||
assertThat(problem.penalty()).isEqualTo(18);
|
||||
assertThat(problem.problemTypes()).containsExactly("ROOM_CAPACITY_SHORTAGE");
|
||||
assertThat(problem.issueLabels()).containsExactly("Аудитория меньше состава занятия");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
|
||||
Semester semester = semester();
|
||||
@@ -238,9 +265,21 @@ class ScheduleQualityServiceTest {
|
||||
Long timeSlotId,
|
||||
Classroom classroom,
|
||||
StudentGroup group
|
||||
) {
|
||||
return lesson(100L + ruleSlotId, ruleSlotId, date, order, timeSlotId, classroom, group);
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(
|
||||
Long ruleId,
|
||||
Long ruleSlotId,
|
||||
LocalDate date,
|
||||
int order,
|
||||
Long timeSlotId,
|
||||
Classroom classroom,
|
||||
StudentGroup group
|
||||
) {
|
||||
return new RenderedLessonDto(
|
||||
100L + ruleSlotId,
|
||||
ruleId,
|
||||
ruleSlotId,
|
||||
date,
|
||||
date.getDayOfWeek().getValue(),
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.ScheduleVersion;
|
||||
import com.magistr.app.model.ScheduleVersionStatus;
|
||||
import com.magistr.app.model.Semester;
|
||||
import com.magistr.app.model.SemesterType;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
@@ -22,6 +24,8 @@ import com.magistr.app.repository.ClassroomRepository;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.LessonTypesRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleRepository;
|
||||
import com.magistr.app.repository.ScheduleRuleSlotRepository;
|
||||
import com.magistr.app.repository.ScheduleVersionRepository;
|
||||
import com.magistr.app.repository.SemesterRepository;
|
||||
import com.magistr.app.repository.SubjectRepository;
|
||||
import com.magistr.app.repository.SubgroupRepository;
|
||||
@@ -88,6 +92,10 @@ class ScheduleRuleServiceTest {
|
||||
private SubgroupRepository subgroupRepository;
|
||||
@Mock
|
||||
private ScheduleGeneratorService scheduleGeneratorService;
|
||||
@Mock
|
||||
private ScheduleVersionRepository scheduleVersionRepository;
|
||||
@Mock
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
|
||||
private ScheduleRuleService service;
|
||||
private Subject subject;
|
||||
@@ -130,6 +138,8 @@ class ScheduleRuleServiceTest {
|
||||
subgroupRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
service.setScheduleVersionRepository(scheduleVersionRepository);
|
||||
service.setScheduleRuleSlotRepository(scheduleRuleSlotRepository);
|
||||
|
||||
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
|
||||
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
|
||||
@@ -188,6 +198,76 @@ class ScheduleRuleServiceTest {
|
||||
assertThat(saved.practiceAcademicHours()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsEditingPublishedVersionButRejectsArchive() {
|
||||
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
|
||||
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
|
||||
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, null)).thenReturn(List.of());
|
||||
|
||||
ScheduleRuleDto saved = service.create(ruleRequestForVersion(90L, lectureSlot()));
|
||||
|
||||
assertThat(saved.scheduleVersionId()).isEqualTo(90L);
|
||||
assertThat(saved.id()).isNotNull();
|
||||
|
||||
ScheduleVersion archived = scheduleVersion(91L, ScheduleVersionStatus.ARCHIVED);
|
||||
when(scheduleVersionRepository.findByIdForUpdate(91L)).thenReturn(Optional.of(archived));
|
||||
|
||||
assertThatThrownBy(() -> service.create(ruleRequestForVersion(91L, lectureSlot())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesRuleInsidePublishedVersionImmediately() {
|
||||
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
|
||||
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
|
||||
managed.setScheduleVersion(published);
|
||||
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
|
||||
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
|
||||
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
|
||||
|
||||
ScheduleRuleDto updated = service.update(
|
||||
RULE_ID,
|
||||
ruleRequestForVersion(90L, slot(
|
||||
RULE_ID * 10,
|
||||
ScheduleParity.BOTH,
|
||||
TEACHER_ID,
|
||||
CLASSROOM_ID,
|
||||
LECTURE_TYPE_ID,
|
||||
List.of(),
|
||||
"Онлайн"
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(updated.scheduleVersionId()).isEqualTo(90L);
|
||||
assertThat(updated.slots()).singleElement()
|
||||
.satisfies(slot -> {
|
||||
assertThat(slot.id()).isEqualTo(RULE_ID * 10);
|
||||
assertThat(slot.lessonFormat()).isEqualTo("Онлайн");
|
||||
});
|
||||
verify(scheduleGeneratorService).clearCache();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRemovingPublishedSlotReferencedByOperationalHistory() {
|
||||
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
|
||||
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
|
||||
managed.setScheduleVersion(published);
|
||||
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
|
||||
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
|
||||
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
|
||||
when(scheduleRuleSlotRepository.hasProtectedReferences(List.of(RULE_ID * 10))).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.update(
|
||||
RULE_ID,
|
||||
ruleRequestForVersion(90L, lectureSlot())
|
||||
))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям");
|
||||
|
||||
verify(scheduleRuleRepository, never()).saveAndFlush(managed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActiveUserWithoutTeacherRole() {
|
||||
User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор");
|
||||
@@ -437,6 +517,37 @@ class ScheduleRuleServiceTest {
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleRuleDto ruleRequestForVersion(Long versionId, ScheduleRuleSlotDto... slots) {
|
||||
return new ScheduleRuleDto(
|
||||
null,
|
||||
SUBJECT_ID,
|
||||
null,
|
||||
SEMESTER_ID,
|
||||
null,
|
||||
null,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
List.of(GROUP_ID),
|
||||
null,
|
||||
List.of(slots),
|
||||
versionId
|
||||
);
|
||||
}
|
||||
|
||||
private ScheduleVersion scheduleVersion(Long id, ScheduleVersionStatus status) {
|
||||
ScheduleVersion version = new ScheduleVersion();
|
||||
version.setId(id);
|
||||
version.setSemester(semester);
|
||||
version.setVersionNumber(1);
|
||||
version.setName("Тестовая версия");
|
||||
version.setStatus(status);
|
||||
return version;
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto lectureSlot() {
|
||||
return slot(
|
||||
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
|
||||
@@ -461,8 +572,18 @@ class ScheduleRuleServiceTest {
|
||||
long lessonTypeId,
|
||||
List<Long> subgroupIds,
|
||||
String format) {
|
||||
return slot(null, parity, teacherId, classroomId, lessonTypeId, subgroupIds, format);
|
||||
}
|
||||
|
||||
private ScheduleRuleSlotDto slot(Long id,
|
||||
ScheduleParity parity,
|
||||
long teacherId,
|
||||
long classroomId,
|
||||
long lessonTypeId,
|
||||
List<Long> subgroupIds,
|
||||
String format) {
|
||||
return new ScheduleRuleSlotDto(
|
||||
null,
|
||||
id,
|
||||
1,
|
||||
null,
|
||||
parity,
|
||||
|
||||
16
docs/API.md
16
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
|
||||
{
|
||||
|
||||
@@ -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`-версию на
|
||||
семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с
|
||||
редактированием черновика или конкурентной публикацией. Каждое создание, архивирование,
|
||||
редактированием расписания или конкурентной публикацией. Каждое создание, архивирование,
|
||||
публикация и восстановление записывается в неизменяемый журнал с автором, временем и
|
||||
причиной.
|
||||
|
||||
|
||||
@@ -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` — Аудит версий расписания
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
**Статус: реализовано в MVP.** Добавлены изолированные версии `DRAFT/PUBLISHED/ARCHIVED`,
|
||||
полное копирование правил, валидация и diff правил/занятий, атомарная публикация,
|
||||
восстановление предыдущей публикации и неизменяемый журнал действий. Экран качества умеет
|
||||
проверять черновики до публикации; изменения в них выполняются через конструктор правил.
|
||||
проверять черновики до публикации. Конструктор по умолчанию редактирует опубликованное
|
||||
расписание, позволяет переключиться на черновик и создать новую копию выбранной версии.
|
||||
|
||||
### Проблема и пользователи
|
||||
|
||||
@@ -128,8 +129,9 @@
|
||||
|
||||
### Пользовательский сценарий
|
||||
|
||||
1. Пользователь создаёт черновик расписания для выбранного семестра.
|
||||
2. Все изменения правил применяются только к черновику и не видны обычным пользователям.
|
||||
1. Пользователь редактирует основное опубликованное расписание напрямую либо создаёт
|
||||
черновик для выбранного семестра на основе открытой версии.
|
||||
2. Изменения опубликованной версии видны сразу, а изменения черновика изолированы до публикации.
|
||||
3. Перед публикацией система проверяет конфликты и показывает сравнение с текущей версией:
|
||||
добавленные, удалённые и изменённые занятия.
|
||||
4. Ответственный сотрудник указывает причину и публикует версию одной операцией.
|
||||
@@ -149,8 +151,9 @@
|
||||
### Связь с текущей системой
|
||||
|
||||
Текущая динамическая генерация получила явный контекст версии. Конструктор правил меняет
|
||||
только черновик, кабинеты просмотра используют только опубликованную версию, а анализатор
|
||||
качества позволяет выбрать опубликованную, черновую или архивную версию. Точечные изменения
|
||||
выбранную опубликованную или черновую версию, кабинеты просмотра используют только
|
||||
опубликованную, а анализатор качества позволяет выбрать опубликованную, черновую или
|
||||
архивную версию. Точечные изменения
|
||||
остаются привязаны к конкретной публикации и не переносятся в новый черновик автоматически.
|
||||
|
||||
### Дальнейшее развитие
|
||||
|
||||
@@ -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 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function initScheduleQuality() {
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
|
||||
await loadSemesters();
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ async function runAnalysis() {
|
||||
renderAnalysis();
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
|
||||
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -131,7 +131,7 @@ function renderAnalysis() {
|
||||
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
|
||||
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
|
||||
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
|
||||
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
|
||||
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
|
||||
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
|
||||
`;
|
||||
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
|
||||
@@ -145,11 +145,11 @@ function renderAnalysis() {
|
||||
</article>
|
||||
`).join('');
|
||||
const totalLabel = analysis.problemsTruncated
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
|
||||
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
|
||||
: `Правил с проблемами: ${analysis.totalProblemCount}`;
|
||||
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
|
||||
renderProblems();
|
||||
renderRecommendationsEmpty();
|
||||
renderRulePanelEmpty();
|
||||
}
|
||||
|
||||
function renderProblems() {
|
||||
@@ -171,6 +171,9 @@ function renderProblems() {
|
||||
}
|
||||
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
|
||||
const selected = problem.id === qualityState.selectedProblemId;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
return `
|
||||
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
|
||||
<button type="button" class="quality-problem-main" data-problem-index="${index}">
|
||||
@@ -178,199 +181,92 @@ function renderProblems() {
|
||||
<span class="quality-problem-copy">
|
||||
<strong>${escapeHtml(problem.title)}</strong>
|
||||
<span>${escapeHtml(problem.description)}</span>
|
||||
<span class="quality-rule-issues">${issueLabels.map(label => `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
`).join('')}</span>
|
||||
<small>
|
||||
${escapeHtml(formatDate(problem.lessonDate))}
|
||||
· ${escapeHtml(problem.subjectName || 'Занятие')}
|
||||
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
|
||||
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
|
||||
· ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку
|
||||
· суммарный штраф
|
||||
</small>
|
||||
</span>
|
||||
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
|
||||
</button>
|
||||
<div class="quality-problem-action">
|
||||
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
|
||||
Подобрать улучшения
|
||||
${canEdit
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
|
||||
Изменить правило
|
||||
</button>`
|
||||
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
|
||||
: '<span>Только просмотр</span>'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function handleProblemClick(event) {
|
||||
function handleProblemClick(event) {
|
||||
const button = event.target.closest('[data-problem-index]');
|
||||
if (!button) return;
|
||||
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
|
||||
if (!problem) return;
|
||||
if (button.dataset.action === 'edit-rule') {
|
||||
openRuleInConstructor(problem);
|
||||
return;
|
||||
}
|
||||
qualityState.selectedProblemId = problem.id;
|
||||
renderProblems();
|
||||
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
renderRecommendationsMessage(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик остаётся изолированным'
|
||||
: 'Архивная версия доступна только для проверки',
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
|
||||
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!problem.optimizable) {
|
||||
renderRecommendationsMessage(
|
||||
'Ручное решение сохранено',
|
||||
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await loadRecommendations(problem);
|
||||
renderRuleDetails(problem);
|
||||
}
|
||||
|
||||
async function loadRecommendations(problem) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-loading">
|
||||
<span class="quality-loader"></span>
|
||||
<strong>Проверяем варианты</strong>
|
||||
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
|
||||
</div>
|
||||
`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
semesterId: String(qualityState.analysis.semesterId),
|
||||
versionId: String(qualityState.analysis.scheduleVersionId),
|
||||
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
|
||||
lessonDate: problem.lessonDate
|
||||
});
|
||||
const result = await api.get(
|
||||
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
|
||||
);
|
||||
renderRecommendations(problem, result);
|
||||
} catch (error) {
|
||||
renderRecommendationsMessage(
|
||||
'Не удалось подобрать варианты',
|
||||
error.message || 'Повторите попытку после обновления анализа.'
|
||||
);
|
||||
}
|
||||
function renderRulePanelEmpty() {
|
||||
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
|
||||
renderRulePanelMessage(
|
||||
archived ? 'Архивная версия' : 'Работа с правилом',
|
||||
archived
|
||||
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
|
||||
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecommendations(problem, result) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidates = result.candidates || [];
|
||||
if (!candidates.length) {
|
||||
renderRecommendationsMessage(
|
||||
'Свободных улучшений не найдено',
|
||||
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
function renderRuleDetails(problem) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-head">
|
||||
<span class="quality-kicker">Проверенные варианты</span>
|
||||
<h3>${escapeHtml(problem.title)}</h3>
|
||||
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
|
||||
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
|
||||
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
|
||||
<p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
|
||||
</div>
|
||||
<div class="quality-candidates">
|
||||
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
|
||||
<div class="quality-rule-detail-list">
|
||||
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
|
||||
</div>
|
||||
<p class="quality-recommendations-note">
|
||||
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
|
||||
</p>
|
||||
`;
|
||||
panel._qualityCandidates = candidates;
|
||||
}
|
||||
|
||||
function recommendationCard(candidate, index) {
|
||||
const impactClass = candidate.penaltyDelta > 0
|
||||
? 'is-positive'
|
||||
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
|
||||
const impact = candidate.scoreDelta > 0
|
||||
? `+${candidate.scoreDelta} к оценке`
|
||||
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
|
||||
return `
|
||||
<article class="quality-candidate ${impactClass}">
|
||||
<div class="quality-candidate-top">
|
||||
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
|
||||
<strong>${escapeHtml(candidate.label)}</strong>
|
||||
</div>
|
||||
<p>${escapeHtml(candidate.description)}</p>
|
||||
<div class="quality-candidate-impact">
|
||||
<span>${escapeHtml(impact)}</span>
|
||||
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
|
||||
</div>
|
||||
${changeList('Улучшится', candidate.improvements, 'positive')}
|
||||
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
|
||||
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
|
||||
Применить после подтверждения
|
||||
${canEdit ? `
|
||||
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
|
||||
Открыть правило в конструкторе
|
||||
</button>
|
||||
</article>
|
||||
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
|
||||
`;
|
||||
}
|
||||
|
||||
function changeList(label, items, tone) {
|
||||
if (!items?.length) return '';
|
||||
return `
|
||||
<div class="quality-change-list is-${tone}">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
function handleRulePanelClick(event) {
|
||||
const button = event.target.closest('[data-edit-rule-id]');
|
||||
if (!button) return;
|
||||
const problem = (qualityState?.analysis?.problems || [])
|
||||
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
|
||||
if (problem) openRuleInConstructor(problem);
|
||||
}
|
||||
|
||||
async function handleRecommendationClick(event) {
|
||||
const button = event.target.closest('[data-candidate-index]');
|
||||
if (!button || qualityState?.applying) return;
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
|
||||
if (!candidate) return;
|
||||
const confirmed = window.confirm(
|
||||
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
qualityState.applying = true;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Применяем…';
|
||||
try {
|
||||
await api.post('/api/edu-office/schedule/overrides', candidate.override);
|
||||
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
|
||||
await runAnalysis();
|
||||
} catch (error) {
|
||||
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
|
||||
button.disabled = false;
|
||||
button.textContent = 'Применить после подтверждения';
|
||||
} finally {
|
||||
qualityState.applying = false;
|
||||
}
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||||
}
|
||||
|
||||
function renderRecommendationsEmpty() {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
|
||||
renderRecommendationsMessage(
|
||||
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
|
||||
isDraft
|
||||
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
|
||||
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderRecommendationsMessage(
|
||||
'Локальные улучшения',
|
||||
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
|
||||
);
|
||||
}
|
||||
|
||||
function problemActionLabel(problem) {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
|
||||
return 'Исправляется в конструкторе';
|
||||
}
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
|
||||
return 'Только просмотр';
|
||||
}
|
||||
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
|
||||
}
|
||||
|
||||
function renderRecommendationsMessage(title, message) {
|
||||
function renderRulePanelMessage(title, message) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = `
|
||||
@@ -380,7 +276,6 @@ function renderRecommendationsMessage(title, message) {
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
panel._qualityCandidates = [];
|
||||
}
|
||||
|
||||
function qualityStatus(message, tone = '') {
|
||||
@@ -393,7 +288,8 @@ function qualityStatus(message, tone = '') {
|
||||
export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
return (problems || []).filter(problem => {
|
||||
const severityMatches = !severity || problem.severity === severity;
|
||||
const categoryMatches = !category || problemCategory(problem.type) === category;
|
||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||
return severityMatches && categoryMatches;
|
||||
});
|
||||
}
|
||||
@@ -448,13 +344,13 @@ function localIsoDate(date) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function problemWord(count) {
|
||||
function lessonWord(count) {
|
||||
const mod100 = count % 100;
|
||||
const mod10 = count % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
|
||||
if (mod10 === 1) return 'проблема';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
|
||||
return 'проблем';
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
|
||||
if (mod10 === 1) return 'занятие';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
|
||||
return 'занятий';
|
||||
}
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
|
||||
@@ -113,6 +113,7 @@ function renderCurrent(version) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-card-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -196,7 +197,7 @@ async function handleVersionAction(event) {
|
||||
if (!version) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === 'edit') {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(version.id));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
|
||||
navigateTo('schedule');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
|| available.find(version => version.status === 'PUBLISHED'
|
||||
&& String(version.semesterId) === String(semesterId))
|
||||
|| available.find(version => version.status === 'PUBLISHED')
|
||||
|| available.find(version => String(version.semesterId) === String(semesterId))
|
||||
|| available[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
const LESSON_TYPE_LIMITS = [
|
||||
{
|
||||
key: 'lecture',
|
||||
@@ -68,8 +79,20 @@ export async function initSchedule() {
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const scheduleVersionSelect = document.getElementById('schedule-version-select');
|
||||
const scheduleVersionContext = document.getElementById('schedule-version-context');
|
||||
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
|
||||
const scheduleVersionTitle = document.getElementById('schedule-version-title');
|
||||
const scheduleVersionNote = document.getElementById('schedule-version-note');
|
||||
const scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft');
|
||||
const scheduleVersionManage = document.getElementById('schedule-version-manage');
|
||||
const draftModal = document.getElementById('schedule-draft-modal');
|
||||
const draftForm = document.getElementById('schedule-draft-form');
|
||||
const draftNameInput = document.getElementById('schedule-draft-name');
|
||||
const draftCopy = document.getElementById('schedule-draft-copy');
|
||||
const draftSummary = document.getElementById('schedule-draft-summary');
|
||||
const draftCloseButton = document.getElementById('schedule-draft-close');
|
||||
const draftCancelButton = document.getElementById('schedule-draft-cancel');
|
||||
const draftSubmitButton = document.getElementById('schedule-draft-submit');
|
||||
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||||
hours: document.getElementById(config.hoursInputId),
|
||||
startWeek: document.getElementById(config.startWeekInputId)
|
||||
@@ -138,6 +161,7 @@ export async function initSchedule() {
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -159,13 +183,21 @@ export async function initSchedule() {
|
||||
if (scheduleVersionSelect.value) {
|
||||
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
|
||||
}
|
||||
updateVersionContext();
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
});
|
||||
scheduleVersionCreateDraft?.addEventListener('click', openDraftModal);
|
||||
scheduleVersionManage?.addEventListener('click', () => {
|
||||
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
|
||||
});
|
||||
draftCloseButton?.addEventListener('click', closeDraftModal);
|
||||
draftCancelButton?.addEventListener('click', closeDraftModal);
|
||||
draftModal?.addEventListener('click', event => {
|
||||
if (event.target === draftModal) closeDraftModal();
|
||||
});
|
||||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
@@ -211,6 +243,10 @@ export async function initSchedule() {
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (draftModal && !draftModal.hidden) {
|
||||
closeDraftModal();
|
||||
return;
|
||||
}
|
||||
if (moveSlotModal?.classList.contains('open')) {
|
||||
closeMoveSlotModal();
|
||||
return;
|
||||
@@ -302,31 +338,123 @@ export async function initSchedule() {
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
async function loadScheduleVersions() {
|
||||
async function loadScheduleVersions(preferredVersionId = null) {
|
||||
const responses = await Promise.all(semesters.map(semester =>
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
|
||||
));
|
||||
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT');
|
||||
const storedId = localStorage.getItem('magistr.schedule.versionId');
|
||||
const stored = scheduleVersions.find(version => String(version.id) === storedId);
|
||||
const selected = stored || scheduleVersions[0] || null;
|
||||
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
|
||||
localStorage.removeItem('magistr.schedule.openVersionId');
|
||||
const selected = chooseScheduleVersion(
|
||||
scheduleVersions,
|
||||
ruleSemesterSelect.value || defaultScheduleSemesterId(),
|
||||
requestedVersionId
|
||||
);
|
||||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||||
const drafts = scheduleVersions.filter(version => version.status === 'DRAFT');
|
||||
scheduleVersionSelect.innerHTML = scheduleVersions.length
|
||||
? scheduleVersions.map(version => `
|
||||
<option value="${version.id}">
|
||||
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
|
||||
</option>
|
||||
`).join('')
|
||||
: '<option value="">Черновики не созданы</option>';
|
||||
? [
|
||||
versionOptionGroup('Опубликованные расписания', published),
|
||||
versionOptionGroup('Черновики', drafts)
|
||||
].filter(Boolean).join('')
|
||||
: '<option value="">Расписания не созданы</option>';
|
||||
scheduleVersionSelect.value = selected ? String(selected.id) : '';
|
||||
if (selected) {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
|
||||
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
|
||||
} else {
|
||||
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
|
||||
}
|
||||
updateVersionContext();
|
||||
setRuleEditorEnabled(Boolean(selected));
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
if (!versions.length) return '';
|
||||
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
|
||||
<option value="${version.id}">
|
||||
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
|
||||
</option>
|
||||
`).join('')}</optgroup>`;
|
||||
}
|
||||
|
||||
function defaultScheduleSemesterId() {
|
||||
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
|
||||
if (saved) return saved.id;
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||
return current?.id || semesters[0]?.id || '';
|
||||
}
|
||||
|
||||
function updateVersionContext() {
|
||||
const version = selectedScheduleVersion();
|
||||
const isDraft = version?.status === 'DRAFT';
|
||||
if (scheduleVersionContext) scheduleVersionContext.dataset.status = isDraft ? 'DRAFT' : 'PUBLISHED';
|
||||
if (scheduleVersionEyebrow) scheduleVersionEyebrow.textContent = isDraft ? 'Безопасная рабочая копия' : 'Основной режим';
|
||||
if (scheduleVersionTitle) scheduleVersionTitle.textContent = isDraft ? 'Редактирование черновика' : 'Опубликованное расписание';
|
||||
if (!version) {
|
||||
scheduleVersionNote.textContent = 'Расписание для редактирования не найдено.';
|
||||
return;
|
||||
}
|
||||
scheduleVersionNote.textContent = isDraft
|
||||
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
|
||||
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`;
|
||||
}
|
||||
|
||||
function openDraftModal() {
|
||||
const base = selectedScheduleVersion();
|
||||
if (!base || creatingDraft) return;
|
||||
draftForm?.reset();
|
||||
if (draftCopy) {
|
||||
draftCopy.textContent = `За основу будет взято «${base.name}» — все его правила и слоты будут скопированы.`;
|
||||
}
|
||||
if (draftSummary) {
|
||||
draftSummary.textContent = `${base.semesterLabel} · версия ${base.versionNumber}`;
|
||||
draftSummary.dataset.tone = '';
|
||||
}
|
||||
draftModal.hidden = false;
|
||||
draftNameInput?.focus();
|
||||
}
|
||||
|
||||
function closeDraftModal() {
|
||||
if (!draftModal || creatingDraft) return;
|
||||
draftModal.hidden = true;
|
||||
}
|
||||
|
||||
async function createDraftFromSelectedVersion(event) {
|
||||
event.preventDefault();
|
||||
const base = selectedScheduleVersion();
|
||||
const name = draftNameInput?.value.trim();
|
||||
if (!base || !name || creatingDraft) return;
|
||||
creatingDraft = true;
|
||||
if (draftSubmitButton) {
|
||||
draftSubmitButton.disabled = true;
|
||||
draftSubmitButton.textContent = 'Создаём…';
|
||||
}
|
||||
try {
|
||||
const created = await api.post('/api/edu-office/schedule/versions', {
|
||||
semesterId: Number(base.semesterId),
|
||||
name,
|
||||
basedOnVersionId: Number(base.id)
|
||||
});
|
||||
draftModal.hidden = true;
|
||||
await loadScheduleVersions(created.id);
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
showAlert('schedule-rule-alert', `Черновик «${created.name}» создан и открыт.`, 'success');
|
||||
} catch (error) {
|
||||
if (draftSummary) {
|
||||
draftSummary.textContent = error.message || 'Не удалось создать черновик.';
|
||||
draftSummary.dataset.tone = 'error';
|
||||
}
|
||||
} finally {
|
||||
creatingDraft = false;
|
||||
if (draftSubmitButton) {
|
||||
draftSubmitButton.disabled = false;
|
||||
draftSubmitButton.textContent = 'Создать и открыть';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectedScheduleVersion() {
|
||||
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
|
||||
}
|
||||
@@ -352,8 +480,8 @@ export async function initSchedule() {
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) {
|
||||
rules = [];
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
|
||||
setVisualState('Нет выбранного черновика');
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет расписания для редактирования</td></tr>';
|
||||
setVisualState('Нет выбранного расписания');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1390,7 +1518,7 @@ export async function initSchedule() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
const typeLimits = readTypeLimits();
|
||||
validateTypeLimits(typeLimits, slots);
|
||||
@@ -1456,7 +1584,7 @@ export async function initSchedule() {
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
|
||||
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
|
||||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
|
||||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}" data-slot-id="${escapeHtml(String(slot.id || ''))}">
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
@@ -1521,8 +1649,9 @@ export async function initSchedule() {
|
||||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||||
return rows.map((row, index) => {
|
||||
const subgroupIds = readSlotSubgroupIds(row);
|
||||
const slotId = Number(row.dataset.slotId);
|
||||
const slot = {
|
||||
id: null,
|
||||
id: slotId || null,
|
||||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||||
parity: row.querySelector('.slot-parity').value,
|
||||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
<section class="version-constructor-bar" id="schedule-version-context">
|
||||
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
|
||||
<div>
|
||||
<span class="version-eyebrow">Изолированный контекст</span>
|
||||
<h2>Рабочий черновик</h2>
|
||||
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p>
|
||||
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
|
||||
<h2 id="schedule-version-title">Опубликованное расписание</h2>
|
||||
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
|
||||
</div>
|
||||
<div class="version-constructor-controls">
|
||||
<label for="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" data-native-select="true">
|
||||
<option value="">Загрузка черновиков…</option>
|
||||
<option value="">Загрузка расписаний…</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Управление версиями</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
|
||||
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
|
||||
<button type="button" class="version-modal-close" id="schedule-draft-close" aria-label="Закрыть">×</button>
|
||||
<span class="version-eyebrow">Новая рабочая копия</span>
|
||||
<h3 id="schedule-draft-title">Создать черновик</h3>
|
||||
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
|
||||
<form id="schedule-draft-form">
|
||||
<label for="schedule-draft-name">Название черновика</label>
|
||||
<input id="schedule-draft-name" type="text" maxlength="160" required
|
||||
placeholder="Например, расписание после распределения аудиторий">
|
||||
<div class="version-modal-summary" id="schedule-draft-summary"></div>
|
||||
<div class="version-modal-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-draft-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-draft-submit">Создать и открыть</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card" id="schedule-rule-editor">
|
||||
<div class="card-header-row">
|
||||
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user