From 37850b8e06e5d399f2588bb8fdcbf22bcc3ae8de Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 27 May 2026 21:04:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=B0=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/GroupController.java | 25 ++++-- .../com/magistr/app/dto/GroupResponse.java | 30 ++++++- .../magistr/app/model/LifecycleEntity.java | 2 +- .../app/model/StudentGroupStudyState.java | 19 +++++ .../app/service/ScheduleQueryService.java | 38 +++++++-- .../service/StudentGroupLifecycleService.java | 82 +++++++++++++++++++ .../utils/CourseAndSemesterCalculator.java | 35 +++++--- .../app/model/LifecycleEntityTest.java | 10 +++ .../app/service/ScheduleQueryServiceTest.java | 18 ++-- .../StudentGroupLifecycleServiceTest.java | 63 ++++++++++++++ .../CourseAndSemesterCalculatorTest.java | 26 ++++++ docs/API.md | 22 +++-- docs/BUSINESS_LOGIC.md | 5 +- docs/DATABASE.md | 5 ++ docs/FRONTEND.md | 2 +- frontend/admin/js/views/groups.js | 61 ++++++++++---- frontend/admin/views/groups.html | 3 +- 17 files changed, 390 insertions(+), 56 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java create mode 100644 backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java create mode 100644 backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index 1d31f25..25c4f4f 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -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 groups = includeArchived ? groupRepository.findAll() - : groupRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED); + : groupRepository.findAll().stream() + .filter(group -> groupLifecycleService.isAvailableForSelection(group, LocalDate.now())) + .toList(); List 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 groups = groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + List 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() ); } diff --git a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java index 4807f35..6dc642e 100755 --- a/backend/src/main/java/com/magistr/app/dto/GroupResponse.java +++ b/backend/src/main/java/com/magistr/app/dto/GroupResponse.java @@ -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; + } } diff --git a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java index cad97a4..8de4e61 100644 --- a/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java +++ b/backend/src/main/java/com/magistr/app/model/LifecycleEntity.java @@ -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); diff --git a/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java new file mode 100644 index 0000000..ba8c02c --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/StudentGroupStudyState.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java index 312f048..f226564 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java @@ -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 search(Long groupId, @@ -39,11 +43,12 @@ public class ScheduleQueryService { String parity, LocalDate startDate, LocalDate endDate) { + validateRange(startDate, endDate); List generatedLessons; if (groupId == null && departmentId == null && teacherId != null) { generatedLessons = scheduleGeneratorService.buildScheduleForTeacher(teacherId, startDate, endDate); } else { - List groups = resolveGroups(groupId, departmentId); + List 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 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 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 groups; if (departmentId != null) { - return groupRepository.findByDepartmentIdAndStatusNot(departmentId, LifecycleEntity.STATUS_ARCHIVED); + groups = groupRepository.findByDepartmentId(departmentId); + } else { + groups = groupRepository.findAll(); } - List 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); } diff --git a/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java new file mode 100644 index 0000000..5620a9b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/service/StudentGroupLifecycleService.java @@ -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 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 courseLimit) { + int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date); + return course > 0 && courseLimit.map(limit -> course > limit).orElse(false); + } + + private Optional 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()); + } +} diff --git a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java index af6b488..3b0f9e9 100644 --- a/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java +++ b/backend/src/main/java/com/magistr/app/utils/CourseAndSemesterCalculator.java @@ -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; diff --git a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java index 8d78d5e..c5e36c0 100644 --- a/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java +++ b/backend/src/test/java/com/magistr/app/model/LifecycleEntityTest.java @@ -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(); diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java index def9e5b..7fe126a 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleQueryServiceTest.java @@ -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) { diff --git a/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java new file mode 100644 index 0000000..54e966d --- /dev/null +++ b/backend/src/test/java/com/magistr/app/service/StudentGroupLifecycleServiceTest.java @@ -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; + } +} diff --git a/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java new file mode 100644 index 0000000..f3eb939 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/utils/CourseAndSemesterCalculatorTest.java @@ -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); + } +} diff --git a/docs/API.md b/docs/API.md index 8692cd6..a09293e 100644 --- a/docs/API.md +++ b/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` + +Восстановление архивной группы. ### Подгруппы группы diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 088bcdd..88722a7 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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` остаётся активной для исторических дат до даты вывода из работы. ### Перевод преподавателей между кафедрами diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 133cf7b..bc3f180 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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` — Подгруппы | Колонка | Тип | Описание | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7eff0c3..5d9759f 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index b1fa96b..014627c 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -88,7 +88,7 @@ export async function initGroups() { await loadGroups(); await loadSubgroups(); } catch (error) { - groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; + groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; subgroupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } @@ -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 = `Ошибка загрузки: ${escapeHtml(error.message)}`; + groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } @@ -132,7 +132,7 @@ export async function initGroups() { function renderGroups(groups) { if (!groups || !groups.length) { - groupsTbody.innerHTML = 'Нет групп'; + groupsTbody.innerHTML = 'Нет групп'; return; } groupsTbody.innerHTML = groups.map(group => ` @@ -145,9 +145,12 @@ export async function initGroups() { ${group.course || '-'} ${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))} ${escapeHtml(group.specialtyProfileName || '-')} + ${escapeHtml(statusLabel(group))} - + ${group.status === 'ARCHIVED' + ? `` + : ``} `).join(''); @@ -232,24 +235,26 @@ export async function initGroups() { function populateGroupSelect() { const current = calendarGroupSelect.value; + const availableGroups = allGroups.filter(group => group.active); calendarGroupSelect.innerHTML = '' + - allGroups.map(group => ``).join(''); - if (current && allGroups.some(group => String(group.id) === String(current))) { + availableGroups.map(group => ``).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 = '' + - allGroups.map(group => ``).join(''); - if (current && allGroups.some(group => String(group.id) === String(current))) { + availableGroups.map(group => ``).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 })); diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 879269a..8a40df9 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -67,12 +67,13 @@ Курс Специальность Профиль + Статус Действия - Загрузка... + Загрузка...