merge: объединить изменения команды в Redesign
This commit is contained in:
@@ -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("Слишком много дисциплин для одного графика");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 блокирует график и повторно проверяет
|
||||||
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
|
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
|
||||||
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
||||||
|
|||||||
@@ -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`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
|
||||||
|
|||||||
@@ -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-схема.
|
||||||
ошибку проверки.
|
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
проекте нет.
|
|
||||||
|
|
||||||
### Применение
|
### Применение
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
43
frontend/admin/js/schedule-period.js
Normal file
43
frontend/admin/js/schedule-period.js
Normal 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('-');
|
||||||
|
}
|
||||||
@@ -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, 'Выберите кафедру');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user