исправил логику курса групп

This commit is contained in:
Zuev
2026-05-27 21:04:54 +03:00
parent e9575cf2ce
commit 37850b8e06
17 changed files with 390 additions and 56 deletions

View File

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

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -9,22 +9,30 @@ 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();
return getActualCourse(yearStartStudy, LocalDate.now());
}
if (currentMonth >= 9) {
return currentYear - yearStartStudy + 1;
} else {
return currentYear - yearStartStudy;
}
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());
}
if ( currentMonth <= 1 || currentMonth >= 9) {
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;
} else {
return course * 2;
@@ -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;

View File

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

View File

@@ -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) {

View File

@@ -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;
}
}

View File

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