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

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 UserRepository userRepository;
private final ClassroomRepository classroomRepository; private final ClassroomRepository classroomRepository;
private final LessonTypesRepository lessonTypesRepository; private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository, public ScheduleRuleAdminController(ScheduleRuleRepository scheduleRuleRepository,
@@ -36,6 +37,7 @@ public class ScheduleRuleAdminController {
UserRepository userRepository, UserRepository userRepository,
ClassroomRepository classroomRepository, ClassroomRepository classroomRepository,
LessonTypesRepository lessonTypesRepository, LessonTypesRepository lessonTypesRepository,
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.scheduleRuleRepository = scheduleRuleRepository; this.scheduleRuleRepository = scheduleRuleRepository;
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
@@ -45,6 +47,7 @@ public class ScheduleRuleAdminController {
this.userRepository = userRepository; this.userRepository = userRepository;
this.classroomRepository = classroomRepository; this.classroomRepository = classroomRepository;
this.lessonTypesRepository = lessonTypesRepository; this.lessonTypesRepository = lessonTypesRepository;
this.subgroupRepository = subgroupRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
} }
@@ -187,7 +190,8 @@ public class ScheduleRuleAdminController {
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")); .orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); .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()) { if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен"); throw new IllegalArgumentException("Формат занятия обязателен");
} }
@@ -197,7 +201,7 @@ public class ScheduleRuleAdminController {
slot.setDayOfWeek(slotDto.dayOfWeek()); slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity()); slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot); slot.setTimeSlot(timeSlot);
slot.setSubgroupId(slotDto.subgroupId()); slot.setSubgroup(subgroup);
slot.setTeacher(teacher); slot.setTeacher(teacher);
slot.setClassroom(classroom); slot.setClassroom(classroom);
slot.setLessonType(lessonType); slot.setLessonType(lessonType);
@@ -205,6 +209,23 @@ public class ScheduleRuleAdminController {
return slot; 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) { private String validateTypeHours(ScheduleRuleDto request) {
if (isMissing(request.lectureAcademicHours()) if (isMissing(request.lectureAcademicHours())
|| isMissing(request.laboratoryAcademicHours()) || isMissing(request.laboratoryAcademicHours())
@@ -306,6 +327,7 @@ public class ScheduleRuleAdminController {
timeSlot.getOrderNumber(), timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot), formatTimeSlot(timeSlot),
slot.getSubgroupId(), slot.getSubgroupId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
slot.getTeacher().getId(), slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()), ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(), 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 lessonTypeName,
String lessonFormat, String lessonFormat,
Long subgroupId, Long subgroupId,
String subgroupName,
List<Long> groupIds, List<Long> groupIds,
List<String> groupNames, List<String> groupNames,
String activityType, String activityType,

View File

@@ -11,6 +11,7 @@ public record ScheduleRuleSlotDto(
Integer timeSlotOrder, Integer timeSlotOrder,
String timeSlotLabel, String timeSlotLabel,
Long subgroupId, Long subgroupId,
String subgroupName,
Long teacherId, Long teacherId,
String teacherName, String teacherName,
Long classroomId, 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) @JoinColumn(name = "time_slot_id", nullable = false)
private TimeSlot timeSlot; private TimeSlot timeSlot;
@Column(name = "subgroup_id") @ManyToOne
private Long subgroupId; @JoinColumn(name = "subgroup_id")
private Subgroup subgroup;
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false) @JoinColumn(name = "teacher_id", nullable = false)
@@ -84,11 +85,15 @@ public class ScheduleRuleSlot {
} }
public Long getSubgroupId() { public Long getSubgroupId() {
return subgroupId; return subgroup == null ? null : subgroup.getId();
} }
public void setSubgroupId(Long subgroupId) { public Subgroup getSubgroup() {
this.subgroupId = subgroupId; return subgroup;
}
public void setSubgroup(Subgroup subgroup) {
this.subgroup = subgroup;
} }
public User getTeacher() { 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.groups groups
left join fetch r.slots slots left join fetch r.slots slots
left join fetch slots.timeSlot 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.teacher
left join fetch slots.classroom left join fetch slots.classroom
left join fetch slots.lessonType 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.groups groups
left join fetch r.slots slots left join fetch r.slots slots
left join fetch slots.timeSlot 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.teacher
left join fetch slots.classroom left join fetch slots.classroom
left join fetch slots.lessonType 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.groups groups
left join fetch r.slots slots left join fetch r.slots slots
left join fetch slots.timeSlot 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.teacher
left join fetch slots.classroom left join fetch slots.classroom
left join fetch slots.lessonType 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.groups groups
left join fetch r.slots slots left join fetch r.slots slots
left join fetch slots.timeSlot 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.teacher
left join fetch slots.classroom left join fetch slots.classroom
left join fetch slots.lessonType left join fetch slots.lessonType

View File

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

View File

@@ -119,6 +119,22 @@ CREATE TABLE IF NOT EXISTS subgroups (
UNIQUE(group_id, name) UNIQUE(group_id, name)
); );
INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
sg.id,
data.name,
CASE
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
END
FROM student_groups sg
CROSS JOIN (VALUES
('Подгруппа 1', 1),
('Подгруппа 2', 2)
) AS data(name, part_number)
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, name) DO NOTHING;
-- ========================================== -- ==========================================
-- Справочники -- Справочники
-- ========================================== -- ==========================================
@@ -512,8 +528,35 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_subgroup ON schedule_rule_slots(subgroup_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroup()
RETURNS TRIGGER AS $$
DECLARE
lesson_type_name VARCHAR(50);
BEGIN
IF NEW.subgroup_id IS NULL THEN
RETURN NEW;
END IF;
SELECT name INTO lesson_type_name
FROM lesson_types
WHERE id = NEW.lesson_type_id;
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER validate_schedule_rule_slot_subgroup
BEFORE INSERT OR UPDATE ON schedule_rule_slots
FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup();
CREATE TEMP TABLE tmp_default_rules AS CREATE TEMP TABLE tmp_default_rules AS
SELECT SELECT
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,

View File

@@ -101,7 +101,7 @@
## Динамическое расписание ## Динамическое расписание
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
### `GET /api/schedule` ### `GET /api/schedule`
@@ -148,6 +148,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
"lessonTypeName": "Лекция", "lessonTypeName": "Лекция",
"lessonFormat": "Очно", "lessonFormat": "Очно",
"subgroupId": null, "subgroupId": null,
"subgroupName": null,
"groupIds": [1], "groupIds": [1],
"groupNames": ["ИВТ-21-1"], "groupNames": ["ИВТ-21-1"],
"activityType": "Т", "activityType": "Т",
@@ -291,7 +292,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
"dayOfWeek": 1, "dayOfWeek": 1,
"parity": "BOTH", "parity": "BOTH",
"timeSlotId": 3, "timeSlotId": 3,
"subgroupId": null, "subgroupId": null,
"teacherId": 2, "teacherId": 2,
"classroomId": 1, "classroomId": 1,
"lessonTypeId": 1, "lessonTypeId": 1,
@@ -313,6 +314,8 @@ CRUD доступен по:
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
### `GET /api/lesson-types` ### `GET /api/lesson-types`
@@ -323,7 +326,8 @@ CRUD доступен по:
```json ```json
[ [
{ "id": 1, "name": "Лекция" }, { "id": 1, "name": "Лекция" },
{ "id": 2, "name": "Практика" } { "id": 2, "name": "Практика" },
{ "id": 3, "name": "Лабораторная работа" }
] ]
``` ```
@@ -454,6 +458,37 @@ CRUD доступен по:
Удаление группы. Удаление группы.
### Подгруппы группы
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`.
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/subgroups` | Список всех подгрупп |
| `GET` | `/api/groups/{groupId}/subgroups` | Подгруппы конкретной группы |
| `POST` | `/api/groups/{groupId}/subgroups` | Создать подгруппу |
| `PUT` | `/api/groups/{groupId}/subgroups/{id}` | Обновить подгруппу |
| `DELETE` | `/api/groups/{groupId}/subgroups/{id}` | Удалить подгруппу, если она не используется в расписании |
**Тело создания/обновления:**
```json
{
"name": "Подгруппа 1",
"studentCapacity": 12
}
```
**Ответ:**
```json
{
"id": 1,
"groupId": 1,
"groupName": "ИВТ-21-1",
"name": "Подгруппа 1",
"studentCapacity": 12
}
```
### Календарные графики группы ### Календарные графики группы
| Метод | URL | Назначение | | Метод | URL | Назначение |

View File

@@ -84,7 +84,7 @@
| `time_slots` | Настраиваемая сетка пар для тенанта | | `time_slots` | Настраиваемая сетка пар для тенанта |
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
| `schedule_rule_groups` | Группы правила, включая потоковые лекции | | `schedule_rule_groups` | Группы правила, включая потоковые лекции |
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | | `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа |
Генератор `ScheduleGeneratorService` рендерит расписание по запросу: Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона. 1. Определяет семестр для каждой даты диапазона.
@@ -95,9 +95,11 @@
6. Загружает правила группы или преподавателя. 6. Загружает правила группы или преподавателя.
7. Для каждого типа занятия проверяет свою неделю начала в семестре. 7. Для каждого типа занятия проверяет свою неделю начала в семестре.
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. 8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик. 9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы.
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`.
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
### Временные слоты ### Временные слоты
@@ -124,6 +126,7 @@
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
--- ---

View File

@@ -348,6 +348,8 @@ erDiagram
| `name` | VARCHAR(100) | Название подгруппы | | `name` | VARCHAR(100) | Название подгруппы |
| `student_capacity` | INT | Количество студентов | | `student_capacity` | INT | Количество студентов |
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
#### `subjects` — Дисциплины #### `subjects` — Дисциплины
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
@@ -532,12 +534,14 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели: 1 — понедельник | | `day_of_week` | INT CHECK(17) | День недели: 1 — понедельник |
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | | `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | | `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы |
| `teacher_id` | BIGINT FK → users | Преподаватель | | `teacher_id` | BIGINT FK → users | Преподаватель |
| `classroom_id` | BIGINT FK → classrooms | Аудитория | | `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`.
--- ---
## Flyway миграции ## Flyway миграции

View File

@@ -25,7 +25,7 @@
| `time_slots` | Настраиваемая сетка пар | | `time_slots` | Настраиваемая сетка пар |
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
| `schedule_rule_groups` | Группы правила | | `schedule_rule_groups` | Группы правила |
| `schedule_rule_slots` | Шаблонные слоты правила | | `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу |
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
@@ -40,7 +40,7 @@
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. `ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
## API ## API
@@ -64,6 +64,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
| `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки | | `/api/admin/academic-calendars/{id}/grid` | Получение и сохранение дневной сетки |
| `/api/specialties/{id}/profiles` | CRUD профилей специальности | | `/api/specialties/{id}/profiles` | CRUD профилей специальности |
| `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | | `/api/groups/{id}/calendar-assignments` | Назначения графиков группе |
| `/api/subgroups`, `/api/groups/{id}/subgroups` | Управление подгруппами для лабораторных |
| `/api/admin/schedule-rules` | CRUD правил расписания | | `/api/admin/schedule-rules` | CRUD правил расписания |
## Frontend ## Frontend
@@ -72,12 +73,13 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
- конструктор правил динамического расписания; - конструктор правил динамического расписания;
- отдельные поля часов и недель начала для лекций, лабораторных и практик; - отдельные поля часов и недель начала для лекций, лабораторных и практик;
- выбор подгруппы только для лабораторного слота;
- управление временными слотами; - управление временными слотами;
- учебные годы и семестры; - учебные годы и семестры;
- CRUD календарных графиков; - CRUD календарных графиков;
- Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. - Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей.
Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год. Экран `groups` создаёт и редактирует группы с профилем обучения, управляет подгруппами для лабораторных и назначает календарный график группе на учебный год.
Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей.

View File

@@ -106,13 +106,13 @@ frontend/
| Tab | Описание | API | | Tab | Описание | API |
|-----|----------|-----| |-----|----------|-----|
| `users` | CRUD пользователей | `/api/users` | | `users` | CRUD пользователей | `/api/users` |
| `groups` | CRUD групп | `/api/groups` | | `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
| `edu-forms` | Формы обучения | `/api/education-forms` | | `edu-forms` | Формы обучения | `/api/education-forms` |
| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` |
| `equipments` | Оборудование | `/api/equipments` | | `equipments` | Оборудование | `/api/equipments` |
| `classrooms` | Аудитории | `/api/classrooms` | | `classrooms` | Аудитории | `/api/classrooms` |
| `subjects` | Дисциплины | `/api/subjects` | | `subjects` | Дисциплины | `/api/subjects` |
| `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | | `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` |
| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` |
| `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` | | `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` |
| `database` | Тенанты | `/api/database` | | `database` | Тенанты | `/api/database` |
@@ -120,10 +120,10 @@ frontend/
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API. - Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Из календарной системы здесь используется список семестров для выбора периода действия правила. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. В слоте подгруппа доступна только при выборе лабораторной работы; для лекций и практик селект подгруппы заблокирован. Из календарной системы здесь используется список семестров для выбора периода действия правила.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
@@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() {
- навигация по неделям: предыдущая, текущая, следующая; - навигация по неделям: предыдущая, текущая, следующая;
- выбор даты через `input[type="date"]`; - выбор даты через `input[type="date"]`;
- запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила. - отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила.
Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново.
@@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() {
- селект группы с сохранением выбора в `localStorage.studentGroupId`; - селект группы с сохранением выбора в `localStorage.studentGroupId`;
- недельная сетка по дням; - недельная сетка по дням;
- запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; - запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`;
- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия. - отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы.
--- ---

View File

@@ -8,7 +8,7 @@
- календарный учебный график по специальности, профилю, форме обучения и учебному году; - календарный учебный график по специальности, профилю, форме обучения и учебному году;
- правила расписания дисциплин; - правила расписания дисциплин;
- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия и формат; - слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных;
- назначение конкретного календарного графика учебной группе на учебный год. - назначение конкретного календарного графика учебной группе на учебный год.
При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются.
@@ -29,7 +29,7 @@
- `time_slots` задаёт сетку пар для тенанта. - `time_slots` задаёт сетку пар для тенанта.
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
- `schedule_rule_groups` связывает правило с одной или несколькими группами. - `schedule_rule_groups` связывает правило с одной или несколькими группами.
- `schedule_rule_slots` хранит шаблонные занятия правила. - `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ.
## Алгоритм генерации ## Алгоритм генерации
@@ -39,7 +39,9 @@
4. По курсу и дате находится код активности. 4. По курсу и дате находится код активности.
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. 5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
6. Для разрешённого дня выбираются активные правила и слоты. 6. Для разрешённого дня выбираются активные правила и слоты.
7. Лимит часов считается только по реально проведённым занятиям. 7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы.
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.

View File

@@ -12,6 +12,7 @@
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы.
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
- [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Обновлены админские экраны специальностей, групп и расписания.
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.

View File

@@ -164,7 +164,7 @@
.schedule-slot-row { .schedule-slot-row {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(130px, 1fr)) auto; grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
gap: 0.75rem; gap: 0.75rem;
align-items: end; align-items: end;
padding: 0.75rem; padding: 0.75rem;

View File

@@ -22,8 +22,17 @@ export async function initGroups() {
const calendarYearSelect = document.getElementById('calendar-year'); const calendarYearSelect = document.getElementById('calendar-year');
const calendarSelect = document.getElementById('calendar-id'); const calendarSelect = document.getElementById('calendar-id');
const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody'); const calendarAssignmentsTbody = document.getElementById('group-calendar-tbody');
const subgroupForm = document.getElementById('subgroup-form');
const subgroupIdInput = document.getElementById('subgroup-id');
const subgroupGroupSelect = document.getElementById('subgroup-group');
const subgroupNameInput = document.getElementById('subgroup-name');
const subgroupCapacityInput = document.getElementById('subgroup-capacity');
const subgroupSubmitButton = document.getElementById('subgroup-submit');
const subgroupResetButton = document.getElementById('subgroup-reset');
const subgroupsTbody = document.getElementById('subgroups-tbody');
let allGroups = []; let allGroups = [];
let subgroups = [];
let educationForms = []; let educationForms = [];
let departments = []; let departments = [];
let specialties = []; let specialties = [];
@@ -47,7 +56,11 @@ export async function initGroups() {
createGroupForm.addEventListener('submit', createGroup); createGroupForm.addEventListener('submit', createGroup);
editGroupForm.addEventListener('submit', updateGroup); editGroupForm.addEventListener('submit', updateGroup);
calendarForm.addEventListener('submit', saveAssignment); calendarForm.addEventListener('submit', saveAssignment);
subgroupForm.addEventListener('submit', saveSubgroup);
subgroupGroupSelect.addEventListener('change', renderSubgroups);
subgroupResetButton.addEventListener('click', resetSubgroupForm);
groupsTbody.addEventListener('click', handleGroupTableClick); groupsTbody.addEventListener('click', handleGroupTableClick);
subgroupsTbody.addEventListener('click', handleSubgroupTableClick);
calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick);
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
modalEditGroup.addEventListener('click', (event) => { modalEditGroup.addEventListener('click', (event) => {
@@ -73,8 +86,10 @@ export async function initGroups() {
populateYearSelect(); populateYearSelect();
populateCalendarSelect(); populateCalendarSelect();
await loadGroups(); await loadGroups();
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="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -91,12 +106,22 @@ export async function initGroups() {
allGroups = await api.get('/api/groups'); allGroups = await api.get('/api/groups');
applyGroupFilter(); applyGroupFilter();
populateGroupSelect(); populateGroupSelect();
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="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
} }
} }
async function loadSubgroups() {
try {
subgroups = await api.get('/api/subgroups');
renderSubgroups();
} catch (error) {
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function applyGroupFilter() { function applyGroupFilter() {
const filterId = filterEfSelect.value; const filterId = filterEfSelect.value;
const filtered = filterId const filtered = filterId
@@ -217,6 +242,18 @@ export async function initGroups() {
syncSelects(calendarGroupSelect); syncSelects(calendarGroupSelect);
} }
function populateSubgroupGroupSelect() {
const current = subgroupGroupSelect.value;
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
if (current && allGroups.some(group => String(group.id) === String(current))) {
subgroupGroupSelect.value = current;
} else if (allGroups.length) {
subgroupGroupSelect.value = String(allGroups[0].id);
}
syncSelects(subgroupGroupSelect);
}
function populateCalendarSelect() { function populateCalendarSelect() {
const group = selectedCalendarGroup(); const group = selectedCalendarGroup();
const yearId = calendarYearSelect.value; const yearId = calendarYearSelect.value;
@@ -248,6 +285,7 @@ export async function initGroups() {
createGroupForm.reset(); createGroupForm.reset();
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
} }
@@ -265,6 +303,7 @@ export async function initGroups() {
modalEditGroup.classList.remove('open'); modalEditGroup.classList.remove('open');
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
} }
@@ -309,6 +348,7 @@ export async function initGroups() {
try { try {
await api.delete('/api/groups/' + btnDelete.dataset.id); await api.delete('/api/groups/' + btnDelete.dataset.id);
await loadGroups(); await loadGroups();
await loadSubgroups();
} catch (error) { } catch (error) {
alert(error.message || 'Ошибка удаления'); alert(error.message || 'Ошибка удаления');
} }
@@ -339,6 +379,100 @@ export async function initGroups() {
modalEditGroup.classList.add('open'); modalEditGroup.classList.add('open');
} }
async function saveSubgroup(event) {
event.preventDefault();
hideAlert('subgroup-alert');
const groupId = subgroupGroupSelect.value;
const name = subgroupNameInput.value.trim();
const capacity = subgroupCapacityInput.value;
const subgroupId = subgroupIdInput.value;
if (!groupId) { showAlert('subgroup-alert', 'Выберите группу', 'error'); return; }
if (!name) { showAlert('subgroup-alert', 'Введите название подгруппы', 'error'); return; }
if (capacity && Number(capacity) < 0) { showAlert('subgroup-alert', 'Численность не может быть отрицательной', 'error'); return; }
const payload = {
name,
studentCapacity: capacity ? Number(capacity) : null
};
try {
const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`;
const saved = subgroupId
? await api.put(endpoint, payload)
: await api.post(endpoint, payload);
showAlert('subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success');
resetSubgroupForm();
await loadSubgroups();
} catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
}
}
function renderSubgroups() {
const groupId = subgroupGroupSelect.value;
const visibleSubgroups = groupId
? subgroups.filter(subgroup => String(subgroup.groupId) === String(groupId))
: subgroups;
if (!visibleSubgroups.length) {
subgroupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Подгруппы не созданы</td></tr>';
return;
}
subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => `
<tr>
<td>${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))}</td>
<td>${escapeHtml(subgroup.name)}</td>
<td>${escapeHtml(String(subgroup.studentCapacity ?? '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${subgroup.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${subgroup.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function handleSubgroupTableClick(event) {
const editButton = event.target.closest('.btn-edit-subgroup');
const deleteButton = event.target.closest('.btn-delete-subgroup');
if (editButton) {
const subgroup = subgroups.find(item => String(item.id) === String(editButton.dataset.id));
if (!subgroup) return;
subgroupIdInput.value = subgroup.id;
subgroupGroupSelect.value = subgroup.groupId;
subgroupGroupSelect.disabled = true;
subgroupNameInput.value = subgroup.name || '';
subgroupCapacityInput.value = subgroup.studentCapacity ?? '';
subgroupSubmitButton.textContent = 'Сохранить подгруппу';
syncSelects(subgroupGroupSelect);
hideAlert('subgroup-alert');
return;
}
if (deleteButton) {
const subgroup = subgroups.find(item => String(item.id) === String(deleteButton.dataset.id));
if (!subgroup || !confirm('Удалить подгруппу?')) return;
try {
await api.delete(`/api/groups/${subgroup.groupId}/subgroups/${subgroup.id}`);
showAlert('subgroup-alert', 'Подгруппа удалена', 'success');
resetSubgroupForm();
await loadSubgroups();
} catch (error) {
showAlert('subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
}
}
}
function resetSubgroupForm() {
subgroupIdInput.value = '';
subgroupNameInput.value = '';
subgroupCapacityInput.value = '';
subgroupGroupSelect.disabled = false;
subgroupSubmitButton.textContent = 'Создать подгруппу';
syncSelects(subgroupGroupSelect);
hideAlert('subgroup-alert');
}
async function saveAssignment(event) { async function saveAssignment(event) {
event.preventDefault(); event.preventDefault();
hideAlert('group-calendar-alert'); hideAlert('group-calendar-alert');
@@ -419,6 +553,11 @@ export async function initGroups() {
return department.departmentName || department.name || departmentId; return department.departmentName || department.name || departmentId;
} }
function groupName(groupId) {
const group = allGroups.find(item => String(item.id) === String(groupId));
return group ? group.name : groupId || '-';
}
function specialityLabel(specialityId) { function specialityLabel(specialityId) {
const speciality = specialties.find(item => item.id == specialityId); const speciality = specialties.find(item => item.id == specialityId);
if (!speciality) return specialityId || '-'; if (!speciality) return specialityId || '-';

View File

@@ -72,6 +72,7 @@ export async function initSchedule() {
let teachers = []; let teachers = [];
let classrooms = []; let classrooms = [];
let lessonTypes = []; let lessonTypes = [];
let subgroups = [];
let timeSlots = []; let timeSlots = [];
let semesters = []; let semesters = [];
@@ -99,17 +100,24 @@ export async function initSchedule() {
renderRuleSlots(slots.length ? slots : [{}]); renderRuleSlots(slots.length ? slots : [{}]);
}); });
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
ruleSlotsContainer.addEventListener('change', (event) => {
if (event.target.closest('.slot-lesson-type')) {
renderRuleSlots(readRuleSlots(false));
}
});
ruleForm.addEventListener('submit', saveRule); ruleForm.addEventListener('submit', saveRule);
rulesTbody.addEventListener('click', handleRuleTableClick); rulesTbody.addEventListener('click', handleRuleTableClick);
} }
async function loadBaseLists() { async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([ [subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/groups'), api.get('/api/groups'),
api.get('/api/users/teachers'), api.get('/api/users/teachers'),
api.get('/api/classrooms'), api.get('/api/classrooms'),
api.get('/api/lesson-types') api.get('/api/lesson-types'),
api.get('/api/subgroups')
]); ]);
populateSubjectSelects(); populateSubjectSelects();
} }
@@ -159,6 +167,7 @@ export async function initSchedule() {
slot.dayName, slot.dayName,
slot.timeSlotLabel, slot.timeSlotLabel,
parity, parity,
slot.subgroupName,
slot.teacherName, slot.teacherName,
slot.classroomName, slot.classroomName,
slot.lessonTypeName, slot.lessonTypeName,
@@ -290,6 +299,7 @@ export async function initSchedule() {
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div> <div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div> <div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div> <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</select></div>
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button> <button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div> </div>
@@ -299,12 +309,13 @@ export async function initSchedule() {
function readRuleSlots(validate) { function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => { return rows.map((row, index) => {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
const slot = { const slot = {
id: null, id: null,
dayOfWeek: Number(row.querySelector('.slot-day').value), dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value, parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value), timeSlotId: Number(row.querySelector('.slot-time').value),
subgroupId: null, subgroupId: subgroupValue ? Number(subgroupValue) : null,
teacherId: Number(row.querySelector('.slot-teacher').value), teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value), classroomId: Number(row.querySelector('.slot-classroom').value),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
@@ -314,6 +325,9 @@ export async function initSchedule() {
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) { || !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1}`); throw new Error(`Заполните слот ${index + 1}`);
} }
if (validate) {
validateSlotSubgroup(slot, index);
}
return slot; return slot;
}); });
} }
@@ -354,6 +368,23 @@ export async function initSchedule() {
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий'); if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
} }
function validateSlotSubgroup(slot, index) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
if (slot.subgroupId && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!slot.subgroupId) return;
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
}
async function loadTimeSlots() { async function loadTimeSlots() {
const slots = await api.get('/api/admin/time-slots'); const slots = await api.get('/api/admin/time-slots');
timeSlots = slots timeSlots = slots
@@ -382,6 +413,26 @@ export async function initSchedule() {
}).join(''); }).join('');
} }
function subgroupOptions(slot) {
if (!isLaboratorySlot(slot)) {
return '<option value="">Только для лабораторных</option>';
}
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
}).join('');
}
function selectedRuleGroupIds() {
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
}
function toTimeSlotOption(slot) { function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
} }
@@ -404,6 +455,14 @@ export async function initSchedule() {
return lessonType ? lessonTypeKey(lessonType) : null; return lessonType ? lessonTypeKey(lessonType) : null;
} }
function isLaboratorySlot(slot) {
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
}
function subgroupLabel(subgroup) {
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
}
function lessonTypeKey(lessonType) { function lessonTypeKey(lessonType) {
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase(); const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
if (name.includes('лекц')) return 'lecture'; if (name.includes('лекц')) return 'lecture';

View File

@@ -79,6 +79,49 @@
</div> </div>
</div> </div>
<div class="card create-card">
<h2>Подгруппы для лабораторных</h2>
<form id="subgroup-form">
<input type="hidden" id="subgroup-id">
<div class="form-row">
<div class="form-group">
<label for="subgroup-group">Группа</label>
<select id="subgroup-group">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="subgroup-name">Название подгруппы</label>
<input type="text" id="subgroup-name" placeholder="Подгруппа 1" required>
</div>
<div class="form-group">
<label for="subgroup-capacity">Численность</label>
<input type="number" id="subgroup-capacity" min="0" placeholder="12">
</div>
<button type="submit" class="btn-primary" id="subgroup-submit">Создать подгруппу</button>
<button type="button" class="btn-edit-classroom" id="subgroup-reset">Очистить</button>
</div>
<div class="form-alert" id="subgroup-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table>
<thead>
<tr>
<th>Группа</th>
<th>Подгруппа</th>
<th>Численность</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="subgroups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card"> <div class="card create-card">
<h2>Назначение календарного графика</h2> <h2>Назначение календарного графика</h2>
<form id="group-calendar-form"> <form id="group-calendar-form">

View File

@@ -422,6 +422,7 @@
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div> <div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta"> <div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span> <span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span> <span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span> <span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span> <span class="chip">${escapeHtml(lesson.classroomName)}</span>

View File

@@ -437,6 +437,7 @@
<div class="lesson-meta"> <div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span> <span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span> <span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span> <span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''} ${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div> </div>