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

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())
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
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()) {
throw new IllegalArgumentException("Формат занятия обязателен");
}
@@ -201,7 +201,7 @@ public class ScheduleRuleAdminController {
slot.setDayOfWeek(slotDto.dayOfWeek());
slot.setParity(slotDto.parity());
slot.setTimeSlot(timeSlot);
slot.setSubgroup(subgroup);
slot.setSubgroups(subgroups);
slot.setTeacher(teacher);
slot.setClassroom(classroom);
slot.setLessonType(lessonType);
@@ -209,21 +209,44 @@ public class ScheduleRuleAdminController {
return slot;
}
private Subgroup resolveSubgroup(ScheduleRule rule, Long subgroupId, ScheduleLessonCategory category) {
if (subgroupId == null) {
return null;
private Set<Subgroup> resolveSubgroups(ScheduleRule rule, ScheduleRuleSlotDto slotDto, ScheduleLessonCategory category) {
LinkedHashSet<Long> subgroupIds = new LinkedHashSet<>();
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) {
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("Подгруппа должна относиться к одной из групп правила");
Set<Long> ruleGroupIds = rule.getGroups().stream()
.map(StudentGroup::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> subgroupGroupIds = new HashSet<>();
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) {
@@ -318,6 +341,7 @@ public class ScheduleRuleAdminController {
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);
return new ScheduleRuleSlotDto(
slot.getId(),
slot.getDayOfWeek(),
@@ -326,8 +350,10 @@ public class ScheduleRuleAdminController {
timeSlot.getId(),
timeSlot.getOrderNumber(),
formatTimeSlot(timeSlot),
slot.getSubgroupId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
sortedSubgroups.isEmpty() ? null : sortedSubgroups.get(0).getId(),
sortedSubgroups.isEmpty() ? null : formatSubgroup(sortedSubgroups.get(0)),
sortedSubgroups.stream().map(Subgroup::getId).toList(),
sortedSubgroups.stream().map(this::formatSubgroup).toList(),
slot.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(slot.getTeacher()),
slot.getClassroom().getId(),
@@ -348,4 +374,16 @@ public class ScheduleRuleAdminController {
private String formatTimeSlot(TimeSlot timeSlot) {
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,
Long subgroupId,
String subgroupName,
List<Long> subgroupIds,
List<String> subgroupNames,
List<Long> groupIds,
List<String> groupNames,
String activityType,

View File

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

View File

@@ -2,6 +2,10 @@ package com.magistr.app.model;
import jakarta.persistence.*;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Entity
@Table(name = "schedule_rule_slots")
public class ScheduleRuleSlot {
@@ -25,9 +29,13 @@ public class ScheduleRuleSlot {
@JoinColumn(name = "time_slot_id", nullable = false)
private TimeSlot timeSlot;
@ManyToOne
@JoinColumn(name = "subgroup_id")
private Subgroup subgroup;
@ManyToMany
@JoinTable(
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)
@JoinColumn(name = "teacher_id", nullable = false)
@@ -85,15 +93,41 @@ public class ScheduleRuleSlot {
}
public Long getSubgroupId() {
return subgroup == null ? null : subgroup.getId();
return subgroups.stream().findFirst().map(Subgroup::getId).orElse(null);
}
public Subgroup getSubgroup() {
return subgroup;
return subgroups.stream().findFirst().orElse(null);
}
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() {

View File

@@ -19,8 +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.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
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.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
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.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
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.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroup subgroup
left join fetch subgroup.studentGroup
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
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> {
@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);
}

View File

@@ -201,22 +201,54 @@ public class ScheduleGeneratorService {
if (totalHours <= 0) {
continue;
}
List<StudentGroup> lessonGroups = groupsForSlot(slot, eligibleGroups);
if (lessonGroups.isEmpty()) {
List<LessonScope> lessonScopes = lessonScopesForSlot(slot, category, eligibleGroups);
if (lessonScopes.isEmpty()) {
continue;
}
String consumptionBucket = consumptionBucket(category, slot);
int consumedHours = consumedHoursByBucket.computeIfAbsent(
consumptionBucket,
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, category, slot.getSubgroupId())
);
if (consumedHours >= totalHours) {
List<ActiveLessonScope> activeScopes = new ArrayList<>();
for (LessonScope scope : lessonScopes) {
String consumptionBucket = consumptionBucket(category, scope.subgroupId());
int consumedHours = consumedHoursByBucket.computeIfAbsent(
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;
}
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));
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 -> 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())
.flatMap(slot -> lessonScopesForSlot(slot, category, eligibleGroups).stream())
.filter(scope -> Objects.equals(scope.subgroupId(), subgroupId))
.count();
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
if (consumed >= rule.academicHoursFor(category)) {
@@ -283,26 +315,51 @@ public class ScheduleGeneratorService {
return consumed;
}
private String consumptionBucket(ScheduleLessonCategory category, ScheduleRuleSlot slot) {
return category.name() + ":" + (slot.getSubgroupId() == null ? "whole" : slot.getSubgroupId());
private String consumptionBucket(ScheduleLessonCategory category, Long subgroupId) {
return category.name() + ":" + (subgroupId == null ? "whole" : subgroupId);
}
private List<StudentGroup> groupsForSlot(ScheduleRuleSlot slot, List<StudentGroup> eligibleGroups) {
if (slot.getSubgroup() == null) {
return eligibleGroups;
private List<LessonScope> lessonScopesForSlot(ScheduleRuleSlot slot,
ScheduleLessonCategory category,
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()
.filter(group -> Objects.equals(group.getId(), subgroupGroupId))
Set<Long> eligibleGroupIds = eligibleGroups.stream()
.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();
}
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,
ScheduleRuleSlot slot,
LocalDate date,
int weekNumber,
ScheduleParity dateParity,
List<StudentGroup> groups,
List<Subgroup> subgroups,
int lessonTypeAcademicHours,
int consumedBeforeLesson,
int remainingAfterLesson) {
@@ -310,6 +367,11 @@ public class ScheduleGeneratorService {
List<StudentGroup> sortedGroups = groups.stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
List<Subgroup> sortedSubgroups = subgroups.stream()
.sorted(Comparator
.comparing((Subgroup subgroup) -> subgroup.getStudentGroup().getName())
.thenComparing(Subgroup::getName))
.toList();
return new RenderedLessonDto(
rule.getId(),
@@ -332,8 +394,10 @@ public class ScheduleGeneratorService {
slot.getLessonType().getId(),
slot.getLessonType().getLessonType(),
slot.getLessonFormat(),
slot.getSubgroupId(),
slot.getSubgroup() == null ? null : slot.getSubgroup().getName(),
sortedSubgroups.size() == 1 ? sortedSubgroups.get(0).getId() : null,
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::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) {
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
return baseSlot;

View File

@@ -515,7 +515,6 @@ CREATE TABLE IF NOT EXISTS schedule_rule_slots (
day_of_week INT NOT NULL,
parity VARCHAR(10) NOT NULL,
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),
classroom_id BIGINT NOT NULL REFERENCES classrooms(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_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()
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 $$
DECLARE
lesson_type_name VARCHAR(50);
rule_id BIGINT;
subgroup_group_id BIGINT;
has_group_duplicate BOOLEAN;
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;
SELECT slot.schedule_rule_id, type.name INTO rule_id, lesson_type_name
FROM schedule_rule_slots slot
JOIN lesson_types type ON type.id = slot.lesson_type_id
WHERE slot.id = NEW.schedule_rule_slot_id;
IF lesson_type_name IS NULL OR LOWER(lesson_type_name) NOT LIKE '%лаб%' THEN
RAISE EXCEPTION 'Подгруппы можно выбирать только для лабораторных занятий';
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;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER validate_schedule_rule_slot_subgroup
BEFORE INSERT OR UPDATE ON schedule_rule_slots
CREATE TRIGGER validate_schedule_rule_slot_subgroups
BEFORE INSERT OR UPDATE ON schedule_rule_slot_subgroups
FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroup();
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
CREATE TEMP TABLE tmp_default_rules AS
SELECT
@@ -712,6 +761,7 @@ COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения к
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups 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.laboratory_academic_hours IS 'Лимит академических часов лабораторных работ';
COMMENT ON COLUMN schedule_rules.practice_academic_hours IS 'Лимит академических часов практик';