Поправил графики

This commit is contained in:
dipatrik10
2026-08-14 23:41:55 +03:00
parent 491e373cc3
commit d384a43aa2
22 changed files with 627 additions and 131 deletions

View File

@@ -7,7 +7,6 @@ import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@@ -28,10 +27,6 @@ public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
@@ -40,10 +35,6 @@ public class AcademicCalendarController {
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
@@ -51,10 +42,6 @@ public class AcademicCalendarController {
AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
@@ -81,10 +68,7 @@ public class AcademicCalendarController {
@PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try {
AcademicCalendar calendar = new AcademicCalendar();
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
@@ -153,41 +137,6 @@ public class AcademicCalendarController {
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
}
AcademicYear year = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
}
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
calendar.setAcademicYear(year);
calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile);
calendar.setStudyForm(studyForm);
calendar.setCourseCount(request.courseCount());
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");

View File

@@ -53,7 +53,7 @@ public class WorkloadController {
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarizeTeacherWorkload(lessons, startDate, endDate);
return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
}
@GetMapping("/classrooms")
@@ -98,15 +98,19 @@ public class WorkloadController {
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
LocalDate endDate,
Long scopedDepartmentId) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
? assignmentsByTeacher(lessons, startDate, endDate)
: Map.of();
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey(
lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
scopedDepartmentId != null
? scopedDepartmentId
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
),
LinkedHashMap::new,
Collectors.toList()

View File

@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
@@ -28,14 +31,18 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
@Service
public class AcademicStructureService {
private static final String THEORY_ACTIVITY_CODE = "Т";
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
@@ -48,6 +55,7 @@ public class AcademicStructureService {
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
@@ -59,6 +67,7 @@ public class AcademicStructureService {
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
@@ -82,6 +91,41 @@ public class AcademicStructureService {
return saved;
}
@Transactional
public AcademicCalendar createCalendar(AcademicCalendarDto request) {
validateCalendarRequest(request);
CalendarDimensions dimensions = resolveCalendarDimensions(request);
AcademicCalendarActivityType theoryActivity = activityTypeRepository.findByCode(THEORY_ACTIVITY_CODE)
.filter(activity -> Boolean.TRUE.equals(activity.getAllowSchedule()))
.orElseThrow(() -> new IllegalArgumentException(
"Код активности «Т», разрешающий обычные занятия, не найден"
));
AcademicCalendar calendar = new AcademicCalendar();
calendar.setTitle(AcademicCalendarTitleBuilder.build(
dimensions.academicYear(),
dimensions.speciality(),
dimensions.profile(),
dimensions.studyForm()
));
applyCalendarDimensions(calendar, dimensions, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar);
List<AcademicCalendarPeriod> defaultPeriods = new ArrayList<>(request.courseCount());
for (int courseNumber = 1; courseNumber <= request.courseCount(); courseNumber++) {
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
period.setAcademicCalendar(saved);
period.setCourseNumber(courseNumber);
period.setStartDate(dimensions.academicYear().getStartDate());
period.setEndDate(dimensions.academicYear().getEndDate());
period.setActivityType(theoryActivity);
defaultPeriods.add(period);
}
calendarPeriodRepository.saveAll(defaultPeriods);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request);

View File

@@ -3,6 +3,7 @@ package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired;
@@ -24,25 +25,38 @@ public class ScheduleQueryService {
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicDateService academicDateService;
private final TeacherDepartmentService teacherDepartmentService;
@Autowired
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
AcademicDateService academicDateService,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService;
this.academicDateService = academicDateService;
this.teacherDepartmentService = teacherDepartmentService;
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, academicDateService, null);
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, null, null);
}
/**
@@ -192,10 +206,19 @@ public class ScheduleQueryService {
.findByLessonDateBetweenWithDetails(startDate, endDate);
generatedLessons = new ArrayList<>(generatedLessons);
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
List<RenderedLessonDto> effectiveLessons = applyOverrides(generatedLessons, null, overrideSnapshot);
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(effectiveLessons, departmentId, startDate, endDate);
List<RenderedLessonDto> lessons = effectiveLessons.stream()
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> departmentId == null || teacherDepartmentService
.hasDepartmentAssignmentAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
departmentId,
lesson.date()
))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
.filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
@@ -204,6 +227,29 @@ public class ScheduleQueryService {
return deduplicate(lessons);
}
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
Long departmentId,
LocalDate startDate,
LocalDate endDate
) {
if (departmentId == null) {
return Map.of();
}
if (teacherDepartmentService == null) {
throw new IllegalStateException("Сервис кафедральных связей преподавателей недоступен");
}
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private void addOverrideSourceOccurrences(
List<RenderedLessonDto> generatedLessons,
Collection<ScheduleOverride> overrideSnapshot,
@@ -395,12 +441,10 @@ public class ScheduleQueryService {
.map(List::of)
.orElse(List.of());
}
List<StudentGroup> groups;
if (departmentId != null) {
groups = groupRepository.findByDepartmentId(departmentId);
} else {
groups = groupRepository.findAll();
}
// Кафедральный scope определяется по преподавателю на дату занятия,
// а не по выпускающей кафедре учебной группы. Фильтрация выполняется
// после генерации и применения точечных изменений.
List<StudentGroup> groups = groupRepository.findAll();
groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();

View File

@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
.findFirst();
}
public boolean hasDepartmentAssignmentAtDate(
Collection<TeacherDepartmentAssignment> assignments,
Long departmentId,
LocalDate date
) {
if (assignments == null || departmentId == null || date == null) {
return false;
}
return assignments.stream()
.filter(Objects::nonNull)
.filter(assignment -> isActiveAt(assignment, date))
.map(TeacherDepartmentAssignment::getDepartment)
.filter(Objects::nonNull)
.map(Department::getId)
.anyMatch(departmentId::equals);
}
@Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request,

View File

@@ -0,0 +1,24 @@
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
academic_year.start_date,
academic_year.end_date,
theory_activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year
ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types theory_activity
ON theory_activity.code = 'Т'
CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
WHERE NOT EXISTS (
SELECT 1
FROM academic_calendar_periods existing_period
WHERE existing_period.calendar_id = calendar.id
);

View File

@@ -12,10 +12,6 @@ import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
@@ -24,22 +20,17 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest {
@Test
void createCalendarBuildsTitleWithoutClientValue() {
void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class);
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
AcademicYear year = new AcademicYear();
year.setId(1L);
@@ -55,26 +46,6 @@ class AcademicCalendarControllerTest {
studyForm.setId(4L);
studyForm.setName("Очная");
when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year));
when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(3L)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm));
when(calendarRepository.save(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarPeriodRepository.class),
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
mock(AcademicStructureService.class)
);
AcademicCalendarDto request = new AcademicCalendarDto(
null,
null,
@@ -91,13 +62,31 @@ class AcademicCalendarControllerTest {
null,
4
);
AcademicCalendar saved = new AcademicCalendar();
saved.setTitle("09.03.04 — Разработка ПО — Очная — 2026-2027");
saved.setAcademicYear(year);
saved.setSpeciality(speciality);
saved.setSpecialtyProfile(profile);
saved.setStudyForm(studyForm);
saved.setCourseCount(4);
when(academicStructureService.createCalendar(request)).thenReturn(saved);
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarPeriodRepository.class),
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
academicStructureService
);
var response = controller.createCalendar(request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
.isEqualTo(saved.getTitle());
}
@Test
@@ -134,10 +123,6 @@ class AcademicCalendarControllerTest {
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
periodRepository,
mock(AcademicYearRepository.class),
mock(SpecialtiesRepository.class),
mock(SpecialtyProfileRepository.class),
mock(EducationFormRepository.class),
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),

View File

@@ -82,6 +82,36 @@ class WorkloadControllerTest {
.containsOnly(1L);
}
@Test
void departmentTeacherWorkloadUsesRequestedAdditionalDepartment() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
RenderedLessonDto lesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 5, 15), 1L);
when(scheduleQueryService.searchForAggregation(7L, null, startDate, endDate))
.thenReturn(List.of(lesson));
when(departmentRepository.findById(7L))
.thenReturn(Optional.of(new Department(7L, "Кафедра ИБ", 7L)));
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
departmentRepository,
teacherDepartmentService
);
authenticate(Role.DEPARTMENT, 7L);
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(10L);
assertThat(item.departmentId()).isEqualTo(7L);
assertThat(item.lessonCount()).isEqualTo(1L);
});
verifyNoInteractions(teacherDepartmentService);
}
@Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);

View File

@@ -0,0 +1,148 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 заполняет кодом Т только полностью пустые графики")
void migrationBackfillsOnlyEmptyCalendars() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId);
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(readSummary(connection, emptyCalendarId))
.isEqualTo(new PeriodSummary(3, 1, 3, true));
assertThat(readSummary(connection, configuredCalendarId))
.isEqualTo(new PeriodSummary(1, 1, 1, false));
}
}
private Flyway flyway(String targetVersion) {
var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.cleanDisabled(false);
if (targetVersion != null) {
configuration.target(MigrationVersion.fromVersion(targetVersion));
}
return configuration.load();
}
private long insertCalendarFromSeed(Connection connection,
String title,
int courseCount) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
FROM academic_calendars
ORDER BY id
LIMIT 1
RETURNING id
""")) {
statement.setString(1, title);
statement.setInt(2, courseCount);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT calendar.id, 1, academic_year.start_date, academic_year.start_date, activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'К'
WHERE calendar.id = ?
""")) {
statement.setLong(1, calendarId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private PeriodSummary readSummary(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT
COUNT(*),
MIN(period.course_number),
MAX(period.course_number),
BOOL_AND(
activity.code = 'Т'
AND period.start_date = academic_year.start_date
AND period.end_date = academic_year.end_date
)
FROM academic_calendar_periods period
JOIN academic_calendars calendar ON calendar.id = period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.id = period.activity_type_id
WHERE period.calendar_id = ?
""")) {
statement.setLong(1, calendarId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return new PeriodSummary(
resultSet.getInt(1),
resultSet.getInt(2),
resultSet.getInt(3),
resultSet.getBoolean(4)
);
}
}
}
private record PeriodSummary(int count,
int minCourse,
int maxCourse,
boolean fullYearTheory) {
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
@@ -30,6 +31,7 @@ class AcademicCalendarWeekNumberBaselineIntegrationTest {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();

View File

@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
@@ -23,6 +26,7 @@ import com.magistr.app.repository.SubgroupRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
@@ -56,6 +60,8 @@ class AcademicStructureServiceTest {
@Mock
private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock
private AcademicYearRepository academicYearRepository;
@@ -88,6 +94,7 @@ class AcademicStructureServiceTest {
service = new AcademicStructureService(
calendarRepository,
calendarPeriodRepository,
activityTypeRepository,
calendarSubjectRepository,
academicYearRepository,
specialtiesRepository,
@@ -118,12 +125,54 @@ class AcademicStructureServiceTest {
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarActivityType theoryActivity = new AcademicCalendarActivityType();
theoryActivity.setId(80L);
theoryActivity.setCode("Т");
theoryActivity.setName("Теоретическое обучение");
theoryActivity.setAllowSchedule(true);
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.of(theoryActivity));
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void createsCalendarWithTheoryPeriodForEveryCourseAcrossWholeAcademicYear() {
AcademicCalendar saved = service.createCalendar(calendarRequest(3, FORM_ID));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarPeriod>> periodsCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarPeriodRepository).saveAll(periodsCaptor.capture());
assertThat(saved.getTitle())
.isEqualTo("09.03.01 — Без профиля — Бакалавриат — 2025-2026");
assertThat(periodsCaptor.getValue())
.extracting(AcademicCalendarPeriod::getCourseNumber)
.containsExactly(1, 2, 3);
assertThat(periodsCaptor.getValue()).allSatisfy(period -> {
assertThat(period.getAcademicCalendar()).isSameAs(saved);
assertThat(period.getStartDate()).isEqualTo(academicYear.getStartDate());
assertThat(period.getEndDate()).isEqualTo(academicYear.getEndDate());
assertThat(period.getActivityType().getCode()).isEqualTo("Т");
assertThat(period.getActivityType().getAllowSchedule()).isTrue();
});
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsCalendarCreationWhenSchedulingTheoryActivityIsMissing() {
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.createCalendar(calendarRequest(4, FORM_ID)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Код активности «Т», разрешающий обычные занятия, не найден");
verify(calendarRepository, never()).saveAndFlush(any());
verify(calendarPeriodRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache();
}
@Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))

View File

@@ -1,5 +1,7 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
@@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -138,6 +142,63 @@ class ScheduleQueryServiceTest {
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate);
}
@Test
void departmentSearchIncludesTeacherLessonsForGroupsFromAnotherDepartment() {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7);
LocalDate lessonDate = LocalDate.of(2026, 5, 4);
StudentGroup externalGroup = group(20);
RenderedLessonDto ownTeacherLesson = mock(RenderedLessonDto.class);
RenderedLessonDto anotherDepartmentLesson = mock(RenderedLessonDto.class);
TeacherDepartmentAssignment ownAssignment = new TeacherDepartmentAssignment();
TeacherDepartmentAssignment anotherAssignment = new TeacherDepartmentAssignment();
when(ownTeacherLesson.teacherId()).thenReturn(10L);
when(ownTeacherLesson.date()).thenReturn(lessonDate);
when(anotherDepartmentLesson.teacherId()).thenReturn(11L);
when(anotherDepartmentLesson.date()).thenReturn(lessonDate);
when(groupRepository.findAll()).thenReturn(List.of(externalGroup));
when(groupLifecycleService.mayHaveScheduleInRange(externalGroup, startDate, endDate)).thenReturn(true);
when(generatorService.buildScheduleForGroups(any(), eq(startDate), eq(endDate)))
.thenReturn(List.of(ownTeacherLesson, anotherDepartmentLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
Set.of(10L, 11L),
startDate,
endDate
)).thenReturn(Map.of(
10L, List.of(ownAssignment),
11L, List.of(anotherAssignment)
));
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(ownAssignment), 7L, lessonDate
)).thenReturn(true);
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(anotherAssignment), 7L, lessonDate
)).thenReturn(false);
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService,
mock(AcademicDateService.class),
teacherDepartmentService
);
var result = service.searchForAggregation(7L, null, startDate, endDate);
assertThat(result).singleElement().satisfies(lesson ->
assertThat(lesson.teacherId()).isEqualTo(10L)
);
verify(groupRepository).findAll();
verify(groupRepository, never()).findByDepartmentId(7L);
}
private StudentGroup group(int id) {
StudentGroup group = new StudentGroup();
group.setId((long) id);

View File

@@ -173,6 +173,28 @@ class TeacherDepartmentServiceTest {
)).contains(newDepartment.getId());
}
@Test
void departmentScopeAcceptsAdditionalAssignmentAlongsidePrimary() {
TeacherDepartmentAssignment primary = assignment(
oldDepartment,
LocalDate.of(2025, 9, 1),
null,
true
);
TeacherDepartmentAssignment additional = assignment(
newDepartment,
LocalDate.of(2026, 1, 1),
null,
false
);
assertThat(service.hasDepartmentAssignmentAtDate(
List.of(primary, additional),
newDepartment.getId(),
TODAY
)).isTrue();
}
private void prepareTransfer(TeacherDepartmentAssignment current) {
when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0));