добавил денение на подгруппы для лабораторных работ

This commit is contained in:
Zuev
2026-05-06 22:25:53 +03:00
parent b222f3adac
commit e12a5bcccd
25 changed files with 693 additions and 40 deletions

View File

@@ -26,6 +26,7 @@ public class ScheduleRuleAdminController {
private final UserRepository userRepository;
private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
@@ -36,6 +37,7 @@ public class ScheduleRuleAdminController {
UserRepository userRepository,
ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository;
@@ -45,6 +47,7 @@ public class ScheduleRuleAdminController {
this.userRepository = userRepository;
this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -187,7 +190,8 @@ public class ScheduleRuleAdminController {
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory.fromLessonType(lessonType);
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category);
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
@@ -197,7 +201,7 @@ public class ScheduleRuleAdminController {
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroupId(slotDto.subgroupId());
slot.setSubgroup(subgroup);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
@@ -205,6 +209,23 @@ public class ScheduleRuleAdminController {
return slot;
}
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) {
if (subgroupId == null) {
return null;
}
if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
}
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
boolean subgroupBelongsToRuleGroups = rule.getGroups().stream()
.anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId()));
if (!subgroupBelongsToRuleGroups) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
return subgroup;
}
private String validateTypeHours(ScheduleRuleDto request) {
if (isMissing(request.lectureAcademicHours())
|| isMissing(request.laboratoryAcademicHours())
@@ -306,6 +327,7 @@ public class ScheduleRuleAdminController {
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
slot.getSubgroupId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),

View File

@@ -0,0 +1,139 @@
package com.magistr.app.controller;
import com.magistr.app.dto.SubgroupDto;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
public class SubgroupController {
private final SubgroupRepository subgroupRepository;
private final GroupRepository groupRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public SubgroupController(SubgroupRepository subgroupRepository,
GroupRepository groupRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.subgroupRepository = subgroupRepository;
this.groupRepository = groupRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/api/groups/{groupId}/subgroups")
public ResponseEntity<?> getByGroup(@PathVariable Long groupId) {
if (!groupRepository.existsById(groupId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(subgroupRepository.findByStudentGroupIdOrderByNameAsc(groupId).stream()
.map(this::toDto)
.toList());
}
@PostMapping("/api/groups/{groupId}/subgroups")
public ResponseEntity<?> create(@PathVariable Long groupId, @RequestBody SubgroupDto request) {
StudentGroup group = groupRepository.findById(groupId).orElse(null);
if (group == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
Subgroup subgroup = new Subgroup();
subgroup.setStudentGroup(group);
subgroup.setName(request.name().trim());
subgroup.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@PutMapping("/api/groups/{groupId}/subgroups/{id}")
public ResponseEntity<?> update(@PathVariable Long groupId,
@PathVariable Long id,
@RequestBody SubgroupDto request) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
String newName = request.name().trim();
boolean duplicateName = subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, newName)
&& !subgroup.getName().equalsIgnoreCase(newName);
if (duplicateName) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе"));
}
subgroup.setName(newName);
subgroup.setStudentCapacity(request.studentCapacity());
Subgroup saved = subgroupRepository.save(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toDto(saved));
}
@DeleteMapping("/api/groups/{groupId}/subgroups/{id}")
public ResponseEntity<?> delete(@PathVariable Long groupId, @PathVariable Long id) {
Subgroup subgroup = subgroupRepository.findByIdAndStudentGroupId(id, groupId).orElse(null);
if (subgroup == null) {
return ResponseEntity.notFound().build();
}
if (scheduleRuleSlotRepository.existsBySubgroupId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
try {
subgroupRepository.delete(subgroup);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(Map.of("message", "Подгруппа удалена"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании"));
}
}
private String validate(SubgroupDto request) {
if (request == null || request.name() == null || request.name().isBlank()) {
return "Название подгруппы обязательно";
}
if (request.studentCapacity() != null && request.studentCapacity() < 0) {
return "Численность подгруппы не может быть отрицательной";
}
return null;
}
private SubgroupDto toDto(Subgroup subgroup) {
StudentGroup group = subgroup.getStudentGroup();
return new SubgroupDto(
subgroup.getId(),
group.getId(),
group.getName(),
subgroup.getName(),
subgroup.getStudentCapacity()
);
}
}

View File

@@ -28,6 +28,7 @@ public record RenderedLessonDto(
String lessonTypeName,
String lessonFormat,
Long subgroupId,
String subgroupName,
List<Long> groupIds,
List<String> groupNames,
String activityType,

View File

@@ -11,6 +11,7 @@ public record ScheduleRuleSlotDto(
Integer timeSlotOrder,
String timeSlotLabel,
Long subgroupId,
String subgroupName,
Long teacherId,
String teacherName,
Long classroomId,

View File

@@ -0,0 +1,10 @@
package com.magistr.app.dto;
public record SubgroupDto(
Long id,
Long groupId,
String groupName,
String name,
Integer studentCapacity
) {
}

View File

@@ -25,8 +25,9 @@ public class ScheduleRuleSlot {
@JoinColumn(name = "time_slot_id", nullable = false)
private TimeSlot timeSlot;
@Column(name = "subgroup_id")
private Long subgroupId;
@ManyToOne
@JoinColumn(name = "subgroup_id")
private Subgroup subgroup;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
@@ -84,11 +85,15 @@ public class ScheduleRuleSlot {
}
public Long getSubgroupId() {
return subgroupId;
return subgroup == null ? null : subgroup.getId();
}
public void setSubgroupId(Long subgroupId) {
this.subgroupId = subgroupId;
public Subgroup getSubgroup() {
return subgroup;
}
public void setSubgroup(Subgroup subgroup) {
this.subgroup = subgroup;
}
public User getTeacher() {

View File

@@ -0,0 +1,54 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "subgroups")
public class Subgroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "group_id", nullable = false)
private StudentGroup studentGroup;
@Column(nullable = false, length = 100)
private String name;
@Column(name = "student_capacity")
private Integer studentCapacity;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public StudentGroup getStudentGroup() {
return studentGroup;
}
public void setStudentGroup(StudentGroup studentGroup) {
this.studentGroup = studentGroup;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getStudentCapacity() {
return studentCapacity;
}
public void setStudentCapacity(Integer studentCapacity) {
this.studentCapacity = studentCapacity;
}
}

View File

@@ -19,6 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
@@ -40,6 +42,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
@@ -60,6 +64,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
@@ -76,6 +82,8 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType

View File

@@ -2,6 +2,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRuleSlot;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot where slot.subgroup.id = :subgroupId")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
}

View File

@@ -0,0 +1,46 @@
package com.magistr.app.repository;
import com.magistr.app.model.Subgroup;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
@Query("""
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup
order by studentGroup.name, subgroup.name
""")
List<Subgroup> findAllWithGroupsOrderByGroupNameAndName();
@Query("""
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup
where subgroup.studentGroup.id = :groupId
order by subgroup.name
""")
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@Query("""
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup
where subgroup.id = :id
and subgroup.studentGroup.id = :groupId
""")
Optional<Subgroup> findByIdAndStudentGroupId(@Param("id") Long id, @Param("groupId") Long groupId);
@Query("""
select case when count(subgroup) > 0 then true else false end
from Subgroup subgroup
where subgroup.studentGroup.id = :groupId
and lower(subgroup.name) = lower(:name)
""")
boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name);
}

View File

@@ -190,7 +190,7 @@ public class ScheduleGeneratorService {
return;
}
Map<ScheduleLessonCategory, Integer> consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class);
Map<String, Integer> consumedHoursByBucket = new HashMap<>();
for (ScheduleRuleSlot slot : slots) {
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
@@ -201,17 +201,22 @@ public class ScheduleGeneratorService {
if (totalHours <= 0) {
continue;
}
int consumedHours = consumedHoursByCategory.computeIfAbsent(
category,
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, key)
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups);
if (lessonGroups.isEmpty()) {
continue;
}
String consumptionBucket = consumptionBucket(category, slot);
int consumedHours = consumedHoursByBucket.computeIfAbsent(
consumptionBucket,
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId())
);
if (consumedHours >= totalHours) {
continue;
}
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups,
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups,
totalHours, consumedHours, remainingAfterLesson));
consumedHoursByCategory.put(category, consumedHours + ACADEMIC_HOURS_PER_SLOT);
consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT);
}
}
@@ -229,17 +234,23 @@ public class ScheduleGeneratorService {
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
StudentGroup targetGroup,
LocalDate targetDate,
ScheduleLessonCategory category) {
ScheduleLessonCategory category,
Long subgroupId) {
Long groupId = targetGroup == null ? null : targetGroup.getId();
String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
String cacheKey = rule.getId()
+ ":" + category.name()
+ ":" + (subgroupId == null ? "whole" : subgroupId)
+ ":" + (groupId == null ? "all" : groupId)
+ ":" + targetDate;
return consumedHoursCache.computeIfAbsent(cacheKey,
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category));
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category, subgroupId));
}
private int calculateConsumedHours(ScheduleRule rule,
StudentGroup targetGroup,
LocalDate targetDate,
ScheduleLessonCategory category) {
ScheduleLessonCategory category,
Long subgroupId) {
int consumed = 0;
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
Semester semester = rule.getSemester();
@@ -250,7 +261,8 @@ public class ScheduleGeneratorService {
if (weekNumber < rule.startWeekFor(category)) {
continue;
}
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
List<StudentGroup> eligibleGroups = eligibleGroups(rule, targetGroup, semester, date);
if (eligibleGroups.isEmpty()) {
continue;
}
@@ -260,6 +272,8 @@ public class ScheduleGeneratorService {
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId))
.filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty())
.count();
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
if (consumed >= rule.academicHoursFor(category)) {
@@ -269,6 +283,20 @@ public class ScheduleGeneratorService {
return consumed;
}
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) {
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId());
}
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) {
if (slot.getSubgroup() == null) {
return eligibleGroups;
}
Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId();
return eligibleGroups.stream()
.filter(group -> Objects.equals(group.getId(), subgroupGroupId))
.toList();
}
private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
ScheduleRuleSlot slot,
LocalDate date,
@@ -305,6 +333,7 @@ public class ScheduleGeneratorService {
slot.getLessonType().getLessonType(),
slot.getLessonFormat(),
slot.getSubgroupId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
sortedGroups.stream().map(StudentGroup::getId).toList(),
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
"Т",