merge: объединить изменения команды в Redesign

This commit is contained in:
Zuev
2026-08-15 00:25:58 +03:00
22 changed files with 627 additions and 141 deletions

View File

@@ -7,7 +7,6 @@ import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -28,10 +27,6 @@ public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository; 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 SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService; private final AcademicCalendarGridService calendarGridService;
@@ -40,10 +35,6 @@ public class AcademicCalendarController {
public AcademicCalendarController(AcademicCalendarRepository calendarRepository, public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository, SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService, AcademicCalendarGridService calendarGridService,
@@ -51,10 +42,6 @@ public class AcademicCalendarController {
AcademicStructureService academicStructureService) { AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository; this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService; this.calendarGridService = calendarGridService;
@@ -81,10 +68,7 @@ public class AcademicCalendarController {
@PostMapping @PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) { public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try { try {
AcademicCalendar calendar = new AcademicCalendar(); return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); 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) { private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) { if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика"); throw new IllegalArgumentException("Слишком много дисциплин для одного графика");

View File

@@ -53,7 +53,7 @@ public class WorkloadController {
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation( List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate effectiveDepartmentId, null, startDate, endDate
); );
return summarizeTeacherWorkload(lessons, startDate, endDate); return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
} }
@GetMapping("/classrooms") @GetMapping("/classrooms")
@@ -98,15 +98,19 @@ public class WorkloadController {
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons, private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate,
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = Long scopedDepartmentId) {
assignmentsByTeacher(lessons, startDate, endDate); Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
? assignmentsByTeacher(lessons, startDate, endDate)
: Map.of();
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream() Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null) .filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey( lesson -> new TeacherDepartmentKey(
lesson.teacherId(), lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null) scopedDepartmentId != null
? scopedDepartmentId
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
), ),
LinkedHashMap::new, LinkedHashMap::new,
Collectors.toList() 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.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar; 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.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; 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.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
@Service @Service
public class AcademicStructureService { public class AcademicStructureService {
private static final String THEORY_ACTIVITY_CODE = "Т";
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
@@ -48,6 +55,7 @@ public class AcademicStructureService {
public AcademicStructureService(AcademicCalendarRepository calendarRepository, public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
@@ -59,6 +67,7 @@ public class AcademicStructureService {
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarPeriodRepository = calendarPeriodRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarSubjectRepository = calendarSubjectRepository; this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
@@ -82,6 +91,41 @@ public class AcademicStructureService {
return saved; 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 @Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) { public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request); validateCalendarRequest(request);

View File

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

View File

@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
.findFirst(); .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 @Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId, public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request, 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.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; 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.repository.SubjectRepository;
import com.magistr.app.service.AcademicCalendarGridService; import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService; import com.magistr.app.service.AcademicStructureService;
@@ -24,22 +20,17 @@ import org.junit.jupiter.api.Test;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest { class AcademicCalendarControllerTest {
@Test @Test
void createCalendarBuildsTitleWithoutClientValue() { void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class); AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class); AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
AcademicYear year = new AcademicYear(); AcademicYear year = new AcademicYear();
year.setId(1L); year.setId(1L);
@@ -55,26 +46,6 @@ class AcademicCalendarControllerTest {
studyForm.setId(4L); studyForm.setId(4L);
studyForm.setName("Очная"); 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( AcademicCalendarDto request = new AcademicCalendarDto(
null, null,
null, null,
@@ -91,13 +62,31 @@ class AcademicCalendarControllerTest {
null, null,
4 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); var response = controller.createCalendar(request);
assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class); assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
assertThat(((AcademicCalendarDto) response.getBody()).title()) assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027"); .isEqualTo(saved.getTitle());
} }
@Test @Test
@@ -134,10 +123,6 @@ class AcademicCalendarControllerTest {
AcademicCalendarController controller = new AcademicCalendarController( AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository, calendarRepository,
periodRepository, periodRepository,
mock(AcademicYearRepository.class),
mock(SpecialtiesRepository.class),
mock(SpecialtyProfileRepository.class),
mock(EducationFormRepository.class),
mock(SubjectRepository.class), mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class), mock(AcademicCalendarGridService.class),

View File

@@ -82,6 +82,36 @@ class WorkloadControllerTest {
.containsOnly(1L); .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 @Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() { void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class); 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; package com.magistr.app.migration;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
@@ -30,6 +31,7 @@ class AcademicCalendarWeekNumberBaselineIntegrationTest {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();

View File

@@ -4,12 +4,15 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest; import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto; import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar; 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.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.junit.jupiter.MockitoSettings;
@@ -56,6 +60,8 @@ class AcademicStructureServiceTest {
@Mock @Mock
private AcademicCalendarPeriodRepository calendarPeriodRepository; private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock @Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository; private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock @Mock
private AcademicYearRepository academicYearRepository; private AcademicYearRepository academicYearRepository;
@@ -88,6 +94,7 @@ class AcademicStructureServiceTest {
service = new AcademicStructureService( service = new AcademicStructureService(
calendarRepository, calendarRepository,
calendarPeriodRepository, calendarPeriodRepository,
activityTypeRepository,
calendarSubjectRepository, calendarSubjectRepository,
academicYearRepository, academicYearRepository,
specialtiesRepository, specialtiesRepository,
@@ -118,12 +125,54 @@ class AcademicStructureServiceTest {
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L); when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class))) when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .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))) when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class))) when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .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 @Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() { void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID)) when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))

View File

@@ -1,5 +1,7 @@
package com.magistr.app.service; 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.model.StudentGroup;
import com.magistr.app.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository; import com.magistr.app.repository.ScheduleOverrideRepository;
@@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -138,6 +142,63 @@ class ScheduleQueryServiceTest {
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate); 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) { private StudentGroup group(int id) {
StudentGroup group = new StudentGroup(); StudentGroup group = new StudentGroup();
group.setId((long) id); group.setId((long) id);

View File

@@ -173,6 +173,28 @@ class TeacherDepartmentServiceTest {
)).contains(newDepartment.getId()); )).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) { private void prepareTransfer(TeacherDepartmentAssignment current) {
when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class))) when(assignmentRepository.saveAndFlush(any(TeacherDepartmentAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));

View File

@@ -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 блокирует график и повторно проверяет При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности, все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает

View File

@@ -157,6 +157,14 @@ Bearer-токен проверяется на backend. Frontend-скрытие
При создании и изменении backend всегда формирует его из кода специальности, названия При создании и изменении backend всегда формирует его из кода специальности, названия
профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется. профиля, формы обучения и учебного года. Переданное клиентом значение `title` игнорируется.
Новый календарный график сохраняется вместе с начальной сеткой одной транзакцией. Для каждого
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или
полностью настроенные сетки она не изменяет.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного могут пересекаться внутри одного года. Семестр целиком лежит в границах своего учебного
@@ -217,9 +225,13 @@ constraint. При чтении API разворачивает периоды о
замен полный список групп не загружается. замен полный список групп не загружается.
Отчёты workload и свободные аудитории не являются интерактивным поиском и используют Отчёты workload и свободные аудитории не являются интерактивным поиском и используют
отдельный `searchForAggregation`. Он обходит все активные группы разрешённого scope без отдельный `searchForAggregation`. Он обходит все активные группы без лимита 50, но
лимита 50, но переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию переиспользует общую проверку диапазона, снимок точечных изменений, фильтрацию и
и дедупликацию. Поэтому tenant с 51 или 100 группами получает полный агрегат, а защита дедупликацию. Кафедральный scope применяется после генерации и точечных изменений по
любой действующей основной или дополнительной связи преподавателя с кафедрой на дату занятия,
а не по кафедре учебной группы.
Поэтому межкафедральные занятия входят в нагрузку преподавателя его кафедры. Tenant с 51
или 100 группами получает полный агрегат, а защита
широкого `/api/schedule/search` остаётся прежней. широкого `/api/schedule/search` остаётся прежней.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы. Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.

View File

@@ -887,6 +887,10 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
остаются строки старших курсов или дисциплины старших семестров. остаются строки старших курсов или дисциплины старших семестров.
При создании графика backend в той же транзакции добавляет по одному периоду `Т` на весь
учебный год для каждого курса `1..course_count`. Если создание периодов завершается ошибкой,
строка `academic_calendars` также откатывается.
#### `academic_calendar_periods` — Периоды активности календарного графика #### `academic_calendar_periods` — Периоды активности календарного графика
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -1185,18 +1189,18 @@ CHECK фиксирует допустимую форму каждого типа
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет |
### Этап разработки ### Этап разработки
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются
`V1__init.sql`. отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1. непосредственно в V1.
Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы
предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема.
ошибку проверки.
### Полный сброс БД (локально) ### Полный сброс БД (локально)

View File

@@ -258,12 +258,11 @@ public class AbsenceController {
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
Текущее состояние проекта — единый baseline `V1__init.sql`: по прямому решению владельца `V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено
содержимое прежних V2–V7 объединено в V1, клиентских tenant-БД нет. До отдельного решения о содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам,
фиксации baseline новые DB-инварианты добавляются в V1 и проверяются на полностью чистой БД. пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только
Таблицы отсутствий, решений по заменам, пожеланий и заявок преподавателей из последних отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
вариантов V2 и V3 также перенесены в этот единый baseline; отдельных файлов V2/V3 в заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
проекте нет.
### Применение ### Применение

View File

@@ -64,6 +64,7 @@ frontend/
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone │ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO │ │ ├── date-input.js # Маска ДД.ММ.ГГГГ и преобразование дат в ISO
│ │ ├── utils.js # Утилиты │ │ ├── utils.js # Утилиты
│ │ ├── schedule-period.js # Выбор ближайшего учебного периода для расписаний и нагрузки
│ │ └── views/ # Модули представлений │ │ └── views/ # Модули представлений
│ │ ├── dashboard.js # Дашборд │ │ ├── dashboard.js # Дашборд
│ │ ├── users.js # Управление пользователями │ │ ├── users.js # Управление пользователями
@@ -195,7 +196,7 @@ frontend/
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся. - Вкладка `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`. - Вкладка `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-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки. - Вкладка `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` и видеть статусы собственных заявок в таблице. - Вкладка `department-workspace` позволяет кафедре добавить существующего активного преподавателя на свою кафедру через `/api/department/teachers/{teacherId}/assignments`, отправить заявку на нового преподавателя через `/api/department/teacher-requests` и видеть статусы собственных заявок в таблице.
- Вкладка `teacher-absences` доступна администратору, учебному отделу и кафедре. Верхний - Вкладка `teacher-absences` доступна администратору, учебному отделу и кафедре. Верхний
командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и командный блок показывает очередь инцидентов, форма регистрирует преподавателя, период и
@@ -226,7 +227,7 @@ frontend/
при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор при ошибках. Кнопки версий передают их ID одноразово через `localStorage` и открывают конструктор
или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной. или анализ качества в нужном контексте; архивную публикацию можно восстановить с причиной.
- Компоновка `department-workspace` использует собственные CSS-сетки `department-workspace-filter-grid` и `department-workspace-actions-grid`: фильтры периода отделены от сеток расписания, загрузка дисциплин занимает широкую колонку, формы преподавателей выравниваются справа, а списки и таблицы идут полноширинными блоками ниже. - Компоновка `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`. - Для `ADMIN` и `EDUCATION_OFFICE` карточка занятия содержит кнопку `Изменить`, а уже изменённая пара — индикатор разовой правки. Справа открывается полупрозрачная боковая панель с размытием содержимого под ней; внешний затемнённый слой также размывает страницу, а на мобильном устройстве панель занимает весь экран. Режим `Редактирование` сравнивает `Было по правилу / Станет`, позволяет изменить дату, эффективный временной слот, преподавателя, аудиторию, формат и комментарий, отменить занятие или удалить override через `Вернуть по правилу`. Селект аудитории получает записи из `/api/classrooms`, но показывает только поле `name`, без корпуса и этажа. По умолчанию выводятся семь дней исходной недели; кнопка `Выбрать другую дату` раскрывает календарь всего семестра, где неучебные даты отключены. После смены даты загружается эффективная сетка дня: сначала выбирается тот же ID слота, затем совпадающий интервал, иначе требуется ручной выбор. Дата или время формируют `MOVE`, а только преподаватель, аудитория или формат — `REPLACE`.
- Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.

View File

@@ -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('-');
}

View File

@@ -1,5 +1,6 @@
import { api, getSession } from '../api.js'; import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { defaultSchedulePeriod } from '../schedule-period.js';
const ROLE_DEPARTMENT = 'DEPARTMENT'; const ROLE_DEPARTMENT = 'DEPARTMENT';
@@ -72,6 +73,7 @@ export async function initDepartmentWorkspace() {
departmentField.hidden = true; departmentField.hidden = true;
departmentSelect.value = userDepartmentId || ''; departmentSelect.value = userDepartmentId || '';
} }
await selectDefaultWorkloadPeriod(startInput, endInput);
await loadAllTeachers(); await loadAllTeachers();
renderImportRows([{ code: '', name: '' }]); renderImportRows([{ code: '', name: '' }]);
await loadWorkspace(); 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) { async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments'); const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру'); fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');

View File

@@ -1,5 +1,6 @@
import { api, getSession } from '../api.js'; import { api, getSession } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { preferredScheduleSemester } from '../schedule-period.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js'; import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
const ROLE_DEPARTMENT = 'DEPARTMENT'; const ROLE_DEPARTMENT = 'DEPARTMENT';
@@ -321,17 +322,7 @@ export async function initScheduleView() {
function buildSectionContext(target) { function buildSectionContext(target) {
const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : ''; const selectedGroupId = target === TARGET_GROUPS ? normalizeId(valueOf('schedule-view-group')) : '';
const selectedDepartmentId = role === ROLE_DEPARTMENT const allowedGroupIds = allowedGroupIdsForScheduleTarget(target, selectedGroupId);
? 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)));
}
return { return {
selectedGroupId, selectedGroupId,
selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '', selectedGroupName: selectedGroupId ? groupNameById(selectedGroupId) : '',
@@ -905,8 +896,11 @@ export async function initScheduleView() {
function selectCurrentSemester() { function selectCurrentSemester() {
if (!semesterSelect) return; if (!semesterSelect) return;
const defaultSemester = findDefaultSemester(state.dictionaries.semesters, dateInput.value); const preferredSemester = preferredScheduleSemester(
semesterSelect.value = defaultSemester ? String(defaultSemester.id) : ''; state.dictionaries.semesters,
dateInput.value
);
semesterSelect.value = preferredSemester ? String(preferredSemester.id) : '';
synchronizeDateWithSemester(); synchronizeDateWithSemester();
updatePeriodLabel(); updatePeriodLabel();
} }
@@ -1064,6 +1058,12 @@ function buildScheduleSections(lessons, splitType, context) {
.sort((a, b) => naturalCompare(a.title, b.title)); .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) { function splitEntitiesForLesson(lesson, splitType, context) {
if (splitType === TARGET_TEACHERS) { if (splitType === TARGET_TEACHERS) {
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
@@ -1211,11 +1211,6 @@ export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null; 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) { export function scheduleRangeForSemester(selectedDate, semester) {
if (semester?.startDate && semester?.endDate) { if (semester?.startDate && semester?.endDate) {
return { return {

View File

@@ -2,12 +2,16 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
findDefaultSemester, allowedGroupIdsForScheduleTarget,
findSemesterForDate, findSemesterForDate,
formatLessonWeeksText, formatLessonWeeksText,
scheduleRangeForSemester, scheduleRangeForSemester,
semesterOptionLabel semesterOptionLabel
} from '../admin/js/views/schedule-view.js'; } from '../admin/js/views/schedule-view.js';
import {
defaultSchedulePeriod,
preferredScheduleSemester
} from '../admin/js/schedule-period.js';
const autumnSemester = { const autumnSemester = {
id: 11, id: 11,
@@ -33,10 +37,6 @@ test('семестр определяется по выбранной дате
assert.equal(findSemesterForDate(semesters, '2026-07-01'), null); assert.equal(findSemesterForDate(semesters, '2026-07-01'), null);
}); });
test('вне учебного периода по умолчанию выбирается последний доступный семестр', () => {
assert.equal(findDefaultSemester([springSemester, autumnSemester], '2026-08-14')?.id, 12);
});
test('при выборе семестра диапазон охватывает его целиком', () => { test('при выборе семестра диапазон охватывает его целиком', () => {
assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), { assert.deepEqual(scheduleRangeForSemester('2026-07-21', autumnSemester), {
startDate: '2025-09-01', startDate: '2025-09-01',
@@ -62,3 +62,32 @@ test('название семестра содержит учебный год,
'2025/2026 · Весенний семестр · 09.02.2026 — 30.06.2026' '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'
});
});