исправил логику курса групп
This commit is contained in:
@@ -7,14 +7,15 @@ import com.magistr.app.dto.GroupResponse;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.*;
|
||||
import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import com.magistr.app.service.StudentGroupLifecycleService;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -22,6 +23,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -41,6 +43,7 @@ public class GroupController {
|
||||
private final AcademicCalendarRepository academicCalendarRepository;
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
|
||||
public GroupController(GroupRepository groupRepository,
|
||||
EducationFormRepository educationFormRepository,
|
||||
@@ -49,7 +52,8 @@ public class GroupController {
|
||||
AcademicYearRepository academicYearRepository,
|
||||
AcademicCalendarRepository academicCalendarRepository,
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.educationFormRepository = educationFormRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
@@ -58,6 +62,7 @@ public class GroupController {
|
||||
this.academicCalendarRepository = academicCalendarRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -67,7 +72,9 @@ public class GroupController {
|
||||
try {
|
||||
List<StudentGroup> groups = includeArchived
|
||||
? groupRepository.findAll()
|
||||
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
: groupRepository.findAll().stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.toList();
|
||||
|
||||
List<GroupResponse> response = groups.stream()
|
||||
.map(this::mapToResponse)
|
||||
@@ -84,7 +91,9 @@ public class GroupController {
|
||||
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
|
||||
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
|
||||
try {
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
List<StudentGroup> groups = groupRepository.findByDepartmentId(departmentId).stream()
|
||||
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
|
||||
.toList();
|
||||
|
||||
if(groups.isEmpty()) {
|
||||
logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
|
||||
@@ -264,6 +273,8 @@ public class GroupController {
|
||||
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
|
||||
Speciality speciality = group.getSpeciality();
|
||||
SpecialtyProfile profile = group.getSpecialtyProfile();
|
||||
LocalDate today = LocalDate.now();
|
||||
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
|
||||
return new GroupResponse(
|
||||
group.getId(),
|
||||
group.getName(),
|
||||
@@ -278,7 +289,11 @@ public class GroupController {
|
||||
speciality.getSpecialityCode(),
|
||||
speciality.getSpecialityName(),
|
||||
profile.getId(),
|
||||
profile.getName()
|
||||
profile.getName(),
|
||||
group.getStatus(),
|
||||
groupLifecycleService.isAvailableForSelection(group, today),
|
||||
studyState.name(),
|
||||
studyState.getLabel()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ public class GroupResponse {
|
||||
private String specialtyName;
|
||||
private Long specialtyProfileId;
|
||||
private String specialtyProfileName;
|
||||
private String status;
|
||||
private Boolean active;
|
||||
private String studyState;
|
||||
private String studyStateName;
|
||||
|
||||
public GroupResponse(Long id,
|
||||
String name,
|
||||
@@ -33,7 +37,11 @@ public class GroupResponse {
|
||||
String specialtyCode,
|
||||
String specialtyName,
|
||||
Long specialtyProfileId,
|
||||
String specialtyProfileName) {
|
||||
String specialtyProfileName,
|
||||
String status,
|
||||
Boolean active,
|
||||
String studyState,
|
||||
String studyStateName) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.groupSize = groupSize;
|
||||
@@ -48,6 +56,10 @@ public class GroupResponse {
|
||||
this.specialtyName = specialtyName;
|
||||
this.specialtyProfileId = specialtyProfileId;
|
||||
this.specialtyProfileName = specialtyProfileName;
|
||||
this.status = status;
|
||||
this.active = active;
|
||||
this.studyState = studyState;
|
||||
this.studyStateName = studyStateName;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -109,4 +121,20 @@ public class GroupResponse {
|
||||
public String getSpecialtyProfileName() {
|
||||
return specialtyProfileName;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Boolean getActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public String getStudyState() {
|
||||
return studyState;
|
||||
}
|
||||
|
||||
public String getStudyStateName() {
|
||||
return studyStateName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ public abstract class LifecycleEntity {
|
||||
if (date == null) {
|
||||
return isActiveRecord();
|
||||
}
|
||||
if (!isActiveRecord()) {
|
||||
if (isArchivedRecord() && activeTo == null) {
|
||||
return false;
|
||||
}
|
||||
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum StudentGroupStudyState {
|
||||
ACTIVE("Активна"),
|
||||
NOT_STARTED("Обучение не началось"),
|
||||
GRADUATED("Завершила обучение"),
|
||||
INACTIVE("Неактивна"),
|
||||
ARCHIVED("Архив");
|
||||
|
||||
private final String label;
|
||||
|
||||
StudentGroupStudyState(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.RenderedLessonDto;
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.ScheduleOverride;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
@@ -9,6 +8,7 @@ import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -16,17 +16,21 @@ import java.util.stream.Collectors;
|
||||
public class ScheduleQueryService {
|
||||
|
||||
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
|
||||
private static final long MAX_RANGE_DAYS = 120;
|
||||
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
private final GroupRepository groupRepository;
|
||||
private final ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
private final StudentGroupLifecycleService groupLifecycleService;
|
||||
|
||||
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
|
||||
GroupRepository groupRepository,
|
||||
ScheduleOverrideRepository scheduleOverrideRepository) {
|
||||
ScheduleOverrideRepository scheduleOverrideRepository,
|
||||
StudentGroupLifecycleService groupLifecycleService) {
|
||||
this.scheduleGeneratorService = scheduleGeneratorService;
|
||||
this.groupRepository = groupRepository;
|
||||
this.scheduleOverrideRepository = scheduleOverrideRepository;
|
||||
this.groupLifecycleService = groupLifecycleService;
|
||||
}
|
||||
|
||||
public List<RenderedLessonDto> search(Long groupId,
|
||||
@@ -39,11 +43,12 @@ public class ScheduleQueryService {
|
||||
String parity,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
validateRange(startDate, endDate);
|
||||
List<RenderedLessonDto> generatedLessons;
|
||||
if (groupId == null && departmentId == null && teacherId != null) {
|
||||
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
|
||||
} else {
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId);
|
||||
List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
|
||||
generatedLessons = groups.stream()
|
||||
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
|
||||
.toList();
|
||||
@@ -139,18 +144,35 @@ public class ScheduleQueryService {
|
||||
return scheduleRuleSlotId + ":" + date;
|
||||
}
|
||||
|
||||
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId) {
|
||||
private void validateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalArgumentException("Дата начала и дата окончания обязательны");
|
||||
}
|
||||
if (endDate.isBefore(startDate)) {
|
||||
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
|
||||
}
|
||||
if (ChronoUnit.DAYS.between(startDate, endDate) > MAX_RANGE_DAYS) {
|
||||
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
|
||||
}
|
||||
}
|
||||
|
||||
private List<StudentGroup> resolveGroups(Long groupId, Long departmentId, LocalDate startDate, LocalDate endDate) {
|
||||
if (groupId != null) {
|
||||
return groupRepository.findById(groupId)
|
||||
.filter(StudentGroup::isActiveRecord)
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.map(List::of)
|
||||
.orElse(List.of());
|
||||
}
|
||||
List<StudentGroup> groups;
|
||||
if (departmentId != null) {
|
||||
return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED);
|
||||
groups = groupRepository.findByDepartmentId(departmentId);
|
||||
} else {
|
||||
groups = groupRepository.findAll();
|
||||
}
|
||||
List<StudentGroup> groups = groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED);
|
||||
if (groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
groups = groups.stream()
|
||||
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
|
||||
.toList();
|
||||
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
|
||||
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
|
||||
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StudentGroupLifecycleService {
|
||||
|
||||
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||
|
||||
public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) {
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
}
|
||||
|
||||
public boolean isAvailableForSelection(StudentGroup group, LocalDate date) {
|
||||
return group != null
|
||||
&& group.isActiveRecord()
|
||||
&& group.isActiveOn(date)
|
||||
&& !isGraduatedOn(group, date);
|
||||
}
|
||||
|
||||
public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) {
|
||||
if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||
return false;
|
||||
}
|
||||
Optional<Integer> courseLimit = resolveCourseLimit(group);
|
||||
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||
if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) {
|
||||
if (group == null || group.isArchivedRecord()) {
|
||||
return StudentGroupStudyState.ARCHIVED;
|
||||
}
|
||||
if (!group.isActiveOn(date)) {
|
||||
return StudentGroupStudyState.INACTIVE;
|
||||
}
|
||||
if (isGraduatedOn(group, date)) {
|
||||
return StudentGroupStudyState.GRADUATED;
|
||||
}
|
||||
if (!hasStarted(group, date)) {
|
||||
return StudentGroupStudyState.NOT_STARTED;
|
||||
}
|
||||
return StudentGroupStudyState.ACTIVE;
|
||||
}
|
||||
|
||||
private boolean hasStarted(StudentGroup group, LocalDate date) {
|
||||
return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0;
|
||||
}
|
||||
|
||||
private boolean isGraduatedOn(StudentGroup group, LocalDate date) {
|
||||
return isGraduatedOn(group, date, resolveCourseLimit(group));
|
||||
}
|
||||
|
||||
private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional<Integer> courseLimit) {
|
||||
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
|
||||
return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
|
||||
}
|
||||
|
||||
private Optional<Integer> resolveCourseLimit(StudentGroup group) {
|
||||
if (group.getId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream()
|
||||
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||
.filter(calendar -> calendar.getCourseCount() != null)
|
||||
.map(calendar -> calendar.getCourseCount())
|
||||
.max(Comparator.naturalOrder());
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,28 @@ import java.time.LocalDate;
|
||||
public class CourseAndSemesterCalculator {
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy) {
|
||||
LocalDate now = LocalDate.now();
|
||||
int currentYear = now.getYear();
|
||||
int currentMonth = now.getMonthValue();
|
||||
|
||||
if (currentMonth >= 9) {
|
||||
return currentYear - yearStartStudy + 1;
|
||||
} else {
|
||||
return currentYear - yearStartStudy;
|
||||
return getActualCourse(yearStartStudy, LocalDate.now());
|
||||
}
|
||||
|
||||
public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
|
||||
int currentYear = date.getYear();
|
||||
int currentMonth = date.getMonthValue();
|
||||
int course = currentMonth >= 9
|
||||
? currentYear - yearStartStudy + 1
|
||||
: currentYear - yearStartStudy;
|
||||
return Math.max(0, course);
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy) {
|
||||
int course = getActualCourse(yearStartStudy);
|
||||
int currentMonth = LocalDate.now().getMonthValue();
|
||||
return getActualSemester(yearStartStudy, LocalDate.now());
|
||||
}
|
||||
|
||||
public static int getActualSemester(Integer yearStartStudy, LocalDate date) {
|
||||
int course = getActualCourse(yearStartStudy, date);
|
||||
if (course == 0) {
|
||||
return 0;
|
||||
}
|
||||
int currentMonth = date.getMonthValue();
|
||||
|
||||
if (currentMonth <= 1 || currentMonth >= 9) {
|
||||
return course * 2 - 1;
|
||||
@@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator {
|
||||
|
||||
public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
|
||||
int recordYear = Integer.parseInt(periodYears.substring(0, 4));
|
||||
return recordYear - yearStartStudy + 1;
|
||||
return Math.max(0, recordYear - yearStartStudy + 1);
|
||||
}
|
||||
|
||||
public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
|
||||
int course = getFutureCourse(yearStartStudy, periodYears);
|
||||
if (course == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (semesterType == SemesterType.autumn) {
|
||||
return course * 2 - 1;
|
||||
|
||||
@@ -16,6 +16,16 @@ class LifecycleEntityTest {
|
||||
assertThat(subject.isActiveOn(LocalDate.now())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedEntityRemainsActiveForHistoricalDatesBeforeActiveTo() {
|
||||
Subject subject = new Subject();
|
||||
subject.setStatus(LifecycleEntity.STATUS_ARCHIVED);
|
||||
subject.setActiveTo(LocalDate.of(2026, 5, 10));
|
||||
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 9))).isTrue();
|
||||
assertThat(subject.isActiveOn(LocalDate.of(2026, 5, 11))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeEntityUsesDateRange() {
|
||||
Subject subject = new Subject();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.LifecycleEntity;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.repository.GroupRepository;
|
||||
import com.magistr.app.repository.ScheduleOverrideRepository;
|
||||
@@ -22,14 +21,17 @@ class ScheduleQueryServiceTest {
|
||||
@Test
|
||||
void rejectsTooBroadGroupSearch() {
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
when(groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED))
|
||||
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
when(groupRepository.findAll())
|
||||
.thenReturn(IntStream.rangeClosed(1, 51)
|
||||
.mapToObj(this::group)
|
||||
.toList());
|
||||
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
mock(ScheduleGeneratorService.class),
|
||||
groupRepository,
|
||||
mock(ScheduleOverrideRepository.class)
|
||||
mock(ScheduleOverrideRepository.class),
|
||||
groupLifecycleService
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> service.search(null, null, null, null,
|
||||
@@ -45,7 +47,13 @@ class ScheduleQueryServiceTest {
|
||||
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
|
||||
GroupRepository groupRepository = mock(GroupRepository.class);
|
||||
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
|
||||
ScheduleQueryService service = new ScheduleQueryService(generatorService, groupRepository, overrideRepository);
|
||||
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
|
||||
ScheduleQueryService service = new ScheduleQueryService(
|
||||
generatorService,
|
||||
groupRepository,
|
||||
overrideRepository,
|
||||
groupLifecycleService
|
||||
);
|
||||
LocalDate startDate = LocalDate.of(2026, 5, 1);
|
||||
LocalDate endDate = LocalDate.of(2026, 5, 7);
|
||||
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
|
||||
@@ -57,7 +65,7 @@ class ScheduleQueryServiceTest {
|
||||
endDate);
|
||||
|
||||
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
|
||||
verify(groupRepository, never()).findByStatusNot(any());
|
||||
verify(groupRepository, never()).findAll();
|
||||
}
|
||||
|
||||
private StudentGroup group(int id) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.model.StudentGroupStudyState;
|
||||
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class StudentGroupLifecycleServiceTest {
|
||||
|
||||
@Test
|
||||
void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() {
|
||||
StudentGroup group = group(1L, 2027);
|
||||
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||
|
||||
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED);
|
||||
assertThat(service.isAvailableForSelection(group, date)).isTrue();
|
||||
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() {
|
||||
StudentGroup group = group(1L, 2020);
|
||||
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||
|
||||
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED);
|
||||
assertThat(service.isAvailableForSelection(group, date)).isFalse();
|
||||
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||
}
|
||||
|
||||
private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) {
|
||||
StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class);
|
||||
when(repository.findByGroupIdWithDetails(group.getId()))
|
||||
.thenReturn(List.of(assignment(courseCount)));
|
||||
return new StudentGroupLifecycleService(repository);
|
||||
}
|
||||
|
||||
private StudentGroup group(Long id, int yearStartStudy) {
|
||||
StudentGroup group = new StudentGroup();
|
||||
group.setId(id);
|
||||
group.setName("Группа");
|
||||
group.setYearStartStudy(yearStartStudy);
|
||||
return group;
|
||||
}
|
||||
|
||||
private StudentGroupCalendarAssignment assignment(int courseCount) {
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setCourseCount(courseCount);
|
||||
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
|
||||
assignment.setAcademicCalendar(calendar);
|
||||
return assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.magistr.app.utils;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CourseAndSemesterCalculatorTest {
|
||||
|
||||
@Test
|
||||
void courseAndSemesterAreZeroBeforeStudyStart() {
|
||||
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||
|
||||
assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero();
|
||||
assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void septemberStartsFirstCourseAndFirstSemester() {
|
||||
LocalDate date = LocalDate.of(2026, 9, 1);
|
||||
|
||||
assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1);
|
||||
assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
22
docs/API.md
22
docs/API.md
@@ -599,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
### `GET /api/groups`
|
||||
|
||||
Список всех групп.
|
||||
Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
|
||||
|
||||
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
@@ -619,14 +621,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
"specialtyName": "Программная инженерия",
|
||||
"specialtyProfileId": 2,
|
||||
"specialtyProfileName": "Без профиля",
|
||||
"specialityCode": 1
|
||||
"specialityCode": 1,
|
||||
"status": "ACTIVE",
|
||||
"active": true,
|
||||
"studyState": "ACTIVE",
|
||||
"studyStateName": "Активна"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/groups/{departmentId}`
|
||||
|
||||
Список всех групп привязанных к конкретной кафедре.
|
||||
Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
|
||||
|
||||
### `POST /api/groups`
|
||||
|
||||
@@ -644,7 +650,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
}
|
||||
```
|
||||
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`.
|
||||
`specialtyId` и `specialtyProfileId` обязательны. Поле `specialityCode` сохранено как legacy-alias для старых клиентов и исторически содержит ID записи из `/api/specialties`. Текущий курс вычисляется из `yearStartStudy`, но не опускается ниже `0`, если обучение ещё не началось.
|
||||
|
||||
Поле `active` показывает, можно ли выбирать группу в текущих рабочих сценариях. `studyState` принимает значения `ACTIVE`, `NOT_STARTED`, `GRADUATED`, `INACTIVE`, `ARCHIVED`.
|
||||
|
||||
Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
|
||||
|
||||
@@ -666,7 +674,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
### `DELETE /api/groups/{id}`
|
||||
|
||||
Удаление группы.
|
||||
Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
|
||||
|
||||
### `POST /api/groups/{id}/restore`
|
||||
|
||||
Восстановление архивной группы.
|
||||
|
||||
### Подгруппы группы
|
||||
|
||||
|
||||
@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
### Учебные группы (Student Groups)
|
||||
|
||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||
|
||||
### Аудитории (Classrooms)
|
||||
|
||||
@@ -90,7 +91,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
|
||||
|
||||
Методическая проверка активности учитывает и период действия, и `status`: запись со статусом `ARCHIVED` не считается активной даже без заполненного `active_to`.
|
||||
Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
|
||||
|
||||
### Перевод преподавателей между кафедрами
|
||||
|
||||
|
||||
@@ -429,6 +429,11 @@ erDiagram
|
||||
| `specialty_id` | BIGINT FK → specialties | Специальность |
|
||||
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
|
||||
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
|
||||
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
|
||||
| `active_from` | DATE | Дата начала действия группы |
|
||||
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
|
||||
| `archived_at` | TIMESTAMP | Дата и время архивирования |
|
||||
| `archive_reason` | TEXT | Причина архивирования |
|
||||
|
||||
#### `subgroups` — Подгруппы
|
||||
| Колонка | Тип | Описание |
|
||||
|
||||
@@ -146,7 +146,7 @@ frontend/
|
||||
|
||||
### Особенности админских вкладок
|
||||
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function initGroups() {
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,13 @@ export async function initGroups() {
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
allGroups = await api.get('/api/groups');
|
||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||
applyGroupFilter();
|
||||
populateGroupSelect();
|
||||
populateSubgroupGroupSelect();
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
@@ -145,9 +145,12 @@ export async function initGroups() {
|
||||
<td>${group.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -232,24 +235,26 @@ export async function initGroups() {
|
||||
|
||||
function populateGroupSelect() {
|
||||
const current = calendarGroupSelect.value;
|
||||
const availableGroups = allGroups.filter(group => group.active);
|
||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||
calendarGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
calendarGroupSelect.value = String(allGroups[0].id);
|
||||
} else if (availableGroups.length) {
|
||||
calendarGroupSelect.value = String(availableGroups[0].id);
|
||||
}
|
||||
syncSelects(calendarGroupSelect);
|
||||
}
|
||||
|
||||
function populateSubgroupGroupSelect() {
|
||||
const current = subgroupGroupSelect.value;
|
||||
const availableGroups = allGroups.filter(group => group.active);
|
||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||
subgroupGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
subgroupGroupSelect.value = String(allGroups[0].id);
|
||||
} else if (availableGroups.length) {
|
||||
subgroupGroupSelect.value = String(availableGroups[0].id);
|
||||
}
|
||||
syncSelects(subgroupGroupSelect);
|
||||
}
|
||||
@@ -342,15 +347,26 @@ export async function initGroups() {
|
||||
async function handleGroupTableClick(event) {
|
||||
const btnDelete = event.target.closest('.btn-delete');
|
||||
const btnEdit = event.target.closest('.btn-edit-group');
|
||||
const btnRestore = event.target.closest('.btn-restore-group');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить группу?')) return;
|
||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
alert(error.message || 'Ошибка архивирования');
|
||||
}
|
||||
}
|
||||
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка восстановления');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +582,21 @@ export async function initGroups() {
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
}
|
||||
|
||||
function statusBadgeClass(group) {
|
||||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||
return 'badge-unavailable';
|
||||
}
|
||||
if (group.studyState === 'NOT_STARTED') {
|
||||
return 'badge-ef';
|
||||
}
|
||||
return 'badge-available';
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
@@ -67,12 +67,13 @@
|
||||
<th>Курс</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="9" class="loading-row">Загрузка...</td>
|
||||
<td colspan="10" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user