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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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