diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 56fa24c..728facc 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -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 rows) { if (rows.size() > 1000) { throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); diff --git a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java index 8246c7e..ff663d4 100644 --- a/backend/src/main/java/com/magistr/app/controller/WorkloadController.java +++ b/backend/src/main/java/com/magistr/app/controller/WorkloadController.java @@ -53,7 +53,7 @@ public class WorkloadController { List 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 summarizeTeacherWorkload(List lessons, LocalDate startDate, - LocalDate endDate) { - Map> assignmentsByTeacher = - assignmentsByTeacher(lessons, startDate, endDate); + LocalDate endDate, + Long scopedDepartmentId) { + Map> assignmentsByTeacher = scopedDepartmentId == null + ? assignmentsByTeacher(lessons, startDate, endDate) + : Map.of(); Map> 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() diff --git a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java index eafd172..ce7571e 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java @@ -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 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); diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index ed7ec7a..fd5fb17 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -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 lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream() + List effectiveLessons = applyOverrides(generatedLessons, null, overrideSnapshot); + Map> assignmentsByTeacher = + assignmentsByTeacher(effectiveLessons, departmentId, startDate, endDate); + List 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> assignmentsByTeacher( + List lessons, + Long departmentId, + LocalDate startDate, + LocalDate endDate + ) { + if (departmentId == null) { + return Map.of(); + } + if (teacherDepartmentService == null) { + throw new IllegalStateException("Сервис кафедральных связей преподавателей недоступен"); + } + Set teacherIds = lessons.stream() + .map(RenderedLessonDto::teacherId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return teacherDepartmentService.findAssignmentsForTeachersBetween( + teacherIds, + startDate, + endDate + ); + } + private void addOverrideSourceOccurrences( List generatedLessons, Collection overrideSnapshot, @@ -395,12 +441,10 @@ public class ScheduleQueryService { .map(List::of) .orElse(List.of()); } - List groups; - if (departmentId != null) { - groups = groupRepository.findByDepartmentId(departmentId); - } else { - groups = groupRepository.findAll(); - } + // Кафедральный scope определяется по преподавателю на дату занятия, + // а не по выпускающей кафедре учебной группы. Фильтрация выполняется + // после генерации и применения точечных изменений. + List groups = groupRepository.findAll(); groups = groups.stream() .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate)) .toList(); diff --git a/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java b/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java index 118af72..bca45a0 100644 --- a/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java +++ b/backend/src/main/java/com/magistr/app/service/TeacherDepartmentService.java @@ -136,6 +136,23 @@ public class TeacherDepartmentService { .findFirst(); } + public boolean hasDepartmentAssignmentAtDate( + Collection 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, diff --git a/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql b/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql new file mode 100644 index 0000000..e908a7c --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql @@ -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 +); diff --git a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java index 26ce688..9ec68ac 100644 --- a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java @@ -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), diff --git a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java index 628ffb8..e1967e1 100644 --- a/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/WorkloadControllerTest.java @@ -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 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); diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java new file mode 100644 index 0000000..62c3f56 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java @@ -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) { + } +} diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberBaselineIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberBaselineIntegrationTest.java index 026294c..8014597 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberBaselineIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarWeekNumberBaselineIntegrationTest.java @@ -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(); diff --git a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java index 37b536f..8b86844 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java @@ -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> 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)) diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java index 6a0bb6f..318d25d 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -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); diff --git a/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java b/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java index 69d529e..965e279 100644 --- a/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/TeacherDepartmentServiceTest.java @@ -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)); diff --git a/docs/API.md b/docs/API.md index 5882a19..e3b63cc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -593,6 +593,14 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`. Отдельного пользовательского названия у календарного графика нет. +`POST /api/admin/academic-calendars` создаёт график и начальную сетку одной транзакцией. +Для каждого курса из диапазона `1..courseCount` backend сохраняет один период с кодом `Т` +на весь учебный год, включая обе его границы. Поэтому обычные занятия разрешены сразу после +создания и назначения графика группе; пользователь затем заменяет нужные даты на сессию, +каникулы, практику и другие активности. Если код `Т` отсутствует или не разрешает обычное +расписание, запрос возвращает `400`, а график не создаётся. Обновление существующего графика +через `PUT` не пересоздаёт и не перезаписывает его периоды. + При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности, профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 121f66b..c692561 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -157,6 +157,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие При создании и изменении backend всегда формирует его из кода специальности, названия профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется. +Новый календарный график сохраняется вместе с начальной сеткой одной транзакцией. Для каждого +курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом, +дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения: +сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего +графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет +такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или +полностью настроенные сетки она не изменяет. + Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного @@ -217,9 +225,13 @@ constraint. При чтении API разворачивает периоды о замен полный список групп не загружается. Отчёты workload и свободные аудитории не являются интерактивным поиском и используют -отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без -лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию -и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита +отдельный `searchForAggregation`. Он обходит все активные группы без лимита 50, но +переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию и +дедупликацию. Кафедральный scope применяется после генерации и точечных изменений по +любой действующей основной или дополнительной связи преподавателя с кафедрой на дату занятия, +а не по кафедре учебной группы. +Поэтому межкафедральные занятия входят в нагрузку преподавателя его кафедры. Tenant с 51 +или 100 группами получает полный агрегат, а защита широкого `/api/schedule/search` остаётся прежней. Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 38c5fb5..d0098ff 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -887,6 +887,10 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке остаются строки старших курсов или дисциплины старших семестров. +При создании графика backend в той же транзакции добавляет по одному периоду `Т` на весь +учебный год для каждого курса `1..course_count`. Если создание периодов завершается ошибкой, +строка `academic_calendars` также откатывается. + #### `academic_calendar_periods` — Периоды активности календарного графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -1185,18 +1189,18 @@ CHECK фиксирует допустимую форму каждого типа | Файл | Описание | |------|----------| | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | +| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет | ### Этап разработки Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца -проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл -`V1__init.sql`. +проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются +отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`. Интервальное хранение активностей и правильная нумерация недель календарного графика входят непосредственно в V1. -Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где -предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет -ошибку проверки. +Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы +baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема. ### Полный сброс БД (локально) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0b3947e..849a334 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -258,12 +258,11 @@ public class AbsenceController { Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. -Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца -содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о -фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД. -Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних -вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в -проекте нет. +`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено +содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам, +пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только +отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql` +заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1. ### Применение diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 601add8..a5d8ced 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -64,6 +64,7 @@ frontend/ │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO │ │ ├── utils.js # Утилиты +│ │ ├── schedule-period.js # Выбор ближайшего учебного периода для расписаний и нагрузки │ │ └── views/ # Модули представлений │ │ ├── dashboard.js # Дашборд │ │ ├── users.js # Управление пользователями @@ -195,7 +196,7 @@ frontend/ - Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`. - Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов в скрытом поле с `autocomplete="new-password"`, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. -- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. +- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Между семестрами период автоматически переносится на первые две недели ближайшего будущего семестра, чтобы будущие правила не выглядели как нулевая нагрузка; при недоступности справочника остаётся диапазон от текущей даты. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице. - Вкладка `teacher-absences` доступна администратору, учебному отделу и кафедре. Верхний командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и @@ -226,7 +227,7 @@ frontend/ при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной. - Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. -- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. При выбранном семестре frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу; без семестра сохраняется двухнедельный режим по текущей дате. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней. +- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; основная кнопка `Показать` расположена в заголовке блока параметров, а пустое состояние таблицы с подсказкой об обновлении содержит дополнительную кнопку `Показать расписание`. В дополнительных фильтрах доступен семестр из справочника `/api/schedule/semesters`, предназначенного только для чтения. При загрузке автоматически выбирается текущий семестр, а между учебными периодами — ближайший будущий; если будущего нет, используется последний завершённый. Для выбранного семестра frontend запрашивает весь период от его первой до последней даты и собирает повторяющиеся занятия в одну семестровую матрицу; режим «По текущей дате» сохраняет двухнедельный диапазон. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается действующей кафедральной связью преподавателя на дату занятия; группы другой кафедры не отбрасываются, если занятие ведёт преподаватель текущей кафедры. Преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Каждая карточка занятия явно показывает фактические границы недель: `(с 1 по 18 нед.)` либо `(7 нед.)`. На мобильной ширине вместо широкой матрицы показывается один день активного расписания с переключателем дней. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. diff --git a/frontend/admin/js/schedule-period.js b/frontend/admin/js/schedule-period.js new file mode 100644 index 0000000..98d3e33 --- /dev/null +++ b/frontend/admin/js/schedule-period.js @@ -0,0 +1,43 @@ +export function preferredScheduleSemester(semesters, date) { + const items = (semesters || []) + .filter(semester => semester?.startDate && semester?.endDate); + const current = items.find(semester => + semester.startDate <= date && semester.endDate >= date + ); + if (current) return current; + + const upcoming = items + .filter(semester => semester.startDate > date) + .sort((first, second) => first.startDate.localeCompare(second.startDate)); + if (upcoming.length) return upcoming[0]; + + return items + .filter(semester => semester.endDate < date) + .sort((first, second) => second.endDate.localeCompare(first.endDate))[0] || null; +} + +export function defaultSchedulePeriod(semesters, date, durationDays = 14) { + const semester = preferredScheduleSemester(semesters, date); + const startDate = semester && (date < semester.startDate || date > semester.endDate) + ? semester.startDate + : date; + const calculatedEnd = addIsoDays(startDate, Math.max(1, durationDays) - 1); + return { + semester, + startDate, + endDate: semester && calculatedEnd > semester.endDate + ? semester.endDate + : calculatedEnd + }; +} + +function addIsoDays(value, days) { + const [year, month, day] = String(value).split('-').map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() + days); + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); +} diff --git a/frontend/admin/js/views/department-workspace.js b/frontend/admin/js/views/department-workspace.js index b34a134..2e301e2 100644 --- a/frontend/admin/js/views/department-workspace.js +++ b/frontend/admin/js/views/department-workspace.js @@ -1,5 +1,6 @@ import { api, getSession } from '../api.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; +import { defaultSchedulePeriod } from '../schedule-period.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; @@ -72,6 +73,7 @@ export async function initDepartmentWorkspace() { departmentField.hidden = true; departmentSelect.value = userDepartmentId || ''; } + await selectDefaultWorkloadPeriod(startInput, endInput); await loadAllTeachers(); renderImportRows([{ code: '', name: '' }]); await loadWorkspace(); @@ -475,6 +477,20 @@ export async function initDepartmentWorkspace() { } } +async function selectDefaultWorkloadPeriod(startInput, endInput) { + try { + const semesters = await api.get('/api/schedule/semesters'); + const period = defaultSchedulePeriod( + semesters, + formatLocalDate(new Date()) + ); + startInput.value = period.startDate; + endInput.value = period.endDate; + } catch (_error) { + // Текущий диапазон остаётся безопасным fallback, если справочник семестров недоступен. + } +} + async function loadDepartments(role, userDepartmentId) { const departments = await api.get('/api/departments'); fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру'); diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 1a6d612..66ef3fc 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -1,5 +1,6 @@ import { api, getSession } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; +import { preferredScheduleSemester } from '../schedule-period.js'; import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js'; const ROLE_DEPARTMENT = 'DEPARTMENT'; @@ -321,17 +322,7 @@ export async function initScheduleView() { function buildSectionContext(target) { const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; - const selectedDepartmentId = role === ROLE_DEPARTMENT - ? userDepartmentId - : (target === TARGET_DEPARTMENTS ? normalizeId(valueOf('schedule-view-department')) : ''); - const allowedGroupIds = new Set(); - if (selectedGroupId) { - allowedGroupIds.add(selectedGroupId); - } else if (selectedDepartmentId) { - state.dictionaries.groups - .filter(group => String(group.departmentId) === String(selectedDepartmentId)) - .forEach(group => allowedGroupIds.add(String(group.id))); - } + const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId); return { selectedGroupId, selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', @@ -905,8 +896,11 @@ export async function initScheduleView() { function selectCurrentSemester() { if (!semesterSelect) return; - const defaultSemester = findDefaultSemester(state.dictionaries.semesters, dateInput.value); - semesterSelect.value = defaultSemester ? String(defaultSemester.id) : ''; + const preferredSemester = preferredScheduleSemester( + state.dictionaries.semesters, + dateInput.value + ); + semesterSelect.value = preferredSemester ? String(preferredSemester.id) : ''; synchronizeDateWithSemester(); updatePeriodLabel(); } @@ -1064,6 +1058,12 @@ function buildScheduleSections(lessons, splitType, context) { .sort((a, b) => naturalCompare(a.title, b.title)); } +export function allowedGroupIdsForScheduleTarget(target, selectedGroupId) { + return target === TARGET_GROUPS && selectedGroupId + ? new Set([String(selectedGroupId)]) + : new Set(); +} + function splitEntitiesForLesson(lesson, splitType, context) { if (splitType === TARGET_TEACHERS) { return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; @@ -1211,11 +1211,6 @@ export function findSemesterForDate(semesters, date) { return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null; } -export function findDefaultSemester(semesters, date) { - const availableSemesters = semesters || []; - return findSemesterForDate(availableSemesters, date) || availableSemesters[0] || null; -} - export function scheduleRangeForSemester(selectedDate, semester) { if (semester?.startDate && semester?.endDate) { return { diff --git a/frontend/tests/schedule-view-semesters.test.mjs b/frontend/tests/schedule-view-semesters.test.mjs index 4353464..7307fd9 100644 --- a/frontend/tests/schedule-view-semesters.test.mjs +++ b/frontend/tests/schedule-view-semesters.test.mjs @@ -2,12 +2,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - findDefaultSemester, + allowedGroupIdsForScheduleTarget, findSemesterForDate, formatLessonWeeksText, scheduleRangeForSemester, semesterOptionLabel } from '../admin/js/views/schedule-view.js'; +import { + defaultSchedulePeriod, + preferredScheduleSemester +} from '../admin/js/schedule-period.js'; const autumnSemester = { id: 11, @@ -33,10 +37,6 @@ test('семестр определяется по выбранной дате assert.equal(findSemesterForDate(semesters, '2026-07-01'), null); }); -test('вне учебного периода по умолчанию выбирается последний доступный семестр', () => { - assert.equal(findDefaultSemester([springSemester, autumnSemester], '2026-08-14')?.id, 12); -}); - test('при выборе семестра диапазон охватывает его целиком', () => { assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), { startDate: '2025-09-01', @@ -62,3 +62,32 @@ test('название семестра содержит учебный год, '2025/2026 · Весенний семестр · 09.02.2026 — 30.06.2026' ); }); + +test('кафедральное расписание не отбрасывает группы других кафедр', () => { + assert.deepEqual( + [...allowedGroupIdsForScheduleTarget('departments', '')], + [] + ); + assert.deepEqual( + [...allowedGroupIdsForScheduleTarget('groups', 15)], + ['15'] + ); +}); + +test('между семестрами выбирается ближайший будущий период с занятиями', () => { + const nextAutumnSemester = { + id: 13, + academicYearTitle: '2026/2027', + semesterType: 'autumn', + startDate: '2026-09-01', + endDate: '2027-01-31' + }; + const semesters = [autumnSemester, springSemester, nextAutumnSemester]; + + assert.equal(preferredScheduleSemester(semesters, '2026-08-12')?.id, 13); + assert.deepEqual(defaultSchedulePeriod(semesters, '2026-08-12'), { + semester: nextAutumnSemester, + startDate: '2026-09-01', + endDate: '2026-09-14' + }); +});