From e12a5bcccd9735330586d4572af3eef5bed63e90 Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 6 May 2026 22:25:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D1=85=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleRuleAdminController.java | 26 +++- .../app/controller/SubgroupController.java | 139 ++++++++++++++++++ .../magistr/app/dto/RenderedLessonDto.java | 1 + .../magistr/app/dto/ScheduleRuleSlotDto.java | 1 + .../java/com/magistr/app/dto/SubgroupDto.java | 10 ++ .../magistr/app/model/ScheduleRuleSlot.java | 15 +- .../java/com/magistr/app/model/Subgroup.java | 54 +++++++ .../repository/ScheduleRuleRepository.java | 8 + .../ScheduleRuleSlotRepository.java | 5 + .../app/repository/SubgroupRepository.java | 46 ++++++ .../app/service/ScheduleGeneratorService.java | 51 +++++-- .../main/resources/db/migration/V1__init.sql | 43 ++++++ docs/API.md | 41 +++++- docs/BUSINESS_LOGIC.md | 7 +- docs/DATABASE.md | 6 +- docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 8 +- docs/FRONTEND.md | 12 +- docs/SCHEDULE_PROPOSAL.md | 8 +- docs/SCHEDULE_TASKS.md | 1 + frontend/admin/css/components.css | 2 +- frontend/admin/js/views/groups.js | 139 ++++++++++++++++++ frontend/admin/js/views/schedule.js | 65 +++++++- frontend/admin/views/groups.html | 43 ++++++ frontend/student/index.html | 1 + frontend/teacher/index.html | 1 + 25 files changed, 693 insertions(+), 40 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/controller/SubgroupController.java create mode 100644 backend/src/main/java/com/magistr/app/dto/SubgroupDto.java create mode 100644 backend/src/main/java/com/magistr/app/model/Subgroup.java create mode 100644 backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index 40258a1..3c36000 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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(), diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java new file mode 100644 index 0000000..7488d52 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 633219d..6b6ddcc 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -28,6 +28,7 @@ public record RenderedLessonDto( String lessonTypeName, String lessonFormat, Long subgroupId, + String subgroupName, List groupIds, List groupNames, String activityType, diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java index 6f01af3..6cb88cf 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleSlotDto.java @@ -11,6 +11,7 @@ public record ScheduleRuleSlotDto( Integer timeSlotOrder, String timeSlotLabel, Long subgroupId, + String subgroupName, Long teacherId, String teacherName, Long classroomId, diff --git a/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java new file mode 100644 index 0000000..b0747ef --- /dev/null +++ b/backend/src/main/java/com/magistr/app/dto/SubgroupDto.java @@ -0,0 +1,10 @@ +package com.magistr.app.dto; + +public record SubgroupDto( + Long id, + Long groupId, + String groupName, + String name, + Integer studentCapacity +) { +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java index a837635..63cfcf3 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java @@ -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() { diff --git a/backend/src/main/java/com/magistr/app/model/Subgroup.java b/backend/src/main/java/com/magistr/app/model/Subgroup.java new file mode 100644 index 0000000..851fec9 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/Subgroup.java @@ -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; + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index b4573c6..1577f1d 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -19,6 +19,8 @@ public interface ScheduleRuleRepository extends JpaRepository { + + @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); } diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java new file mode 100644 index 0000000..0a4ac8f --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -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 { + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup studentGroup + order by studentGroup.name, subgroup.name + """) + List findAllWithGroupsOrderByGroupNameAndName(); + + @Query(""" + select subgroup + from Subgroup subgroup + join fetch subgroup.studentGroup + where subgroup.studentGroup.id = :groupId + order by subgroup.name + """) + List 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 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); +} diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index 757df1c..0a98f9e 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -190,7 +190,7 @@ public class ScheduleGeneratorService { return; } - Map consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class); + Map 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 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 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 groupsForSlot(ScheduleRuleSlot slot, List 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()), "Т", diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 8beacef..0fe5465 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -119,6 +119,22 @@ CREATE TABLE IF NOT EXISTS subgroups ( 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_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_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 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 SELECT NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, diff --git a/docs/API.md b/docs/API.md index 75f8bf9..42caf0a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Лабораторные слоты можно назначать на подгруппы, лекции и практики всегда проводятся для всей выбранной группы или потока. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -148,6 +148,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "lessonTypeName": "Лекция", "lessonFormat": "Очно", "subgroupId": null, + "subgroupName": null, "groupIds": [1], "groupNames": ["ИВТ-21-1"], "activityType": "Т", @@ -291,7 +292,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "dayOfWeek": 1, "parity": "BOTH", "timeSlotId": 3, - "subgroupId": null, + "subgroupId": null, "teacherId": 2, "classroomId": 1, "lessonTypeId": 1, @@ -313,6 +314,8 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. +`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации. + Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. ### `GET /api/lesson-types` @@ -323,7 +326,8 @@ CRUD доступен по: ```json [ { "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 | Назначение | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 940eaa8..3b3ffe6 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -84,7 +84,7 @@ | `time_slots` | Настраиваемая сетка пар для тенанта | | `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам | | `schedule_rule_groups` | Группы правила, включая потоковые лекции | -| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия | +| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип, формат и опциональная лабораторная подгруппа | Генератор `ScheduleGeneratorService` рендерит расписание по запросу: 1. Определяет семестр для каждой даты диапазона. @@ -95,9 +95,11 @@ 6. Загружает правила группы или преподавателя. 7. Для каждого типа занятия проверяет свою неделю начала в семестре. 8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую. -9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик. +9. Считает уже проведённые часы отдельно для лекций, практик и каждой лабораторной подгруппы. 10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов. +Лабораторные работы могут делиться на подгруппы через `schedule_rule_slots.subgroup_id`. Если подгруппа выбрана, занятие выводится только для родительской группы этой подгруппы, а лимит лабораторных часов списывается отдельно по этой подгруппе. Лекции и практики не делятся на подгруппы: такие слоты сохраняются только с `subgroup_id = NULL`. + Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки. ### Временные слоты @@ -124,6 +126,7 @@ - **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов. - **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна. - **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД. +- **Подгруппы:** `subgroupId` разрешён только для лабораторных слотов и должен относиться к одной из групп правила. - **Формат:** `lessonFormat` обязателен и хранится в слоте правила. --- diff --git a/docs/DATABASE.md b/docs/DATABASE.md index f7f962a..d11be68 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -348,6 +348,8 @@ erDiagram | `name` | VARCHAR(100) | Название подгруппы | | `student_capacity` | INT | Количество студентов | +Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. + #### `subjects` — Дисциплины | Колонка | Тип | Описание | |---------|-----|----------| @@ -532,12 +534,14 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня | `day_of_week` | INT CHECK(1–7) | День недели: 1 — понедельник | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | | `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | -| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа | +| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы | | `teacher_id` | BIGINT FK → users | Преподаватель | | `classroom_id` | BIGINT FK → classrooms | Аудитория | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | +`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`. + --- ## Flyway миграции diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 79ed21f..7a06847 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -25,7 +25,7 @@ | `time_slots` | Настраиваемая сетка пар | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rule_groups` | Группы правила | -| `schedule_rule_slots` | Шаблонные слоты правила | +| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу | Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. @@ -40,7 +40,7 @@ | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | -`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. +`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. ## 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/specialties/{id}/profiles` | CRUD профилей специальности | | `/api/groups/{id}/calendar-assignments` | Назначения графиков группе | +| `/api/subgroups`, `/api/groups/{id}/subgroups` | Управление подгруппами для лабораторных | | `/api/admin/schedule-rules` | CRUD правил расписания | ## Frontend @@ -72,12 +73,13 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03 - конструктор правил динамического расписания; - отдельные поля часов и недель начала для лекций, лабораторных и практик; +- выбор подгруппы только для лабораторного слота; - управление временными слотами; - учебные годы и семестры; - CRUD календарных графиков; - Excel-подобный редактор дневной сетки по курсам, датам и кодам активностей. -Экран `groups` создаёт и редактирует группы с профилем обучения, а также назначает календарный график группе на учебный год. +Экран `groups` создаёт и редактирует группы с профилем обучения, управляет подгруппами для лабораторных и назначает календарный график группе на учебный год. Экран `departments-data` управляет кафедрами, специальностями и профилями специальностей. diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index d887377..6884331 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -106,13 +106,13 @@ frontend/ | Tab | Описание | API | |-----|----------|-----| | `users` | CRUD пользователей | `/api/users` | -| `groups` | CRUD групп | `/api/groups` | +| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `edu-forms` | Формы обучения | `/api/education-forms` | | `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `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` | | `auditorium-workload` | Динамическая загруженность аудиторий: аудитории в строках, временные слоты в столбцах | `/api/classrooms`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule` | | `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. - Вкладка `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` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. @@ -218,7 +218,7 @@ export function isAuthenticatedAsAdmin() { - навигация по неделям: предыдущая, текущая, следующая; - выбор даты через `input[type="date"]`; - запрос `GET /api/schedule?teacherId={userId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, типа занятия, аудитории и всех групп правила. +- отображение дисциплины, времени, типа занятия, лабораторной подгруппы, аудитории и всех групп правила. Если пользователь вошёл до появления поля `userId`, страница попросит выполнить вход заново. @@ -230,7 +230,7 @@ export function isAuthenticatedAsAdmin() { - селект группы с сохранением выбора в `localStorage.studentGroupId`; - недельная сетка по дням; - запрос `GET /api/schedule?groupId={groupId}&startDate={YYYY-MM-DD}&endDate={YYYY-MM-DD}`; -- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия. +- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы. --- diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index 3450083..2af646d 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -8,7 +8,7 @@ - календарный учебный график по специальности, профилю, форме обучения и учебному году; - правила расписания дисциплин; -- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия и формат; +- слоты правил: день недели, чётность, пара, преподаватель, аудитория, тип занятия, формат и опциональная подгруппа для лабораторных; - назначение конкретного календарного графика учебной группе на учебный год. При запросе `GET /api/schedule` backend рендерит расписание на диапазон дат. Если дата не является днём теоретического обучения по назначенному группе графику, обычные пары не выводятся и академические часы не списываются. @@ -29,7 +29,7 @@ - `time_slots` задаёт сетку пар для тенанта. - `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rule_groups` связывает правило с одной или несколькими группами. -- `schedule_rule_slots` хранит шаблонные занятия правила. +- `schedule_rule_slots` хранит шаблонные занятия правила; `subgroup_id` разрешён только для лабораторных работ. ## Алгоритм генерации @@ -39,7 +39,9 @@ 4. По курсу и дате находится код активности. 5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. 6. Для разрешённого дня выбираются активные правила и слоты. -7. Лимит часов считается только по реально проведённым занятиям. +7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. + +Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы. Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index 3af1334..fb220d7 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -12,6 +12,7 @@ - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. +- [x] Добавлены подгруппы для лабораторных слотов; лекции и практики не делятся на подгруппы. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 1cf7c2f..cd5a439 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -164,7 +164,7 @@ .schedule-slot-row { display: grid; - grid-template-columns: repeat(7, minmax(130px, 1fr)) auto; + grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; gap: 0.75rem; align-items: end; padding: 0.75rem; diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 70606ba..b1fa96b 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -22,8 +22,17 @@ export async function initGroups() { const calendarYearSelect = document.getElementById('calendar-year'); const calendarSelect = document.getElementById('calendar-id'); 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 subgroups = []; let educationForms = []; let departments = []; let specialties = []; @@ -47,7 +56,11 @@ export async function initGroups() { createGroupForm.addEventListener('submit', createGroup); editGroupForm.addEventListener('submit', updateGroup); calendarForm.addEventListener('submit', saveAssignment); + subgroupForm.addEventListener('submit', saveSubgroup); + subgroupGroupSelect.addEventListener('change', renderSubgroups); + subgroupResetButton.addEventListener('click', resetSubgroupForm); groupsTbody.addEventListener('click', handleGroupTableClick); + subgroupsTbody.addEventListener('click', handleSubgroupTableClick); calendarAssignmentsTbody.addEventListener('click', handleAssignmentTableClick); modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open')); modalEditGroup.addEventListener('click', (event) => { @@ -73,8 +86,10 @@ export async function initGroups() { populateYearSelect(); populateCalendarSelect(); await loadGroups(); + await loadSubgroups(); } catch (error) { groupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; + subgroupsTbody.innerHTML = `Ошибка загрузки данных: ${escapeHtml(error.message)}`; } } @@ -91,12 +106,22 @@ export async function initGroups() { allGroups = await api.get('/api/groups'); applyGroupFilter(); populateGroupSelect(); + populateSubgroupGroupSelect(); await loadAssignments(); } catch (error) { groupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } + async function loadSubgroups() { + try { + subgroups = await api.get('/api/subgroups'); + renderSubgroups(); + } catch (error) { + subgroupsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + function applyGroupFilter() { const filterId = filterEfSelect.value; const filtered = filterId @@ -217,6 +242,18 @@ export async function initGroups() { syncSelects(calendarGroupSelect); } + function populateSubgroupGroupSelect() { + const current = subgroupGroupSelect.value; + subgroupGroupSelect.innerHTML = '' + + allGroups.map(group => ``).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() { const group = selectedCalendarGroup(); const yearId = calendarYearSelect.value; @@ -248,6 +285,7 @@ export async function initGroups() { createGroupForm.reset(); syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect); await loadGroups(); + await loadSubgroups(); } catch (error) { showAlert('create-group-alert', error.message || 'Ошибка создания', 'error'); } @@ -265,6 +303,7 @@ export async function initGroups() { modalEditGroup.classList.remove('open'); showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success'); await loadGroups(); + await loadSubgroups(); } catch (error) { showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error'); } @@ -309,6 +348,7 @@ export async function initGroups() { try { await api.delete('/api/groups/' + btnDelete.dataset.id); await loadGroups(); + await loadSubgroups(); } catch (error) { alert(error.message || 'Ошибка удаления'); } @@ -339,6 +379,100 @@ export async function initGroups() { 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 = 'Подгруппы не созданы'; + return; + } + subgroupsTbody.innerHTML = visibleSubgroups.map(subgroup => ` + + ${escapeHtml(subgroup.groupName || groupName(subgroup.groupId))} + ${escapeHtml(subgroup.name)} + ${escapeHtml(String(subgroup.studentCapacity ?? '-'))} + + + + + + `).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) { event.preventDefault(); hideAlert('group-calendar-alert'); @@ -419,6 +553,11 @@ export async function initGroups() { 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) { const speciality = specialties.find(item => item.id == specialityId); if (!speciality) return specialityId || '-'; diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index accc674..ce8c626 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -72,6 +72,7 @@ export async function initSchedule() { let teachers = []; let classrooms = []; let lessonTypes = []; + let subgroups = []; let timeSlots = []; let semesters = []; @@ -99,17 +100,24 @@ export async function initSchedule() { 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); rulesTbody.addEventListener('click', handleRuleTableClick); } 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/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), - api.get('/api/lesson-types') + api.get('/api/lesson-types'), + api.get('/api/subgroups') ]); populateSubjectSelects(); } @@ -159,6 +167,7 @@ export async function initSchedule() { slot.dayName, slot.timeSlotLabel, parity, + slot.subgroupName, slot.teacherName, slot.classroomName, slot.lessonTypeName, @@ -290,6 +299,7 @@ export async function initSchedule() {
+
@@ -299,12 +309,13 @@ export async function initSchedule() { function readRuleSlots(validate) { const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); return rows.map((row, index) => { + const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; const slot = { id: null, dayOfWeek: Number(row.querySelector('.slot-day').value), parity: row.querySelector('.slot-parity').value, timeSlotId: Number(row.querySelector('.slot-time').value), - subgroupId: null, + subgroupId: subgroupValue ? Number(subgroupValue) : null, teacherId: Number(row.querySelector('.slot-teacher').value), classroomId: Number(row.querySelector('.slot-classroom').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)) { throw new Error(`Заполните слот ${index + 1}`); } + if (validate) { + validateSlotSubgroup(slot, index); + } return slot; }); } @@ -354,6 +368,23 @@ export async function initSchedule() { 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() { const slots = await api.get('/api/admin/time-slots'); timeSlots = slots @@ -382,6 +413,26 @@ export async function initSchedule() { }).join(''); } + function subgroupOptions(slot) { + if (!isLaboratorySlot(slot)) { + return ''; + } + 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 '' + matchingSubgroups.map(subgroup => { + const value = String(subgroup.id); + return ``; + }).join(''); + } + + function selectedRuleGroupIds() { + return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) + .map(input => Number(input.value)); + } + function toTimeSlotOption(slot) { 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; } + function isLaboratorySlot(slot) { + return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory'; + } + + function subgroupLabel(subgroup) { + return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`; + } + function lessonTypeKey(lessonType) { const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase(); if (name.includes('лекц')) return 'lecture'; diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index cdba50c..879269a 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -79,6 +79,49 @@ +
+

Подгруппы для лабораторных

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ + + + + + + + + + + + + + +
ГруппаПодгруппаЧисленностьДействия
Загрузка...
+
+
+

Назначение календарного графика

diff --git a/frontend/student/index.html b/frontend/student/index.html index 4ca5a2f..4b83cae 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -422,6 +422,7 @@
${escapeHtml(lesson.subjectName)}
${escapeHtml(lesson.lessonTypeName)} + ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} ${escapeHtml(lesson.lessonFormat)} ${escapeHtml(lesson.teacherName)} ${escapeHtml(lesson.classroomName)} diff --git a/frontend/teacher/index.html b/frontend/teacher/index.html index 43d82f4..f15428c 100644 --- a/frontend/teacher/index.html +++ b/frontend/teacher/index.html @@ -437,6 +437,7 @@
${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))} ${escapeHtml(lesson.lessonTypeName)} + ${lesson.subgroupName ? `${escapeHtml(lesson.subgroupName)}` : ''} ${escapeHtml(lesson.classroomName)} ${groups ? `${escapeHtml(groups)}` : ''}