исправил качество правил и изменение расписания (не черновика)
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,
|
||||
|
||||
Reference in New Issue
Block a user