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

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.AcademicCalendar;
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.LifecycleEntity;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
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.model.StudentGroupStudyState;
import com.magistr.app.repository.*; import com.magistr.app.repository.*;
import com.magistr.app.service.ScheduleGeneratorService; import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.service.StudentGroupLifecycleService;
import com.magistr.app.utils.CourseAndSemesterCalculator; import com.magistr.app.utils.CourseAndSemesterCalculator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -22,6 +23,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -41,6 +43,7 @@ public class GroupController {
private final AcademicCalendarRepository academicCalendarRepository; private final AcademicCalendarRepository academicCalendarRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
public GroupController(GroupRepository groupRepository, public GroupController(GroupRepository groupRepository,
EducationFormRepository educationFormRepository, EducationFormRepository educationFormRepository,
@@ -49,7 +52,8 @@ public class GroupController {
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository, AcademicCalendarRepository academicCalendarRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository, StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.educationFormRepository = educationFormRepository; this.educationFormRepository = educationFormRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
@@ -58,6 +62,7 @@ public class GroupController {
this.academicCalendarRepository = academicCalendarRepository; this.academicCalendarRepository = academicCalendarRepository;
this.assignmentRepository = assignmentRepository; this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
} }
@GetMapping @GetMapping
@@ -67,7 +72,9 @@ public class GroupController {
try { try {
List<StudentGroup> groups = includeArchived List<StudentGroup> groups = includeArchived
? groupRepository.findAll() ? groupRepository.findAll()
: groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); : groupRepository.findAll().stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now()))
.toList();
List<GroupResponse> response = groups.stream() List<GroupResponse> response = groups.stream()
.map(this::mapToResponse) .map(this::mapToResponse)
@@ -84,7 +91,9 @@ public class GroupController {
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) { public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId); logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);
try { 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()) { if(groups.isEmpty()) {
logger.info("Группы для кафедры с ID - {} не найдены", departmentId); logger.info("Группы для кафедры с ID - {} не найдены", departmentId);
@@ -264,6 +273,8 @@ public class GroupController {
int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy()); int semester = CourseAndSemesterCalculator.getActualSemester(group.getYearStartStudy());
Speciality speciality = group.getSpeciality(); Speciality speciality = group.getSpeciality();
SpecialtyProfile profile = group.getSpecialtyProfile(); SpecialtyProfile profile = group.getSpecialtyProfile();
LocalDate today = LocalDate.now();
StudentGroupStudyState studyState = groupLifecycleService.getStudyState(group, today);
return new GroupResponse( return new GroupResponse(
group.getId(), group.getId(),
group.getName(), group.getName(),
@@ -278,7 +289,11 @@ public class GroupController {
speciality.getSpecialityCode(), speciality.getSpecialityCode(),
speciality.getSpecialityName(), speciality.getSpecialityName(),
profile.getId(), 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 String specialtyName;
private Long specialtyProfileId; private Long specialtyProfileId;
private String specialtyProfileName; private String specialtyProfileName;
private String status;
private Boolean active;
private String studyState;
private String studyStateName;
public GroupResponse(Long id, public GroupResponse(Long id,
String name, String name,
@@ -33,7 +37,11 @@ public class GroupResponse {
String specialtyCode, String specialtyCode,
String specialtyName, String specialtyName,
Long specialtyProfileId, Long specialtyProfileId,
String specialtyProfileName) { String specialtyProfileName,
String status,
Boolean active,
String studyState,
String studyStateName) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.groupSize = groupSize; this.groupSize = groupSize;
@@ -48,6 +56,10 @@ public class GroupResponse {
this.specialtyName = specialtyName; this.specialtyName = specialtyName;
this.specialtyProfileId = specialtyProfileId; this.specialtyProfileId = specialtyProfileId;
this.specialtyProfileName = specialtyProfileName; this.specialtyProfileName = specialtyProfileName;
this.status = status;
this.active = active;
this.studyState = studyState;
this.studyStateName = studyStateName;
} }
public Long getId() { public Long getId() {
@@ -109,4 +121,20 @@ public class GroupResponse {
public String getSpecialtyProfileName() { public String getSpecialtyProfileName() {
return specialtyProfileName; 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) { if (date == null) {
return isActiveRecord(); return isActiveRecord();
} }
if (!isActiveRecord()) { if (isArchivedRecord() && activeTo == null) {
return false; return false;
} }
boolean afterStart = activeFrom == null || !date.isBefore(activeFrom); 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; package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto; import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.LifecycleEntity;
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.repository.GroupRepository; import com.magistr.app.repository.GroupRepository;
@@ -9,6 +8,7 @@ import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -16,17 +16,21 @@ import java.util.stream.Collectors;
public class ScheduleQueryService { public class ScheduleQueryService {
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50; private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
private static final long MAX_RANGE_DAYS = 120;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository; private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository; private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService;
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService, public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository, GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository) { ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository; this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService;
} }
public List<RenderedLessonDto> search(Long groupId, public List<RenderedLessonDto> search(Long groupId,
@@ -39,11 +43,12 @@ public class ScheduleQueryService {
String parity, String parity,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate) {
validateRange(startDate, endDate);
List<RenderedLessonDto> generatedLessons; List<RenderedLessonDto> generatedLessons;
if (groupId == null && departmentId == null && teacherId != null) { if (groupId == null && departmentId == null && teacherId != null) {
generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate);
} else { } else {
List<StudentGroup> groups = resolveGroups(groupId, departmentId); List<StudentGroup> groups = resolveGroups(groupId, departmentId, startDate, endDate);
generatedLessons = groups.stream() generatedLessons = groups.stream()
.flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream()) .flatMap(group -> scheduleGeneratorService.buildScheduleForGroup(group.getId(), startDate, endDate).stream())
.toList(); .toList();
@@ -139,18 +144,35 @@ public class ScheduleQueryService {
return scheduleRuleSlotId + ":" + date; 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) { if (groupId != null) {
return groupRepository.findById(groupId) return groupRepository.findById(groupId)
.filter(StudentGroup::isActiveRecord) .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.map(List::of) .map(List::of)
.orElse(List.of()); .orElse(List.of());
} }
List<StudentGroup> groups;
if (departmentId != null) { 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); groups = groups.stream()
if (groups.size() > MAX_GROUPS_WITHOUT_SCOPE) { .filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();
if (departmentId == null && groups.size() > MAX_GROUPS_WITHOUT_SCOPE) {
throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает " throw new IllegalArgumentException("Уточните группу или кафедру: широкий поиск затрагивает "
+ groups.size() + " активных групп, максимум " + MAX_GROUPS_WITHOUT_SCOPE); + 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 class CourseAndSemesterCalculator {
public static int getActualCourse(Integer yearStartStudy) { public static int getActualCourse(Integer yearStartStudy) {
LocalDate now = LocalDate.now(); return getActualCourse(yearStartStudy, LocalDate.now());
int currentYear = now.getYear(); }
int currentMonth = now.getMonthValue();
if (currentMonth >= 9) { public static int getActualCourse(Integer yearStartStudy, LocalDate date) {
return currentYear - yearStartStudy + 1; int currentYear = date.getYear();
} else { int currentMonth = date.getMonthValue();
return currentYear - yearStartStudy; int course = currentMonth >= 9
} ? currentYear - yearStartStudy + 1
: currentYear - yearStartStudy;
return Math.max(0, course);
} }
public static int getActualSemester(Integer yearStartStudy) { public static int getActualSemester(Integer yearStartStudy) {
int course = getActualCourse(yearStartStudy); return getActualSemester(yearStartStudy, LocalDate.now());
int currentMonth = LocalDate.now().getMonthValue(); }
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; return course * 2 - 1;
} else { } else {
return course * 2; return course * 2;
@@ -33,11 +41,14 @@ public class CourseAndSemesterCalculator {
public static int getFutureCourse(Integer yearStartStudy, String periodYears) { public static int getFutureCourse(Integer yearStartStudy, String periodYears) {
int recordYear = Integer.parseInt(periodYears.substring(0, 4)); 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) { public static int getFutureSemester(Integer yearStartStudy, String periodYears, SemesterType semesterType) {
int course = getFutureCourse(yearStartStudy, periodYears); int course = getFutureCourse(yearStartStudy, periodYears);
if (course == 0) {
return 0;
}
if (semesterType == SemesterType.autumn) { if (semesterType == SemesterType.autumn) {
return course * 2 - 1; return course * 2 - 1;

View File

@@ -16,6 +16,16 @@ class LifecycleEntityTest {
assertThat(subject.isActiveOn(LocalDate.now())).isFalse(); 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 @Test
void activeEntityUsesDateRange() { void activeEntityUsesDateRange() {
Subject subject = new Subject(); Subject subject = new Subject();

View File

@@ -1,6 +1,5 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.model.LifecycleEntity;
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;
@@ -22,14 +21,17 @@ class ScheduleQueryServiceTest {
@Test @Test
void rejectsTooBroadGroupSearch() { void rejectsTooBroadGroupSearch() {
GroupRepository groupRepository = mock(GroupRepository.class); GroupRepository groupRepository = mock(GroupRepository.class);
when(groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED)) StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
when(groupRepository.findAll())
.thenReturn(IntStream.rangeClosed(1, 51) .thenReturn(IntStream.rangeClosed(1, 51)
.mapToObj(this::group) .mapToObj(this::group)
.toList()); .toList());
when(groupLifecycleService.mayHaveScheduleInRange(any(), any(), any())).thenReturn(true);
ScheduleQueryService service = new ScheduleQueryService( ScheduleQueryService service = new ScheduleQueryService(
mock(ScheduleGeneratorService.class), mock(ScheduleGeneratorService.class),
groupRepository, groupRepository,
mock(ScheduleOverrideRepository.class) mock(ScheduleOverrideRepository.class),
groupLifecycleService
); );
assertThatThrownBy(() -> service.search(null, null, null, null, assertThatThrownBy(() -> service.search(null, null, null, null,
@@ -45,7 +47,13 @@ class ScheduleQueryServiceTest {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class); ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class); GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.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 startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7); LocalDate endDate = LocalDate.of(2026, 5, 7);
when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of()); when(generatorService.buildScheduleForTeacher(10L, startDate, endDate)).thenReturn(List.of());
@@ -57,7 +65,7 @@ class ScheduleQueryServiceTest {
endDate); endDate);
verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate); verify(generatorService).buildScheduleForTeacher(10L, startDate, endDate);
verify(groupRepository, never()).findByStatusNot(any()); verify(groupRepository, never()).findAll();
} }
private StudentGroup group(int id) { 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);
}
}

View File

@@ -599,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `GET /api/groups` ### `GET /api/groups`
Список всех групп. Список групп, доступных для выбора в текущем учебном контуре. Группы, завершившие обучение по назначенному календарному графику, и архивные группы не возвращаются по умолчанию.
Параметр `includeArchived=true` возвращает все группы, включая архивные и завершившие обучение.
**Ответ:** **Ответ:**
```json ```json
@@ -619,14 +621,18 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
"specialtyName": "Программная инженерия", "specialtyName": "Программная инженерия",
"specialtyProfileId": 2, "specialtyProfileId": 2,
"specialtyProfileName": "Без профиля", "specialtyProfileName": "Без профиля",
"specialityCode": 1 "specialityCode": 1,
"status": "ACTIVE",
"active": true,
"studyState": "ACTIVE",
"studyStateName": "Активна"
} }
] ]
``` ```
### `GET /api/groups/{departmentId}` ### `GET /api/groups/{departmentId}`
Список всех групп привязанных к конкретной кафедре. Список групп выбранной кафедры, доступных для текущих рабочих сценариев. Завершившие обучение и архивные группы исключаются.
### `POST /api/groups` ### `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`. Название группы не является уникальным полем: допускается несколько групп с одинаковым `name`.
@@ -666,7 +674,11 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `DELETE /api/groups/{id}` ### `DELETE /api/groups/{id}`
Удаление группы. Архивирование группы. Запись остаётся в истории, поэтому расписание за прошлые даты не теряет связь с группой.
### `POST /api/groups/{id}/restore`
Восстановление архивной группы.
### Подгруппы группы ### Подгруппы группы

View File

@@ -57,9 +57,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Учебные группы (Student Groups) ### Учебные группы (Student Groups)
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1` - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) - **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
### Аудитории (Classrooms) ### Аудитории (Classrooms)
@@ -90,7 +91,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия. Архивирование уже применяется к пользователям, аудиториям, оборудованию, кафедрам, специальностям, группам, подгруппам, дисциплинам и профилям обучения. Исторические отчёты используют записи, действовавшие на дату занятия.
Методическая проверка активности учитывает и период действия, и `status`: запись со статусом `ARCHIVED` не считается активной даже без заполненного `active_to`. Методическая проверка активности учитывает период действия и `status`: архивная запись без `active_to` не считается активной, но запись с заполненным `active_to` остаётся активной для исторических дат до даты вывода из работы.
### Перевод преподавателей между кафедрами ### Перевод преподавателей между кафедрами

View File

@@ -429,6 +429,11 @@ erDiagram
| `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_id` | BIGINT FK → specialties | Специальность |
| `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения группы |
| `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса | | `year_start_study` | BIGINT | Год начала обучения, используется для вычисления текущего курса |
| `status` | VARCHAR(20) | Жизненный цикл группы: `ACTIVE` или `ARCHIVED` |
| `active_from` | DATE | Дата начала действия группы |
| `active_to` | DATE | Дата окончания действия группы для исторических расчётов |
| `archived_at` | TIMESTAMP | Дата и время архивирования |
| `archive_reason` | TEXT | Причина архивирования |
#### `subgroups` — Подгруппы #### `subgroups` — Подгруппы
| Колонка | Тип | Описание | | Колонка | Тип | Описание |

View File

@@ -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` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `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`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.

View File

@@ -88,7 +88,7 @@ export async function initGroups() {
await loadGroups(); await loadGroups();
await loadSubgroups(); await loadSubgroups();
} catch (error) { } 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>`; 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() { async function loadGroups() {
try { try {
allGroups = await api.get('/api/groups'); allGroups = await api.get('/api/groups?includeArchived=true');
applyGroupFilter(); applyGroupFilter();
populateGroupSelect(); populateGroupSelect();
populateSubgroupGroupSelect(); populateSubgroupGroupSelect();
await loadAssignments(); await loadAssignments();
} catch (error) { } 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) { function renderGroups(groups) {
if (!groups || !groups.length) { 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; return;
} }
groupsTbody.innerHTML = groups.map(group => ` groupsTbody.innerHTML = groups.map(group => `
@@ -145,9 +145,12 @@ export async function initGroups() {
<td>${group.course || '-'}</td> <td>${group.course || '-'}</td>
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td> <td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
<td>${escapeHtml(group.specialtyProfileName || '-')}</td> <td>${escapeHtml(group.specialtyProfileName || '-')}</td>
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button> <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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -232,24 +235,26 @@ export async function initGroups() {
function populateGroupSelect() { function populateGroupSelect() {
const current = calendarGroupSelect.value; const current = calendarGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' + calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join(''); availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && allGroups.some(group => String(group.id) === String(current))) { if (current && availableGroups.some(group => String(group.id) === String(current))) {
calendarGroupSelect.value = current; calendarGroupSelect.value = current;
} else if (allGroups.length) { } else if (availableGroups.length) {
calendarGroupSelect.value = String(allGroups[0].id); calendarGroupSelect.value = String(availableGroups[0].id);
} }
syncSelects(calendarGroupSelect); syncSelects(calendarGroupSelect);
} }
function populateSubgroupGroupSelect() { function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value; const current = subgroupGroupSelect.value;
const availableGroups = allGroups.filter(group => group.active);
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' + subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join(''); availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && allGroups.some(group => String(group.id) === String(current))) { if (current && availableGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current; subgroupGroupSelect.value = current;
} else if (allGroups.length) { } else if (availableGroups.length) {
subgroupGroupSelect.value = String(allGroups[0].id); subgroupGroupSelect.value = String(availableGroups[0].id);
} }
syncSelects(subgroupGroupSelect); syncSelects(subgroupGroupSelect);
} }
@@ -342,15 +347,26 @@ export async function initGroups() {
async function handleGroupTableClick(event) { async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-delete'); const btnDelete = event.target.closest('.btn-delete');
const btnEdit = event.target.closest('.btn-edit-group'); const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group');
if (btnDelete) { if (btnDelete) {
if (!confirm('Удалить группу?')) return; if (!confirm('Архивировать группу? История расписания сохранится.')) return;
try { try {
await api.delete('/api/groups/' + btnDelete.dataset.id); await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups(); await loadGroups();
await loadSubgroups(); await loadSubgroups();
} catch (error) { } 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; 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) { function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));

View File

@@ -67,12 +67,13 @@
<th>Курс</th> <th>Курс</th>
<th>Специальность</th> <th>Специальность</th>
<th>Профиль</th> <th>Профиль</th>
<th>Статус</th>
<th>Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody id="groups-tbody"> <tbody id="groups-tbody">
<tr> <tr>
<td colspan="9" class="loading-row">Загрузка...</td> <td colspan="10" class="loading-row">Загрузка...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>