исправил логику курса групп
This commit is contained in:
@@ -7,14 +7,15 @@ import com.magistr.app.dto.GroupResponse;
|
|||||||
import com.magistr.app.model.AcademicCalendar;
|
import com.magistr.app.model.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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
public enum StudentGroupStudyState {
|
||||||
|
ACTIVE("Активна"),
|
||||||
|
NOT_STARTED("Обучение не началось"),
|
||||||
|
GRADUATED("Завершила обучение"),
|
||||||
|
INACTIVE("Неактивна"),
|
||||||
|
ARCHIVED("Архив");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
StudentGroupStudyState(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.magistr.app.service;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import com.magistr.app.utils.CourseAndSemesterCalculator;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class StudentGroupLifecycleService {
|
||||||
|
|
||||||
|
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
|
||||||
|
|
||||||
|
public StudentGroupLifecycleService(StudentGroupCalendarAssignmentRepository assignmentRepository) {
|
||||||
|
this.assignmentRepository = assignmentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailableForSelection(StudentGroup group, LocalDate date) {
|
||||||
|
return group != null
|
||||||
|
&& group.isActiveRecord()
|
||||||
|
&& group.isActiveOn(date)
|
||||||
|
&& !isGraduatedOn(group, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean mayHaveScheduleInRange(StudentGroup group, LocalDate startDate, LocalDate endDate) {
|
||||||
|
if (group == null || startDate == null || endDate == null || endDate.isBefore(startDate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<Integer> courseLimit = resolveCourseLimit(group);
|
||||||
|
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
|
||||||
|
if (group.isActiveOn(date) && hasStarted(group, date) && !isGraduatedOn(group, date, courseLimit)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudentGroupStudyState getStudyState(StudentGroup group, LocalDate date) {
|
||||||
|
if (group == null || group.isArchivedRecord()) {
|
||||||
|
return StudentGroupStudyState.ARCHIVED;
|
||||||
|
}
|
||||||
|
if (!group.isActiveOn(date)) {
|
||||||
|
return StudentGroupStudyState.INACTIVE;
|
||||||
|
}
|
||||||
|
if (isGraduatedOn(group, date)) {
|
||||||
|
return StudentGroupStudyState.GRADUATED;
|
||||||
|
}
|
||||||
|
if (!hasStarted(group, date)) {
|
||||||
|
return StudentGroupStudyState.NOT_STARTED;
|
||||||
|
}
|
||||||
|
return StudentGroupStudyState.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasStarted(StudentGroup group, LocalDate date) {
|
||||||
|
return CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGraduatedOn(StudentGroup group, LocalDate date) {
|
||||||
|
return isGraduatedOn(group, date, resolveCourseLimit(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGraduatedOn(StudentGroup group, LocalDate date, Optional<Integer> courseLimit) {
|
||||||
|
int course = CourseAndSemesterCalculator.getActualCourse(group.getYearStartStudy(), date);
|
||||||
|
return course > 0 && courseLimit.map(limit -> course > limit).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Integer> resolveCourseLimit(StudentGroup group) {
|
||||||
|
if (group.getId() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return assignmentRepository.findByGroupIdWithDetails(group.getId()).stream()
|
||||||
|
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
|
||||||
|
.filter(calendar -> calendar.getCourseCount() != null)
|
||||||
|
.map(calendar -> calendar.getCourseCount())
|
||||||
|
.max(Comparator.naturalOrder());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.model.AcademicCalendar;
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||||
|
import com.magistr.app.model.StudentGroupStudyState;
|
||||||
|
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class StudentGroupLifecycleServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void futureGroupIsSelectableButDoesNotHaveScheduleBeforeStudyStart() {
|
||||||
|
StudentGroup group = group(1L, 2027);
|
||||||
|
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.NOT_STARTED);
|
||||||
|
assertThat(service.isAvailableForSelection(group, date)).isTrue();
|
||||||
|
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void groupAfterCalendarCourseLimitIsGraduatedAndUnavailableForSelection() {
|
||||||
|
StudentGroup group = group(1L, 2020);
|
||||||
|
StudentGroupLifecycleService service = serviceWithAssignments(group, 4);
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(service.getStudyState(group, date)).isEqualTo(StudentGroupStudyState.GRADUATED);
|
||||||
|
assertThat(service.isAvailableForSelection(group, date)).isFalse();
|
||||||
|
assertThat(service.mayHaveScheduleInRange(group, date, date.plusDays(7))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroupLifecycleService serviceWithAssignments(StudentGroup group, int courseCount) {
|
||||||
|
StudentGroupCalendarAssignmentRepository repository = mock(StudentGroupCalendarAssignmentRepository.class);
|
||||||
|
when(repository.findByGroupIdWithDetails(group.getId()))
|
||||||
|
.thenReturn(List.of(assignment(courseCount)));
|
||||||
|
return new StudentGroupLifecycleService(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroup group(Long id, int yearStartStudy) {
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setId(id);
|
||||||
|
group.setName("Группа");
|
||||||
|
group.setYearStartStudy(yearStartStudy);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StudentGroupCalendarAssignment assignment(int courseCount) {
|
||||||
|
AcademicCalendar calendar = new AcademicCalendar();
|
||||||
|
calendar.setCourseCount(courseCount);
|
||||||
|
StudentGroupCalendarAssignment assignment = new StudentGroupCalendarAssignment();
|
||||||
|
assignment.setAcademicCalendar(calendar);
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.magistr.app.utils;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CourseAndSemesterCalculatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void courseAndSemesterAreZeroBeforeStudyStart() {
|
||||||
|
LocalDate date = LocalDate.of(2026, 5, 1);
|
||||||
|
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualCourse(2027, date)).isZero();
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualSemester(2027, date)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void septemberStartsFirstCourseAndFirstSemester() {
|
||||||
|
LocalDate date = LocalDate.of(2026, 9, 1);
|
||||||
|
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualCourse(2026, date)).isEqualTo(1);
|
||||||
|
assertThat(CourseAndSemesterCalculator.getActualSemester(2026, date)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
docs/API.md
22
docs/API.md
@@ -599,7 +599,9 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
|
|
||||||
### `GET /api/groups`
|
### `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`
|
||||||
|
|
||||||
|
Восстановление архивной группы.
|
||||||
|
|
||||||
### Подгруппы группы
|
### Подгруппы группы
|
||||||
|
|
||||||
|
|||||||
@@ -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` остаётся активной для исторических дат до даты вывода из работы.
|
||||||
|
|
||||||
### Перевод преподавателей между кафедрами
|
### Перевод преподавателей между кафедрами
|
||||||
|
|
||||||
|
|||||||
@@ -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` — Подгруппы
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
|
|||||||
@@ -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` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user