подправил создание правил расписания

This commit is contained in:
Zuev
2026-05-08 13:41:25 +03:00
parent e12a5bcccd
commit 02c5ea9118
20 changed files with 592 additions and 126 deletions

View File

@@ -191,7 +191,7 @@ public class ScheduleRuleAdminController {
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId()) LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден")); .orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType); ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(lessonType);
Subgroup subgroup = resolveSubgroup(rule, slotDto.subgroupId(), category); Set<Subgroup> subgroups = resolveSubgroups(rule, slotDto, category);
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) { if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
throw new IllegalArgumentException("Формат занятия обязателен"); throw new IllegalArgumentException("Формат занятия обязателен");
} }
@@ -201,7 +201,7 @@ public class ScheduleRuleAdminController {
slot.setDayOfWeek(slotDto.dayOfWeek()); slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity()); slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot); slot.setTimeSlot(timeSlot);
slot.setSubgroup(subgroup); slot.setSubgroups(subgroups);
slot.setTeacher(teacher); slot.setTeacher(teacher);
slot.setClassroom(classroom); slot.setClassroom(classroom);
slot.setLessonType(lessonType); slot.setLessonType(lessonType);
@@ -209,21 +209,44 @@ public class ScheduleRuleAdminController {
return slot; return slot;
} }
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) { private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
if (subgroupId == null) { LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
return null; if (slotDto.subgroupIds() != null) {
slotDto.subgroupIds().stream()
.filter(Objects::nonNull)
.forEach(subgroupIds::add);
} }
if (slotDto.subgroupId() != null) {
subgroupIds.add(slotDto.subgroupId());
}
if (subgroupIds.isEmpty()) {
return new LinkedHashSet<>();
}
if (category != ScheduleLessonCategory.LABORATORY) { if (category != ScheduleLessonCategory.LABORATORY) {
throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий"); throw new IllegalArgumentException("Подгруппы можно выбирать только для лабораторных занятий");
} }
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена")); Set<Long> ruleGroupIds = rule.getGroups().stream()
boolean subgroupBelongsToRuleGroups = rule.getGroups().stream() .map(StudentGroup::getId)
.anyMatch(group -> group.getId().equals(subgroup.getStudentGroup().getId())); .collect(java.util.stream.Collectors.toSet());
if (!subgroupBelongsToRuleGroups) { Set<Long> subgroupGroupIds = new HashSet<>();
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила"); LinkedHashSet<Subgroup> subgroups = new LinkedHashSet<>();
for (Long subgroupId : subgroupIds) {
Subgroup subgroup = subgroupRepository.findById(subgroupId)
.orElseThrow(() -> new IllegalArgumentException("Подгруппа не найдена"));
Long subgroupGroupId = subgroup.getStudentGroup().getId();
if (!ruleGroupIds.contains(subgroupGroupId)) {
throw new IllegalArgumentException("Подгруппа должна относиться к одной из групп правила");
}
if (!subgroupGroupIds.add(subgroupGroupId)) {
throw new IllegalArgumentException("В одном слоте можно выбрать не больше одной подгруппы каждой группы");
}
subgroups.add(subgroup);
} }
return subgroup;
return subgroups;
} }
private String validateTypeHours(ScheduleRuleDto request) { private String validateTypeHours(ScheduleRuleDto request) {
@@ -318,6 +341,7 @@ public class ScheduleRuleAdminController {
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) { private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot(); TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto( return new ScheduleRuleSlotDto(
slot.getId(), slot.getId(),
slot.getDayOfWeek(), slot.getDayOfWeek(),
@@ -326,8 +350,10 @@ public class ScheduleRuleAdminController {
timeSlot.getId(), timeSlot.getId(),
timeSlot.getOrderNumber(), timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot), formatTimeSlot(timeSlot),
slot.getSubgroupId(), sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(), slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()), ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(), slot.getClassroom().getId(),
@@ -348,4 +374,16 @@ public class ScheduleRuleAdminController {
private String formatTimeSlot(TimeSlot timeSlot) { private String formatTimeSlot(TimeSlot timeSlot) {
return timeSlot.getStartTime() + " - " + timeSlot.getEndTime(); return timeSlot.getStartTime() + " - " + timeSlot.getEndTime();
} }
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private List<Subgroup> sortedSubgroups(ScheduleRuleSlot slot) {
return slot.getSubgroups().stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
}
} }

View File

@@ -29,6 +29,8 @@ public record RenderedLessonDto(
String lessonFormat, String lessonFormat,
Long subgroupId, Long subgroupId,
String subgroupName, String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
List<Long> groupIds, List<Long> groupIds,
List<String> groupNames, List<String> groupNames,
String activityType, String activityType,

View File

@@ -2,6 +2,8 @@ package com.magistr.app.dto;
import com.magistr.app.model.ScheduleParity; import com.magistr.app.model.ScheduleParity;
import java.util.List;
public record ScheduleRuleSlotDto( public record ScheduleRuleSlotDto(
Long id, Long id,
Integer dayOfWeek, Integer dayOfWeek,
@@ -12,6 +14,8 @@ public record ScheduleRuleSlotDto(
String timeSlotLabel, String timeSlotLabel,
Long subgroupId, Long subgroupId,
String subgroupName, String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
Long teacherId, Long teacherId,
String teacherName, String teacherName,
Long classroomId, Long classroomId,

View File

@@ -2,6 +2,10 @@ package com.magistr.app.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Entity @Entity
@Table(name = "schedule_rule_slots") @Table(name = "schedule_rule_slots")
public class ScheduleRuleSlot { public class ScheduleRuleSlot {
@@ -25,9 +29,13 @@ public class ScheduleRuleSlot {
@JoinColumn(name = "time_slot_id", nullable = false) @JoinColumn(name = "time_slot_id", nullable = false)
private TimeSlot timeSlot; private TimeSlot timeSlot;
@ManyToOne @ManyToMany
@JoinColumn(name = "subgroup_id") @JoinTable(
private Subgroup subgroup; name = "schedule_rule_slot_subgroups",
joinColumns = @JoinColumn(name = "schedule_rule_slot_id"),
inverseJoinColumns = @JoinColumn(name = "subgroup_id")
)
private Set<Subgroup> subgroups = new LinkedHashSet<>();
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false) @JoinColumn(name = "teacher_id", nullable = false)
@@ -85,15 +93,41 @@ public class ScheduleRuleSlot {
} }
public Long getSubgroupId() { public Long getSubgroupId() {
return subgroup == null ? null : subgroup.getId(); return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
} }
public Subgroup getSubgroup() { public Subgroup getSubgroup() {
return subgroup; return subgroups.stream().findFirst().orElse(null);
} }
public void setSubgroup(Subgroup subgroup) { public void setSubgroup(Subgroup subgroup) {
this.subgroup = subgroup; subgroups.clear();
if (subgroup != null) {
subgroups.add(subgroup);
}
}
public Set<Subgroup> getSubgroups() {
return subgroups;
}
public void setSubgroups(Set<Subgroup> subgroups) {
this.subgroups.clear();
if (subgroups != null) {
this.subgroups.addAll(subgroups);
}
}
public List<Long> getSubgroupIds() {
return subgroups.stream()
.map(Subgroup::getId)
.toList();
}
public List<String> getSubgroupNames() {
return subgroups.stream()
.map(Subgroup::getName)
.toList();
} }
public User getTeacher() { public User getTeacher() {

View File

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

View File

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

View File

@@ -201,22 +201,54 @@ public class ScheduleGeneratorService {
if (totalHours <= 0) { if (totalHours <= 0) {
continue; continue;
} }
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups); List<LessonScope> lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups);
if (lessonGroups.isEmpty()) { if (lessonScopes.isEmpty()) {
continue; continue;
} }
String consumptionBucket = consumptionBucket(category, slot);
int consumedHours = consumedHoursByBucket.computeIfAbsent( List<ActiveLessonScope> activeScopes = new ArrayList<>();
consumptionBucket, for (LessonScope scope : lessonScopes) {
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId()) String consumptionBucket = consumptionBucket(category, scope.subgroupId());
); int consumedHours = consumedHoursByBucket.computeIfAbsent(
if (consumedHours >= totalHours) { consumptionBucket,
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, scope.subgroupId())
);
if (consumedHours >= totalHours) {
continue;
}
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
activeScopes.add(new ActiveLessonScope(scope, consumedHours, remainingAfterLesson));
}
if (activeScopes.isEmpty()) {
continue; continue;
} }
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, Map<Long, StudentGroup> lessonGroupById = new LinkedHashMap<>();
Map<Long, Subgroup> lessonSubgroupById = new LinkedHashMap<>();
activeScopes.forEach(scope -> {
scope.scope().groups().forEach(group -> lessonGroupById.putIfAbsent(group.getId(), group));
scope.scope().subgroups().forEach(subgroup -> lessonSubgroupById.putIfAbsent(subgroup.getId(), subgroup));
});
List<StudentGroup> lessonGroups = new ArrayList<>(lessonGroupById.values());
List<Subgroup> lessonSubgroups = new ArrayList<>(lessonSubgroupById.values());
int consumedHours = activeScopes.stream()
.mapToInt(ActiveLessonScope::consumedHours)
.min()
.orElse(0);
int remainingAfterLesson = activeScopes.stream()
.mapToInt(ActiveLessonScope::remainingAfterLesson)
.min()
.orElse(0);
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, lessonGroups, lessonSubgroups,
totalHours, consumedHours, remainingAfterLesson)); totalHours, consumedHours, remainingAfterLesson));
consumedHoursByBucket.put(consumptionBucket, consumedHours + ACADEMIC_HOURS_PER_SLOT); for (ActiveLessonScope scope : activeScopes) {
consumedHoursByBucket.put(
consumptionBucket(category, scope.scope().subgroupId()),
scope.consumedHours() + ACADEMIC_HOURS_PER_SLOT
);
}
} }
} }
@@ -272,8 +304,8 @@ public class ScheduleGeneratorService {
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek)) .filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity) .filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category) .filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
.filter(slot -> Objects.equals(slot.getSubgroupId(), subgroupId)) .flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
.filter(slot -> !groupsForSlot(slot, eligibleGroups).isEmpty()) .filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
.count(); .count();
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT; consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
if (consumed >= rule.academicHoursFor(category)) { if (consumed >= rule.academicHoursFor(category)) {
@@ -283,26 +315,51 @@ public class ScheduleGeneratorService {
return consumed; return consumed;
} }
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) { private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId()); return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
} }
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) { private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
if (slot.getSubgroup() == null) { ScheduleLessonCategory category,
return eligibleGroups; List<StudentGroup> eligibleGroups) {
if (category != ScheduleLessonCategory.LABORATORY || slot.getSubgroups().isEmpty()) {
return eligibleGroups.isEmpty()
? List.of()
: List.of(new LessonScope(eligibleGroups, List.of(), null));
} }
Long subgroupGroupId = slot.getSubgroup().getStudentGroup().getId();
return eligibleGroups.stream() Set<Long> eligibleGroupIds = eligibleGroups.stream()
.filter(group -> Objects.equals(group.getId(), subgroupGroupId)) .map(StudentGroup::getId)
.collect(Collectors.toSet());
Map<Long, StudentGroup> eligibleGroupById = eligibleGroups.stream()
.collect(Collectors.toMap(StudentGroup::getId, group -> group));
return slot.getSubgroups().stream()
.filter(subgroup -> eligibleGroupIds.contains(subgroup.getStudentGroup().getId()))
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.map(subgroup -> new LessonScope(
List.of(eligibleGroupById.get(subgroup.getStudentGroup().getId())),
List.of(subgroup),
subgroup.getId()
))
.toList(); .toList();
} }
private record LessonScope(List<StudentGroup> groups, List<Subgroup> subgroups, Long subgroupId) {
}
private record ActiveLessonScope(LessonScope scope, int consumedHours, int remainingAfterLesson) {
}
private RenderedLessonDto toRenderedLesson(ScheduleRule rule, private RenderedLessonDto toRenderedLesson(ScheduleRule rule,
ScheduleRuleSlot slot, ScheduleRuleSlot slot,
LocalDate date, LocalDate date,
int weekNumber, int weekNumber,
ScheduleParity dateParity, ScheduleParity dateParity,
List<StudentGroup> groups, List<StudentGroup> groups,
List<Subgroup> subgroups,
int lessonTypeAcademicHours, int lessonTypeAcademicHours,
int consumedBeforeLesson, int consumedBeforeLesson,
int remainingAfterLesson) { int remainingAfterLesson) {
@@ -310,6 +367,11 @@ public class ScheduleGeneratorService {
List<StudentGroup> sortedGroups = groups.stream() List<StudentGroup> sortedGroups = groups.stream()
.sorted(Comparator.comparing(StudentGroup::getName)) .sorted(Comparator.comparing(StudentGroup::getName))
.toList(); .toList();
List<Subgroup> sortedSubgroups = subgroups.stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
return new RenderedLessonDto( return new RenderedLessonDto(
rule.getId(), rule.getId(),
@@ -332,8 +394,10 @@ public class ScheduleGeneratorService {
slot.getLessonType().getId(), slot.getLessonType().getId(),
slot.getLessonType().getLessonType(), slot.getLessonType().getLessonType(),
slot.getLessonFormat(), slot.getLessonFormat(),
slot.getSubgroupId(), sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null,
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(), sortedSubgroups.size() == 1 ? formatSubgroup(sortedSubgroups.get(0)) : null,
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
sortedGroups.stream().map(StudentGroup::getId).toList(), sortedGroups.stream().map(StudentGroup::getId).toList(),
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()), sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
"Т", "Т",
@@ -343,6 +407,10 @@ public class ScheduleGeneratorService {
); );
} }
private String formatSubgroup(Subgroup subgroup) {
return subgroup.getStudentGroup().getName() + ": " + subgroup.getName();
}
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) { private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null) { if (baseSlot == null || baseSlot.getOrderNumber() == null) {
return baseSlot; return baseSlot;

View File

@@ -515,7 +515,6 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
day_of_week INT NOT NULL, day_of_week INT NOT NULL,
parity VARCHAR(10) NOT NULL, parity VARCHAR(10) NOT NULL,
time_slot_id BIGINT NOT NULL REFERENCES time_slots(id), time_slot_id BIGINT NOT NULL REFERENCES time_slots(id),
subgroup_id BIGINT NULL REFERENCES subgroups(id) ON DELETE SET NULL,
teacher_id BIGINT NOT NULL REFERENCES users(id), teacher_id BIGINT NOT NULL REFERENCES users(id),
classroom_id BIGINT NOT NULL REFERENCES classrooms(id), classroom_id BIGINT NOT NULL REFERENCES classrooms(id),
lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id), lesson_type_id BIGINT NOT NULL REFERENCES lesson_types(id),
@@ -528,34 +527,84 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_rule ON schedule_rule_slots(schedule_rule_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_teacher ON schedule_rule_slots(teacher_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_classroom ON schedule_rule_slots(classroom_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_subgroup ON schedule_rule_slots(subgroup_id);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id); CREATE INDEX IF NOT EXISTS idx_schedule_rule_slots_time ON schedule_rule_slots(day_of_week, parity, time_slot_id);
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroup() CREATE TABLE IF NOT EXISTS schedule_rule_slot_subgroups (
schedule_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id) ON DELETE CASCADE,
subgroup_id BIGINT NOT NULL REFERENCES subgroups(id) ON DELETE CASCADE,
PRIMARY KEY (schedule_rule_slot_id, subgroup_id)
);
CREATE INDEX IF NOT EXISTS idx_schedule_rule_slot_subgroups_subgroup ON schedule_rule_slot_subgroups(subgroup_id);
CREATE OR REPLACE FUNCTION validate_schedule_rule_slot_subgroups()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
lesson_type_name VARCHAR(50); lesson_type_name VARCHAR(50);
rule_id BIGINT;
subgroup_group_id BIGINT;
has_group_duplicate BOOLEAN;
BEGIN BEGIN
IF NEW.subgroup_id IS NULL THEN SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
RETURN NEW; FROM schedule_rule_slots slot
END IF; JOIN lesson_types type ON type.id = slot.lesson_type_id
WHERE slot.id = NEW.schedule_rule_slot_id;
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 IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий'; RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
END IF; END IF;
SELECT group_id INTO subgroup_group_id
FROM subgroups
WHERE id = NEW.subgroup_id;
IF subgroup_group_id IS NULL THEN
RAISE EXCEPTION 'Подгруппа не найдена';
END IF;
IF NOT EXISTS (
SELECT 1
FROM schedule_rule_groups rule_group
WHERE rule_group.schedule_rule_id = rule_id
AND rule_group.group_id = subgroup_group_id
) THEN
RAISE EXCEPTION 'Подгруппа должна относиться к одной из групп правила';
END IF;
IF TG_OP = 'UPDATE' THEN
SELECT EXISTS (
SELECT 1
FROM schedule_rule_slot_subgroups slot_subgroup
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
AND subgroup.group_id = subgroup_group_id
AND NOT (
slot_subgroup.schedule_rule_slot_id = OLD.schedule_rule_slot_id
AND slot_subgroup.subgroup_id = OLD.subgroup_id
)
) INTO has_group_duplicate;
ELSE
SELECT EXISTS (
SELECT 1
FROM schedule_rule_slot_subgroups slot_subgroup
JOIN subgroups subgroup ON subgroup.id = slot_subgroup.subgroup_id
WHERE slot_subgroup.schedule_rule_slot_id = NEW.schedule_rule_slot_id
AND subgroup.group_id = subgroup_group_id
) INTO has_group_duplicate;
END IF;
IF has_group_duplicate THEN
RAISE EXCEPTION 'В одном слоте можно выбрать не больше одной подгруппы каждой группы';
END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
CREATE TRIGGER validate_schedule_rule_slot_subgroup CREATE TRIGGER validate_schedule_rule_slot_subgroups
BEFORE INSERT OR UPDATE ON schedule_rule_slots BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup(); EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
CREATE TEMP TABLE tmp_default_rules AS CREATE TEMP TABLE tmp_default_rules AS
SELECT SELECT
@@ -712,6 +761,7 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам'; COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания'; COMMENT ON TABLE schedule_rule_slots IS 'Слоты проведения занятий внутри правила расписания';
COMMENT ON TABLE schedule_rule_slot_subgroups IS 'Подгруппы лабораторных слотов правила расписания';
COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций'; COMMENT ON COLUMN schedule_rules.lecture_academic_hours IS 'Лимит академических часов лекций';
COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ'; COMMENT ON COLUMN schedule_rules.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик'; COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';

View File

@@ -149,6 +149,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
"lessonFormat": "Очно", "lessonFormat": "Очно",
"subgroupId": null, "subgroupId": null,
"subgroupName": null, "subgroupName": null,
"subgroupIds": [],
"subgroupNames": [],
"groupIds": [1], "groupIds": [1],
"groupNames": ["ИВТ-21-1"], "groupNames": ["ИВТ-21-1"],
"activityType": "Т", "activityType": "Т",
@@ -292,7 +294,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
"dayOfWeek": 1, "dayOfWeek": 1,
"parity": "BOTH", "parity": "BOTH",
"timeSlotId": 3, "timeSlotId": 3,
"subgroupId": null, "subgroupId": null,
"subgroupIds": [],
"teacherId": 2, "teacherId": 2,
"classroomId": 1, "classroomId": 1,
"lessonTypeId": 1, "lessonTypeId": 1,
@@ -314,7 +317,7 @@ CRUD доступен по:
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
`subgroupId` можно передавать только для лабораторного слота. Подгруппа должна относиться к одной из групп правила. Для лекций и практик поле должно быть `null`, иначе API вернёт ошибку валидации. `subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
@@ -460,7 +463,7 @@ CRUD доступен по:
### Подгруппы группы ### Подгруппы группы
Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId`. Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`.
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|

View File

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

View File

@@ -226,12 +226,16 @@ erDiagram
INT day_of_week INT day_of_week
VARCHAR parity VARCHAR parity
BIGINT time_slot_id FK BIGINT time_slot_id FK
BIGINT subgroup_id FK
BIGINT teacher_id FK BIGINT teacher_id FK
BIGINT classroom_id FK BIGINT classroom_id FK
BIGINT lesson_type_id FK BIGINT lesson_type_id FK
VARCHAR lesson_format VARCHAR lesson_format
} }
schedule_rule_slot_subgroups {
BIGINT schedule_rule_slot_id FK,PK
BIGINT subgroup_id FK,PK
}
departments ||--o{ users : "department_id" departments ||--o{ users : "department_id"
departments ||--o{ student_groups : "department_id" departments ||--o{ student_groups : "department_id"
@@ -265,10 +269,11 @@ erDiagram
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id" time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id" time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
time_slots ||--o{ schedule_rule_slots : "time_slot_id" time_slots ||--o{ schedule_rule_slots : "time_slot_id"
subgroups ||--o{ schedule_rule_slots : "subgroup_id" subgroups ||--o{ schedule_rule_slot_subgroups : "subgroup_id"
users ||--o{ schedule_rule_slots : "teacher_id" users ||--o{ schedule_rule_slots : "teacher_id"
``` ```
@@ -534,13 +539,18 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `day_of_week` | INT CHECK(17) | День недели: 1 — понедельник | | `day_of_week` | INT CHECK(17) | День недели: 1 — понедельник |
| `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` | | `parity` | VARCHAR(10) | `BOTH`, `EVEN`, `ODD` |
| `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот | | `time_slot_id` | BIGINT FK → time_slots | Базовый временной слот |
| `subgroup_id` | BIGINT FK → subgroups, NULL | Подгруппа лабораторной работы |
| `teacher_id` | BIGINT FK → users | Преподаватель | | `teacher_id` | BIGINT FK → users | Преподаватель |
| `classroom_id` | BIGINT FK → classrooms | Аудитория | | `classroom_id` | BIGINT FK → classrooms | Аудитория |
| `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия | | `lesson_type_id` | BIGINT FK → lesson_types | Тип занятия |
| `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` | | `lesson_format` | VARCHAR(30) | `Очно` или `Онлайн` |
`subgroup_id` заполняется только для лабораторных слотов. Лекции и практики не делятся на подгруппы и должны хранить `NULL`. Ограничение дополнительно проверяется триггером `validate_schedule_rule_slot_subgroup`; для быстрых выборок есть индекс `idx_schedule_rule_slots_subgroup`. #### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
| Колонка | Тип | Описание |
|---------|-----|----------|
| `schedule_rule_slot_id` | BIGINT PK, FK → schedule_rule_slots (CASCADE) | Слот правила |
| `subgroup_id` | BIGINT PK, FK → subgroups (CASCADE) | Подгруппа |
Связь заполняется только для лабораторных слотов. Для потоковой лабораторной можно выбрать разные подгруппы разных групп в одном слоте. Лекции и практики не делятся на подгруппы и не имеют записей в этой таблице. Триггер `validate_schedule_rule_slot_subgroups` проверяет тип занятия, принадлежность подгруппы к группам правила и запрет на две подгруппы одной группы в одном слоте; для быстрых выборок есть индекс `idx_schedule_rule_slot_subgroups_subgroup`.
--- ---

View File

@@ -25,7 +25,8 @@
| `time_slots` | Настраиваемая сетка пар | | `time_slots` | Настраиваемая сетка пар |
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
| `schedule_rule_groups` | Группы правила | | `schedule_rule_groups` | Группы правила |
| `schedule_rule_slots` | Шаблонные слоты правила, включая опциональную лабораторную подгруппу | | `schedule_rule_slots` | Шаблонные слоты правила |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
@@ -40,7 +41,7 @@
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику | | `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат | | `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком. `ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Лабораторные подгруппы имеют отдельный счётчик лабораторных часов; один лабораторный слот может включать разные подгруппы разных групп. Лекции и практики на подгруппы не делятся. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
## API ## API
@@ -73,7 +74,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
- конструктор правил динамического расписания; - конструктор правил динамического расписания;
- отдельные поля часов и недель начала для лекций, лабораторных и практик; - отдельные поля часов и недель начала для лекций, лабораторных и практик;
- выбор подгруппы только для лабораторного слота; - выбор подгрупп только для лабораторного слота;
- управление временными слотами; - управление временными слотами;
- учебные годы и семестры; - учебные годы и семестры;
- CRUD календарных графиков; - CRUD календарных графиков;

View File

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

View File

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

View File

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

View File

@@ -114,14 +114,18 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.schedule-checkbox-grid { .schedule-group-select {
display: grid; max-width: 520px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
gap: 0.35rem;
padding: 0.5rem; .schedule-group-select .select-text {
background: var(--bg-input); overflow: hidden;
border: 1px solid var(--bg-card-border); text-overflow: ellipsis;
border-radius: var(--radius-sm); white-space: nowrap;
}
.schedule-group-checkboxes {
max-height: 220px;
} }
.schedule-hours-panel { .schedule-hours-panel {
@@ -164,7 +168,7 @@
.schedule-slot-row { .schedule-slot-row {
display: grid; display: grid;
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto; grid-template-columns: repeat(7, minmax(125px, 1fr)) auto;
gap: 0.75rem; gap: 0.75rem;
align-items: end; align-items: end;
padding: 0.75rem; padding: 0.75rem;
@@ -173,6 +177,20 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
.slot-subgroup-multi .select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-subgroup-multi .dropdown-menu {
min-width: 260px;
}
.schedule-slot-summary, .schedule-slot-summary,
.schedule-semester-line { .schedule-semester-line {
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
@@ -479,7 +497,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.schedule-slot-row { .schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(2, minmax(180px, 1fr)); grid-template-columns: repeat(2, minmax(180px, 1fr));
} }
@@ -499,7 +518,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.schedule-slot-row { .schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -22,6 +22,8 @@ const SEMESTER_LABELS = {
spring: 'весенний' spring: 'весенний'
}; };
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const LESSON_TYPE_LIMITS = [ const LESSON_TYPE_LIMITS = [
{ {
key: 'lecture', key: 'lecture',
@@ -59,7 +61,10 @@ export async function initSchedule() {
hours: document.getElementById(config.hoursInputId), hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId) startWeek: document.getElementById(config.startWeekInputId)
}])); }]));
const ruleGroupsContainer = document.getElementById('schedule-rule-groups'); const ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
const ruleSlotsContainer = document.getElementById('schedule-rule-slots'); const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
const ruleResetButton = document.getElementById('schedule-rule-reset'); const ruleResetButton = document.getElementById('schedule-rule-reset');
const slotAddButton = document.getElementById('schedule-slot-add'); const slotAddButton = document.getElementById('schedule-slot-add');
@@ -94,16 +99,44 @@ export async function initSchedule() {
ruleSlotsContainer.addEventListener('click', (event) => { ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove'); const removeButton = event.target.closest('.schedule-slot-remove');
if (!removeButton) return; const subgroupBox = event.target.closest('.slot-subgroup-box');
const slots = readRuleSlots(false); const subgroupMenu = event.target.closest('.slot-subgroup-menu');
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]); if (removeButton) {
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
return;
}
if (subgroupBox) {
toggleSlotSubgroupMenu(event, subgroupBox);
return;
}
if (subgroupMenu) {
event.stopPropagation();
}
}); });
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false))); ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
ruleGroupCheckboxes.addEventListener('change', () => {
updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false));
});
document.addEventListener('click', () => {
closeRuleGroupsMenu();
closeSlotSubgroupMenus();
});
ruleSlotsContainer.addEventListener('change', (event) => { ruleSlotsContainer.addEventListener('change', (event) => {
if (event.target.closest('.slot-lesson-type')) { if (event.target.closest('.slot-lesson-type')) {
renderRuleSlots(readRuleSlots(false)); renderRuleSlots(readRuleSlots(false));
return;
}
if (event.target.closest('.slot-subgroup-checkboxes')) {
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
} }
}); });
ruleForm.addEventListener('submit', saveRule); ruleForm.addEventListener('submit', saveRule);
@@ -167,7 +200,7 @@ export async function initSchedule() {
slot.dayName, slot.dayName,
slot.timeSlotLabel, slot.timeSlotLabel,
parity, parity,
slot.subgroupName, slotSubgroupNames(slot).join(', '),
slot.teacherName, slot.teacherName,
slot.classroomName, slot.classroomName,
slot.lessonTypeName, slot.lessonTypeName,
@@ -232,8 +265,7 @@ export async function initSchedule() {
} }
function buildRulePayload() { function buildRulePayload() {
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) const groupIds = selectedRuleGroupIds();
.map(input => Number(input.value));
const slots = readRuleSlots(true); const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину'); if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр'); if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
@@ -277,29 +309,31 @@ export async function initSchedule() {
function renderRuleGroups(selectedIds) { function renderRuleGroups(selectedIds) {
if (!groups.length) { if (!groups.length) {
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>'; ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
updateRuleGroupsText();
return; return;
} }
const selected = new Set((selectedIds || []).map(String)); const selected = new Set((selectedIds || []).map(String));
ruleGroupsContainer.innerHTML = groups.map(group => ` ruleGroupCheckboxes.innerHTML = groups.map(group => `
<label class="checkbox-item"> <label class="checkbox-item">
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}> <input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
<span class="checkmark"></span> <span class="checkmark"></span>
<span>${escapeHtml(group.name)}</span> <span>${escapeHtml(group.name)}</span>
</label> </label>
`).join(''); `).join('');
updateRuleGroupsText();
} }
function renderRuleSlots(slots) { function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => ` ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
<div class="schedule-slot-row" data-index="${index}"> <div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div> <div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div> <div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div> <div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div> <div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div> <div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div> <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</select></div> ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button> <button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div> </div>
@@ -309,13 +343,14 @@ export async function initSchedule() {
function readRuleSlots(validate) { function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row')); const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => { return rows.map((row, index) => {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || ''; const subgroupIds = readSlotSubgroupIds(row);
const slot = { const slot = {
id: null, id: null,
dayOfWeek: Number(row.querySelector('.slot-day').value), dayOfWeek: Number(row.querySelector('.slot-day').value),
parity: row.querySelector('.slot-parity').value, parity: row.querySelector('.slot-parity').value,
timeSlotId: Number(row.querySelector('.slot-time').value), timeSlotId: Number(row.querySelector('.slot-time').value),
subgroupId: subgroupValue ? Number(subgroupValue) : null, subgroupId: subgroupIds[0] || null,
subgroupIds,
teacherId: Number(row.querySelector('.slot-teacher').value), teacherId: Number(row.querySelector('.slot-teacher').value),
classroomId: Number(row.querySelector('.slot-classroom').value), classroomId: Number(row.querySelector('.slot-classroom').value),
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value), lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
@@ -370,18 +405,26 @@ export async function initSchedule() {
function validateSlotSubgroup(slot, index) { function validateSlotSubgroup(slot, index) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId); const typeKey = lessonTypeKeyById(slot.lessonTypeId);
if (slot.subgroupId && typeKey !== 'laboratory') { const subgroupIds = slotSubgroupIds(slot);
if (subgroupIds.length && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`); throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
} }
if (!slot.subgroupId) return; if (!subgroupIds.length) 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)); const selectedGroups = new Set(selectedRuleGroupIds().map(String));
if (!selectedGroups.has(String(selectedSubgroup.groupId))) { const subgroupGroupIds = new Set();
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`); for (const subgroupId of subgroupIds) {
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
}
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
}
subgroupGroupIds.add(String(selectedSubgroup.groupId));
} }
} }
@@ -417,22 +460,196 @@ export async function initSchedule() {
if (!isLaboratorySlot(slot)) { if (!isLaboratorySlot(slot)) {
return '<option value="">Только для лабораторных</option>'; return '<option value="">Только для лабораторных</option>';
} }
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId); const selectedGroupIds = selectedRuleGroupIds();
const selectedGroups = new Set(selectedRuleGroupIds().map(String)); if (selectedGroupIds.length !== 1) {
return '<option value="">Выберите группу правила</option>';
}
const selected = slotSubgroupIds(slot)[0] == null
? WHOLE_GROUP_SUBGROUP_VALUE
: String(slotSubgroupIds(slot)[0]);
const selectedGroups = new Set(selectedGroupIds.map(String));
const matchingSubgroups = subgroups const matchingSubgroups = subgroups
.filter(subgroup => selectedGroups.has(String(subgroup.groupId))) .filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU')); .sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
return '<option value="">Вся группа</option>' + matchingSubgroups.map(subgroup => { return `<option value="${WHOLE_GROUP_SUBGROUP_VALUE}" ${selected === WHOLE_GROUP_SUBGROUP_VALUE ? 'selected' : ''}>Вся группа</option>` + matchingSubgroups.map(subgroup => {
const value = String(subgroup.id); const value = String(subgroup.id);
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`; return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
}).join(''); }).join('');
} }
function renderSubgroupField(slot, index) {
return selectedRuleGroupIds().length > 1
? renderSubgroupMultiSelect(slot, index)
: `<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup">${subgroupOptions(slot)}</select></div>`;
}
function renderSubgroupMultiSelect(slot, index) {
const selected = new Set(slotSubgroupIds(slot).map(String));
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'));
const items = matchingSubgroups.length
? matchingSubgroups.map(subgroup => {
const value = String(subgroup.id);
return `
<label class="checkbox-item">
<input type="checkbox" value="${escapeHtml(value)}" ${selected.has(value) ? 'checked' : ''}>
<span class="checkmark"></span>
<span>${escapeHtml(subgroupLabel(subgroup))}</span>
</label>
`;
}).join('')
: '<div class="loading-row">Нет подгрупп выбранных групп</div>';
return `
<div class="form-group slot-subgroup-multi">
<label>Подгруппы</label>
<div class="custom-multi-select">
<div class="select-box slot-subgroup-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text">${escapeHtml(slotSubgroupText(slot))}</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu slot-subgroup-menu">
<div class="checkbox-group-vertical slot-subgroup-checkboxes" data-index="${index}">
${items}
</div>
</div>
</div>
</div>
`;
}
function readSlotSubgroupIds(row) {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
.map(input => Number(input.value))
.filter(Boolean);
return checkboxValues.length
? checkboxValues
: (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
}
function slotSubgroupIds(slot) {
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
return slot.subgroupIds.map(Number).filter(Boolean);
}
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
}
function slotSubgroupNames(slot) {
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
return slot.subgroupNames.filter(Boolean);
}
return slot.subgroupName ? [slot.subgroupName] : [];
}
function slotSubgroupText(slot) {
const selectedIds = slotSubgroupIds(slot);
if (!selectedIds.length) return 'Все выбранные группы';
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
}
return `Выбрано подгрупп: ${selectedIds.length}`;
}
function selectedRuleGroupIds() { function selectedRuleGroupIds() {
return Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked')) return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value)); .map(input => Number(input.value));
} }
function toggleRuleGroupsMenu(event) {
event.stopPropagation();
const isOpen = ruleGroupsMenu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
if (isOpen) {
closeRuleGroupsMenu();
return;
}
ruleGroupsMenu.classList.add('open');
ruleGroupsBox.classList.add('active');
ruleGroupsBox.setAttribute('aria-expanded', 'true');
}
function handleRuleGroupsKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleRuleGroupsMenu(event);
}
if (event.key === 'Escape') {
closeRuleGroupsMenu();
}
}
function closeRuleGroupsMenu() {
ruleGroupsMenu.classList.remove('open');
ruleGroupsBox.classList.remove('active');
ruleGroupsBox.setAttribute('aria-expanded', 'false');
}
function toggleSlotSubgroupMenu(event, box) {
event.stopPropagation();
const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
if (!menu) return;
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
if (isOpen) {
box.setAttribute('aria-expanded', 'false');
return;
}
menu.classList.add('open');
box.classList.add('active');
box.setAttribute('aria-expanded', 'true');
}
function closeSlotSubgroupMenus() {
ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
box.classList.remove('active');
box.setAttribute('aria-expanded', 'false');
});
}
function updateSlotSubgroupText(row) {
if (!row) return;
const text = row.querySelector('.slot-subgroup-box .select-text');
if (!text) return;
const selectedIds = readSlotSubgroupIds(row);
if (!selectedIds.length) {
text.textContent = 'Все выбранные группы';
return;
}
if (selectedIds.length === 1) {
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
return;
}
text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
}
function updateRuleGroupsText() {
const selectedIds = selectedRuleGroupIds();
if (!groups.length) {
ruleGroupsText.textContent = 'Нет групп';
return;
}
if (!selectedIds.length) {
ruleGroupsText.textContent = 'Выберите группы...';
return;
}
if (selectedIds.length === 1) {
const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
return;
}
ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
}
function toTimeSlotOption(slot) { function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
} }

View File

@@ -61,7 +61,19 @@
<div class="schedule-groups-panel"> <div class="schedule-groups-panel">
<label class="schedule-section-label">Группы</label> <label class="schedule-section-label">Группы</label>
<div id="schedule-rule-groups" class="schedule-checkbox-grid"></div> <div id="schedule-rule-groups" class="custom-multi-select schedule-group-select">
<div class="select-box" id="schedule-rule-groups-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text" id="schedule-rule-groups-text">Выберите группы...</span>
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="dropdown-menu" id="schedule-rule-groups-menu">
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
</div>
</div>
</div> </div>
<div class="schedule-slots-panel"> <div class="schedule-slots-panel">

View File

@@ -416,13 +416,16 @@
function renderLesson(lesson) { function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return ` return `
<div class="lesson"> <div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div> <div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div> <div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta"> <div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span> <span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''} ${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span> <span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span> <span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span> <span class="chip">${escapeHtml(lesson.classroomName)}</span>

View File

@@ -431,13 +431,16 @@
function renderLesson(lesson) { function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : ''; const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return ` return `
<div class="lesson"> <div class="lesson">
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div> <div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta"> <div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span> <span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span> <span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''} ${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span> <span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''} ${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div> </div>