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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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": "Т",
@@ -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 | Назначение |

View File

@@ -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` обязателен и хранится в слоте правила.
---

View File

@@ -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(17) | День недели: 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 миграции

View File

@@ -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` управляет кафедрами, специальностями и профилями специальностей.

View File

@@ -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}`;
- отображение дисциплины, времени, преподавателя, аудитории, формата и типа занятия.
- отображение дисциплины, времени, преподавателя, аудитории, формата, типа занятия и лабораторной подгруппы.
---

View File

@@ -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. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
Лекции и практики нельзя делить на подгруппы. Если лабораторный слот привязан к подгруппе, он выводится только для родительской группы этой подгруппы.
Если назначение графика отсутствует, расписание группы возвращается пустым списком без ошибки.

View File

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

View File

@@ -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;

View File

@@ -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 = `<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');
applyGroupFilter();
populateGroupSelect();
populateSubgroupGroupSelect();
await loadAssignments();
} catch (error) {
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() {
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 = '<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() {
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 = '<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) {
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 || '-';

View File

@@ -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() {
<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-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>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div>
@@ -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 '<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) {
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';

View File

@@ -79,6 +79,49 @@
</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">
<h2>Назначение календарного графика</h2>
<form id="group-calendar-form">

View File

@@ -422,6 +422,7 @@
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<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.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>

View File

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