написал обработку количества часов лекций лабораторных и практик по отдельности
This commit is contained in:
@@ -124,11 +124,9 @@ public class ScheduleRuleAdminController {
|
|||||||
if (request.semesterId() == null) {
|
if (request.semesterId() == null) {
|
||||||
return "Семестр обязателен";
|
return "Семестр обязателен";
|
||||||
}
|
}
|
||||||
if (request.activeFromDate() == null) {
|
String hoursValidationError = validateTypeHours(request);
|
||||||
return "Дата начала правила обязательна";
|
if (hoursValidationError != null) {
|
||||||
}
|
return hoursValidationError;
|
||||||
if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) {
|
|
||||||
return "Количество академических часов должно быть больше нуля";
|
|
||||||
}
|
}
|
||||||
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
||||||
return "Нужно выбрать хотя бы одну группу";
|
return "Нужно выбрать хотя бы одну группу";
|
||||||
@@ -151,15 +149,23 @@ public class ScheduleRuleAdminController {
|
|||||||
|
|
||||||
rule.setSubject(subject);
|
rule.setSubject(subject);
|
||||||
rule.setSemester(semester);
|
rule.setSemester(semester);
|
||||||
rule.setActiveFromDate(request.activeFromDate());
|
rule.setLectureAcademicHours(request.lectureAcademicHours());
|
||||||
rule.setTotalAcademicHours(request.totalAcademicHours());
|
rule.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
|
||||||
|
rule.setPracticeAcademicHours(request.practiceAcademicHours());
|
||||||
|
rule.setLectureStartWeek(request.lectureStartWeek());
|
||||||
|
rule.setLaboratoryStartWeek(request.laboratoryStartWeek());
|
||||||
|
rule.setPracticeStartWeek(request.practiceStartWeek());
|
||||||
rule.getGroups().clear();
|
rule.getGroups().clear();
|
||||||
rule.getGroups().addAll(groups);
|
rule.getGroups().addAll(groups);
|
||||||
|
|
||||||
rule.getSlots().clear();
|
List<ScheduleRuleSlot> slots = new ArrayList<>();
|
||||||
for (ScheduleRuleSlotDto slotDto : request.slots()) {
|
for (ScheduleRuleSlotDto slotDto : request.slots()) {
|
||||||
rule.getSlots().add(buildSlot(rule, slotDto));
|
slots.add(buildSlot(rule, slotDto));
|
||||||
}
|
}
|
||||||
|
validateTypeCoverage(rule, slots);
|
||||||
|
|
||||||
|
rule.getSlots().clear();
|
||||||
|
rule.getSlots().addAll(slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
private ScheduleRuleSlot buildSlot(ScheduleRule rule, ScheduleRuleSlotDto slotDto) {
|
||||||
@@ -181,6 +187,7 @@ public class ScheduleRuleAdminController {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||||
|
ScheduleLessonCategory.fromLessonType(lessonType);
|
||||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||||
}
|
}
|
||||||
@@ -198,6 +205,65 @@ public class ScheduleRuleAdminController {
|
|||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String validateTypeHours(ScheduleRuleDto request) {
|
||||||
|
if (isMissing(request.lectureAcademicHours())
|
||||||
|
|| isMissing(request.laboratoryAcademicHours())
|
||||||
|
|| isMissing(request.practiceAcademicHours())) {
|
||||||
|
return "Укажите количество часов для всех типов занятий";
|
||||||
|
}
|
||||||
|
if (isNegative(request.lectureAcademicHours())
|
||||||
|
|| isNegative(request.laboratoryAcademicHours())
|
||||||
|
|| isNegative(request.practiceAcademicHours())) {
|
||||||
|
return "Количество часов по типам занятий не может быть отрицательным";
|
||||||
|
}
|
||||||
|
if (isInvalidStartWeek(request.lectureStartWeek())
|
||||||
|
|| isInvalidStartWeek(request.laboratoryStartWeek())
|
||||||
|
|| isInvalidStartWeek(request.practiceStartWeek())) {
|
||||||
|
return "Неделя начала занятий должна быть больше нуля";
|
||||||
|
}
|
||||||
|
int totalHours = valueOrZero(request.lectureAcademicHours())
|
||||||
|
+ valueOrZero(request.laboratoryAcademicHours())
|
||||||
|
+ valueOrZero(request.practiceAcademicHours());
|
||||||
|
if (totalHours <= 0) {
|
||||||
|
return "Укажите часы хотя бы для одного типа занятий";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTypeCoverage(ScheduleRule rule, List<ScheduleRuleSlot> slots) {
|
||||||
|
EnumSet<ScheduleLessonCategory> categoriesWithSlots = EnumSet.noneOf(ScheduleLessonCategory.class);
|
||||||
|
for (ScheduleRuleSlot slot : slots) {
|
||||||
|
categoriesWithSlots.add(ScheduleLessonCategory.fromLessonType(slot.getLessonType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ScheduleLessonCategory category : ScheduleLessonCategory.values()) {
|
||||||
|
int hours = rule.academicHoursFor(category);
|
||||||
|
boolean hasSlots = categoriesWithSlots.contains(category);
|
||||||
|
if (hours > 0 && !hasSlots) {
|
||||||
|
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" добавьте хотя бы один слот");
|
||||||
|
}
|
||||||
|
if (hours == 0 && hasSlots) {
|
||||||
|
throw new IllegalArgumentException("Для типа \"" + category.getDisplayName() + "\" укажите часы или удалите слоты этого типа");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMissing(Integer value) {
|
||||||
|
return value == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNegative(Integer value) {
|
||||||
|
return value < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInvalidStartWeek(Integer value) {
|
||||||
|
return value == null || value <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int valueOrZero(Integer value) {
|
||||||
|
return value == null ? 0 : value;
|
||||||
|
}
|
||||||
|
|
||||||
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
private ScheduleRuleDto toDto(ScheduleRule rule) {
|
||||||
List<StudentGroup> groups = rule.getGroups().stream()
|
List<StudentGroup> groups = rule.getGroups().stream()
|
||||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||||
@@ -217,8 +283,12 @@ public class ScheduleRuleAdminController {
|
|||||||
rule.getSemester().getId(),
|
rule.getSemester().getId(),
|
||||||
rule.getSemester().getAcademicYear().getTitle(),
|
rule.getSemester().getAcademicYear().getTitle(),
|
||||||
rule.getSemester().getSemesterType(),
|
rule.getSemester().getSemesterType(),
|
||||||
rule.getActiveFromDate(),
|
rule.getLectureAcademicHours(),
|
||||||
rule.getTotalAcademicHours(),
|
rule.getLaboratoryAcademicHours(),
|
||||||
|
rule.getPracticeAcademicHours(),
|
||||||
|
rule.getLectureStartWeek(),
|
||||||
|
rule.getLaboratoryStartWeek(),
|
||||||
|
rule.getPracticeStartWeek(),
|
||||||
groups.stream().map(StudentGroup::getId).toList(),
|
groups.stream().map(StudentGroup::getId).toList(),
|
||||||
groups.stream().map(StudentGroup::getName).toList(),
|
groups.stream().map(StudentGroup::getName).toList(),
|
||||||
slots
|
slots
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public record RenderedLessonDto(
|
|||||||
List<Long> groupIds,
|
List<Long> groupIds,
|
||||||
List<String> groupNames,
|
List<String> groupNames,
|
||||||
String activityType,
|
String activityType,
|
||||||
Integer totalAcademicHours,
|
Integer lessonTypeAcademicHours,
|
||||||
Integer consumedAcademicHoursBeforeLesson,
|
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||||
Integer remainingAcademicHoursAfterLesson
|
Integer remainingLessonTypeAcademicHoursAfterLesson
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.magistr.app.dto;
|
|||||||
|
|
||||||
import com.magistr.app.model.SemesterType;
|
import com.magistr.app.model.SemesterType;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record ScheduleRuleDto(
|
public record ScheduleRuleDto(
|
||||||
@@ -12,8 +11,12 @@ public record ScheduleRuleDto(
|
|||||||
Long semesterId,
|
Long semesterId,
|
||||||
String academicYearTitle,
|
String academicYearTitle,
|
||||||
SemesterType semesterType,
|
SemesterType semesterType,
|
||||||
LocalDate activeFromDate,
|
Integer lectureAcademicHours,
|
||||||
Integer totalAcademicHours,
|
Integer laboratoryAcademicHours,
|
||||||
|
Integer practiceAcademicHours,
|
||||||
|
Integer lectureStartWeek,
|
||||||
|
Integer laboratoryStartWeek,
|
||||||
|
Integer practiceStartWeek,
|
||||||
List<Long> groupIds,
|
List<Long> groupIds,
|
||||||
List<String> groupNames,
|
List<String> groupNames,
|
||||||
List<ScheduleRuleSlotDto> slots
|
List<ScheduleRuleSlotDto> slots
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public enum ScheduleLessonCategory {
|
||||||
|
LECTURE("Лекции"),
|
||||||
|
LABORATORY("Лабораторные"),
|
||||||
|
PRACTICE("Практики");
|
||||||
|
|
||||||
|
private final String displayName;
|
||||||
|
|
||||||
|
ScheduleLessonCategory(String displayName) {
|
||||||
|
this.displayName = displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ScheduleLessonCategory fromLessonType(LessonType lessonType) {
|
||||||
|
if (lessonType == null || lessonType.getLessonType() == null) {
|
||||||
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
|
}
|
||||||
|
String name = lessonType.getLessonType().toLowerCase(Locale.forLanguageTag("ru-RU"));
|
||||||
|
if (name.contains("лекц")) {
|
||||||
|
return LECTURE;
|
||||||
|
}
|
||||||
|
if (name.contains("лаб")) {
|
||||||
|
return LABORATORY;
|
||||||
|
}
|
||||||
|
if (name.contains("практ")) {
|
||||||
|
return PRACTICE;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Тип занятия должен быть лекцией, практикой или лабораторной работой");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package com.magistr.app.model;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -22,11 +21,23 @@ public class ScheduleRule {
|
|||||||
@JoinColumn(name = "semester_id", nullable = false)
|
@JoinColumn(name = "semester_id", nullable = false)
|
||||||
private Semester semester;
|
private Semester semester;
|
||||||
|
|
||||||
@Column(name = "active_from_date", nullable = false)
|
@Column(name = "lecture_academic_hours", nullable = false)
|
||||||
private LocalDate activeFromDate;
|
private Integer lectureAcademicHours = 0;
|
||||||
|
|
||||||
@Column(name = "total_academic_hours", nullable = false)
|
@Column(name = "laboratory_academic_hours", nullable = false)
|
||||||
private Integer totalAcademicHours;
|
private Integer laboratoryAcademicHours = 0;
|
||||||
|
|
||||||
|
@Column(name = "practice_academic_hours", nullable = false)
|
||||||
|
private Integer practiceAcademicHours = 0;
|
||||||
|
|
||||||
|
@Column(name = "lecture_start_week", nullable = false)
|
||||||
|
private Integer lectureStartWeek = 1;
|
||||||
|
|
||||||
|
@Column(name = "laboratory_start_week", nullable = false)
|
||||||
|
private Integer laboratoryStartWeek = 1;
|
||||||
|
|
||||||
|
@Column(name = "practice_start_week", nullable = false)
|
||||||
|
private Integer practiceStartWeek = 1;
|
||||||
|
|
||||||
@ManyToMany
|
@ManyToMany
|
||||||
@JoinTable(
|
@JoinTable(
|
||||||
@@ -63,20 +74,76 @@ public class ScheduleRule {
|
|||||||
this.semester = semester;
|
this.semester = semester;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getActiveFromDate() {
|
public Integer getLectureAcademicHours() {
|
||||||
return activeFromDate;
|
return lectureAcademicHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setActiveFromDate(LocalDate activeFromDate) {
|
public void setLectureAcademicHours(Integer lectureAcademicHours) {
|
||||||
this.activeFromDate = activeFromDate;
|
this.lectureAcademicHours = lectureAcademicHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getTotalAcademicHours() {
|
public Integer getLaboratoryAcademicHours() {
|
||||||
return totalAcademicHours;
|
return laboratoryAcademicHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setTotalAcademicHours(Integer totalAcademicHours) {
|
public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) {
|
||||||
this.totalAcademicHours = totalAcademicHours;
|
this.laboratoryAcademicHours = laboratoryAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPracticeAcademicHours() {
|
||||||
|
return practiceAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPracticeAcademicHours(Integer practiceAcademicHours) {
|
||||||
|
this.practiceAcademicHours = practiceAcademicHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLectureStartWeek() {
|
||||||
|
return lectureStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLectureStartWeek(Integer lectureStartWeek) {
|
||||||
|
this.lectureStartWeek = lectureStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLaboratoryStartWeek() {
|
||||||
|
return laboratoryStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLaboratoryStartWeek(Integer laboratoryStartWeek) {
|
||||||
|
this.laboratoryStartWeek = laboratoryStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPracticeStartWeek() {
|
||||||
|
return practiceStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPracticeStartWeek(Integer practiceStartWeek) {
|
||||||
|
this.practiceStartWeek = practiceStartWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int academicHoursFor(ScheduleLessonCategory category) {
|
||||||
|
return switch (category) {
|
||||||
|
case LECTURE -> valueOrZero(lectureAcademicHours);
|
||||||
|
case LABORATORY -> valueOrZero(laboratoryAcademicHours);
|
||||||
|
case PRACTICE -> valueOrZero(practiceAcademicHours);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public int startWeekFor(ScheduleLessonCategory category) {
|
||||||
|
return switch (category) {
|
||||||
|
case LECTURE -> valueOrDefault(lectureStartWeek, 1);
|
||||||
|
case LABORATORY -> valueOrDefault(laboratoryStartWeek, 1);
|
||||||
|
case PRACTICE -> valueOrDefault(practiceStartWeek, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int valueOrZero(Integer value) {
|
||||||
|
return valueOrDefault(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int valueOrDefault(Integer value, int defaultValue) {
|
||||||
|
return value == null ? defaultValue : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<StudentGroup> getGroups() {
|
public Set<StudentGroup> getGroups() {
|
||||||
|
|||||||
@@ -157,10 +157,9 @@ public class ScheduleGeneratorService {
|
|||||||
StudentGroup targetGroup,
|
StudentGroup targetGroup,
|
||||||
Long targetTeacherId,
|
Long targetTeacherId,
|
||||||
List<RenderedLessonDto> result) {
|
List<RenderedLessonDto> result) {
|
||||||
if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) {
|
if (rule.getLectureAcademicHours() == null
|
||||||
return;
|
|| rule.getLaboratoryAcademicHours() == null
|
||||||
}
|
|| rule.getPracticeAcademicHours() == null) {
|
||||||
if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +175,7 @@ public class ScheduleGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
||||||
|
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
int dayOfWeek = date.getDayOfWeek().getValue();
|
||||||
List<ScheduleRuleSlot> slots = rule.getSlots().stream()
|
List<ScheduleRuleSlot> slots = rule.getSlots().stream()
|
||||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||||
@@ -190,16 +190,28 @@ public class ScheduleGeneratorService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date);
|
Map<ScheduleLessonCategory, Integer> consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class);
|
||||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
|
||||||
|
|
||||||
for (ScheduleRuleSlot slot : slots) {
|
for (ScheduleRuleSlot slot : slots) {
|
||||||
if (consumedHours >= rule.getTotalAcademicHours()) {
|
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||||
break;
|
if (weekNumber < rule.startWeekFor(category)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
int totalHours = rule.academicHoursFor(category);
|
||||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson));
|
if (totalHours <= 0) {
|
||||||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
continue;
|
||||||
|
}
|
||||||
|
int consumedHours = consumedHoursByCategory.computeIfAbsent(
|
||||||
|
category,
|
||||||
|
key -> calculateConsumedHoursBeforeDate(rule, targetGroup, date, key)
|
||||||
|
);
|
||||||
|
if (consumedHours >= totalHours) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int remainingAfterLesson = Math.max(0, totalHours - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||||
|
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups,
|
||||||
|
totalHours, consumedHours, remainingAfterLesson));
|
||||||
|
consumedHoursByCategory.put(category, consumedHours + ACADEMIC_HOURS_PER_SLOT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,26 +226,30 @@ public class ScheduleGeneratorService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int calculateConsumedHoursBeforeDate(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) {
|
private int calculateConsumedHoursBeforeDate(ScheduleRule rule,
|
||||||
|
StudentGroup targetGroup,
|
||||||
|
LocalDate targetDate,
|
||||||
|
ScheduleLessonCategory category) {
|
||||||
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
Long groupId = targetGroup == null ? null : targetGroup.getId();
|
||||||
String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
||||||
return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate));
|
return consumedHoursCache.computeIfAbsent(cacheKey,
|
||||||
|
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category));
|
||||||
}
|
}
|
||||||
|
|
||||||
private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) {
|
private int calculateConsumedHours(ScheduleRule rule,
|
||||||
LocalDate start = rule.getActiveFromDate() == null
|
StudentGroup targetGroup,
|
||||||
? rule.getSemester().getStartDate()
|
LocalDate targetDate,
|
||||||
: rule.getActiveFromDate();
|
ScheduleLessonCategory category) {
|
||||||
if (start.isBefore(rule.getSemester().getStartDate())) {
|
|
||||||
start = rule.getSemester().getStartDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
int consumed = 0;
|
int consumed = 0;
|
||||||
for (LocalDate date = start; date.isBefore(targetDate); date = date.plusDays(1)) {
|
for (LocalDate date = rule.getSemester().getStartDate(); date.isBefore(targetDate); date = date.plusDays(1)) {
|
||||||
Semester semester = rule.getSemester();
|
Semester semester = rule.getSemester();
|
||||||
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||||
|
if (weekNumber < rule.startWeekFor(category)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
|
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -243,9 +259,10 @@ public class ScheduleGeneratorService {
|
|||||||
long activeSlots = rule.getSlots().stream()
|
long activeSlots = rule.getSlots().stream()
|
||||||
.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)
|
||||||
.count();
|
.count();
|
||||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||||
if (consumed >= rule.getTotalAcademicHours()) {
|
if (consumed >= rule.academicHoursFor(category)) {
|
||||||
return consumed;
|
return consumed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +275,7 @@ public class ScheduleGeneratorService {
|
|||||||
int weekNumber,
|
int weekNumber,
|
||||||
ScheduleParity dateParity,
|
ScheduleParity dateParity,
|
||||||
List<StudentGroup> groups,
|
List<StudentGroup> groups,
|
||||||
|
int lessonTypeAcademicHours,
|
||||||
int consumedBeforeLesson,
|
int consumedBeforeLesson,
|
||||||
int remainingAfterLesson) {
|
int remainingAfterLesson) {
|
||||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||||
@@ -290,7 +308,7 @@ public class ScheduleGeneratorService {
|
|||||||
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()),
|
||||||
"Т",
|
"Т",
|
||||||
rule.getTotalAcademicHours(),
|
lessonTypeAcademicHours,
|
||||||
consumedBeforeLesson,
|
consumedBeforeLesson,
|
||||||
remainingAfterLesson
|
remainingAfterLesson
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -461,9 +461,25 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||||
active_from_date DATE NOT NULL,
|
lecture_academic_hours INT NOT NULL,
|
||||||
total_academic_hours INT NOT NULL,
|
laboratory_academic_hours INT NOT NULL,
|
||||||
CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0)
|
practice_academic_hours INT NOT NULL,
|
||||||
|
lecture_start_week INT NOT NULL,
|
||||||
|
laboratory_start_week INT NOT NULL,
|
||||||
|
practice_start_week INT NOT NULL,
|
||||||
|
CONSTRAINT chk_schedule_rules_type_hours_non_negative CHECK (
|
||||||
|
lecture_academic_hours >= 0
|
||||||
|
AND laboratory_academic_hours >= 0
|
||||||
|
AND practice_academic_hours >= 0
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_schedule_rules_has_type_hours CHECK (
|
||||||
|
lecture_academic_hours + laboratory_academic_hours + practice_academic_hours > 0
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_schedule_rules_start_weeks_positive CHECK (
|
||||||
|
lecture_start_week > 0
|
||||||
|
AND laboratory_start_week > 0
|
||||||
|
AND practice_start_week > 0
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
|
CREATE INDEX IF NOT EXISTS idx_schedule_rules_semester ON schedule_rules(semester_id);
|
||||||
@@ -504,28 +520,66 @@ SELECT
|
|||||||
rule_key,
|
rule_key,
|
||||||
subject_id,
|
subject_id,
|
||||||
semester_id,
|
semester_id,
|
||||||
active_from_date,
|
lecture_academic_hours,
|
||||||
total_academic_hours
|
laboratory_academic_hours,
|
||||||
|
practice_academic_hours,
|
||||||
|
lecture_start_week,
|
||||||
|
laboratory_start_week,
|
||||||
|
practice_start_week
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
data.rule_key,
|
data.rule_key,
|
||||||
subj.id AS subject_id,
|
subj.id AS subject_id,
|
||||||
sem.id AS semester_id,
|
sem.id AS semester_id,
|
||||||
data.active_from_date,
|
data.lecture_academic_hours,
|
||||||
data.total_academic_hours
|
data.laboratory_academic_hours,
|
||||||
|
data.practice_academic_hours,
|
||||||
|
data.lecture_start_week,
|
||||||
|
data.laboratory_start_week,
|
||||||
|
data.practice_start_week
|
||||||
FROM (VALUES
|
FROM (VALUES
|
||||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
||||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
||||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
||||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54)
|
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
||||||
) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours)
|
) AS data(
|
||||||
|
rule_key,
|
||||||
|
subject_name,
|
||||||
|
year_title,
|
||||||
|
semester_type,
|
||||||
|
lecture_academic_hours,
|
||||||
|
laboratory_academic_hours,
|
||||||
|
practice_academic_hours,
|
||||||
|
lecture_start_week,
|
||||||
|
laboratory_start_week,
|
||||||
|
practice_start_week
|
||||||
|
)
|
||||||
JOIN subjects subj ON subj.name = data.subject_name
|
JOIN subjects subj ON subj.name = data.subject_name
|
||||||
JOIN academic_years ay ON ay.title = data.year_title
|
JOIN academic_years ay ON ay.title = data.year_title
|
||||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
||||||
) prepared_rules;
|
) prepared_rules;
|
||||||
|
|
||||||
INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours)
|
INSERT INTO schedule_rules (
|
||||||
SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours
|
id,
|
||||||
|
subject_id,
|
||||||
|
semester_id,
|
||||||
|
lecture_academic_hours,
|
||||||
|
laboratory_academic_hours,
|
||||||
|
practice_academic_hours,
|
||||||
|
lecture_start_week,
|
||||||
|
laboratory_start_week,
|
||||||
|
practice_start_week
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
schedule_rule_id,
|
||||||
|
subject_id,
|
||||||
|
semester_id,
|
||||||
|
lecture_academic_hours,
|
||||||
|
laboratory_academic_hours,
|
||||||
|
practice_academic_hours,
|
||||||
|
lecture_start_week,
|
||||||
|
laboratory_start_week,
|
||||||
|
practice_start_week
|
||||||
FROM tmp_default_rules;
|
FROM tmp_default_rules;
|
||||||
|
|
||||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
||||||
@@ -615,6 +669,12 @@ 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 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 'Лимит академических часов практик';
|
||||||
|
COMMENT ON COLUMN schedule_rules.lecture_start_week IS 'Неделя начала лекций в семестре';
|
||||||
|
COMMENT ON COLUMN schedule_rules.laboratory_start_week IS 'Неделя начала лабораторных работ в семестре';
|
||||||
|
COMMENT ON COLUMN schedule_rules.practice_start_week IS 'Неделя начала практик в семестре';
|
||||||
|
|
||||||
COMMENT ON TABLE education_forms IS 'Формы обучения';
|
COMMENT ON TABLE education_forms IS 'Формы обучения';
|
||||||
COMMENT ON TABLE subgroups IS 'Подгруппы';
|
COMMENT ON TABLE subgroups IS 'Подгруппы';
|
||||||
|
|||||||
18
docs/API.md
18
docs/API.md
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
## Динамическое расписание
|
## Динамическое расписание
|
||||||
|
|
||||||
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
|
Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары.
|
||||||
|
|
||||||
### `GET /api/schedule`
|
### `GET /api/schedule`
|
||||||
|
|
||||||
@@ -151,9 +151,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
"groupIds": [1],
|
"groupIds": [1],
|
||||||
"groupNames": ["ИВТ-21-1"],
|
"groupNames": ["ИВТ-21-1"],
|
||||||
"activityType": "Т",
|
"activityType": "Т",
|
||||||
"totalAcademicHours": 72,
|
"lessonTypeAcademicHours": 32,
|
||||||
"consumedAcademicHoursBeforeLesson": 24,
|
"consumedLessonTypeAcademicHoursBeforeLesson": 12,
|
||||||
"remainingAcademicHoursAfterLesson": 46
|
"remainingLessonTypeAcademicHoursAfterLesson": 18
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -279,8 +279,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
{
|
{
|
||||||
"subjectId": 1,
|
"subjectId": 1,
|
||||||
"semesterId": 1,
|
"semesterId": 1,
|
||||||
"activeFromDate": "2026-02-01",
|
"lectureAcademicHours": 32,
|
||||||
"totalAcademicHours": 72,
|
"laboratoryAcademicHours": 16,
|
||||||
|
"practiceAcademicHours": 24,
|
||||||
|
"lectureStartWeek": 1,
|
||||||
|
"laboratoryStartWeek": 3,
|
||||||
|
"practiceStartWeek": 2,
|
||||||
"groupIds": [1, 2],
|
"groupIds": [1, 2],
|
||||||
"slots": [
|
"slots": [
|
||||||
{
|
{
|
||||||
@@ -309,6 +313,8 @@ CRUD доступен по:
|
|||||||
|
|
||||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
|
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||||
|
|
||||||
### `GET /api/lesson-types`
|
### `GET /api/lesson-types`
|
||||||
|
|
||||||
Справочник типов занятий для конструктора правил расписания.
|
Справочник типов занятий для конструктора правил расписания.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
||||||
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
||||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||||
| `schedule_rules` | Лимит часов дисциплины в семестре |
|
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||||
|
|
||||||
@@ -93,9 +93,10 @@
|
|||||||
4. Определяет код активности по курсу группы и конкретной дате.
|
4. Определяет код активности по курсу группы и конкретной дате.
|
||||||
5. Пропускает день, если код активности не разрешает обычные пары.
|
5. Пропускает день, если код активности не разрешает обычные пары.
|
||||||
6. Загружает правила группы или преподавателя.
|
6. Загружает правила группы или преподавателя.
|
||||||
7. Симулирует уже проведённые занятия от `active_from_date`.
|
7. Для каждого типа занятия проверяет свою неделю начала в семестре.
|
||||||
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||||
9. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик.
|
||||||
|
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||||
|
|
||||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||||
|
|
||||||
@@ -119,7 +120,8 @@
|
|||||||
|
|
||||||
### Валидация правил расписания
|
### Валидация правил расписания
|
||||||
|
|
||||||
- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа.
|
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
|
||||||
|
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||||
|
|||||||
@@ -207,8 +207,12 @@ erDiagram
|
|||||||
BIGSERIAL id PK
|
BIGSERIAL id PK
|
||||||
BIGINT subject_id FK
|
BIGINT subject_id FK
|
||||||
BIGINT semester_id FK
|
BIGINT semester_id FK
|
||||||
DATE active_from_date
|
INT lecture_academic_hours
|
||||||
INT total_academic_hours
|
INT laboratory_academic_hours
|
||||||
|
INT practice_academic_hours
|
||||||
|
INT lecture_start_week
|
||||||
|
INT laboratory_start_week
|
||||||
|
INT practice_start_week
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule_rule_groups {
|
schedule_rule_groups {
|
||||||
@@ -507,8 +511,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
| `id` | BIGSERIAL PK | ID |
|
| `id` | BIGSERIAL PK | ID |
|
||||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||||
| `active_from_date` | DATE | Дата начала действия правила |
|
| `lecture_academic_hours` | INT | Лимит академических часов лекций |
|
||||||
| `total_academic_hours` | INT | Лимит академических часов |
|
| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ |
|
||||||
|
| `practice_academic_hours` | INT | Лимит академических часов практик |
|
||||||
|
| `lecture_start_week` | INT | Неделя семестра, с которой начинаются лекции |
|
||||||
|
| `laboratory_start_week` | INT | Неделя семестра, с которой начинаются лабораторные |
|
||||||
|
| `practice_start_week` | INT | Неделя семестра, с которой начинаются практики |
|
||||||
|
|
||||||
#### `schedule_rule_groups` — Группы правила
|
#### `schedule_rule_groups` — Группы правила
|
||||||
| Колонка | Тип | Описание |
|
| Колонка | Тип | Описание |
|
||||||
@@ -548,11 +556,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
|||||||
|
|
||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии |
|
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание с раздельными часами и стартовыми неделями по типам занятий, тестовые правила, триггеры, комментарии |
|
||||||
|
|
||||||
### Накатывание на существующих тенантов
|
### Накатывание на существующих тенантов
|
||||||
|
|
||||||
Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend:
|
Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Kubernetes
|
# Kubernetes
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. По требованию текущей ветки новая отдельная Flyway-миграция не создавалась; применение рассчитано на полный сброс БД.
|
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
|
||||||
|
|
||||||
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
| `academic_calendar_days` | Дневная сетка графика по курсу и дате |
|
| `academic_calendar_days` | Дневная сетка графика по курсу и дате |
|
||||||
| `student_group_calendar_assignments` | Назначения графиков группам по учебному году |
|
| `student_group_calendar_assignments` | Назначения графиков группам по учебному году |
|
||||||
| `time_slots` | Настраиваемая сетка пар |
|
| `time_slots` | Настраиваемая сетка пар |
|
||||||
| `schedule_rules` | Правила проведения дисциплин |
|
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||||
| `schedule_rule_groups` | Группы правила |
|
| `schedule_rule_groups` | Группы правила |
|
||||||
| `schedule_rule_slots` | Шаблонные слоты правила |
|
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
||||||
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
||||||
|
|
||||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
Админский экран `schedule` содержит:
|
Админский экран `schedule` содержит:
|
||||||
|
|
||||||
- конструктор правил динамического расписания;
|
- конструктор правил динамического расписания;
|
||||||
|
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
||||||
- управление временными слотами;
|
- управление временными слотами;
|
||||||
- учебные годы и семестры;
|
- учебные годы и семестры;
|
||||||
- CRUD календарных графиков;
|
- CRUD календарных графиков;
|
||||||
@@ -123,7 +123,7 @@ frontend/
|
|||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/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}`.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
### Слой правил расписания
|
### Слой правил расписания
|
||||||
|
|
||||||
- `time_slots` задаёт сетку пар для тенанта.
|
- `time_slots` задаёт сетку пар для тенанта.
|
||||||
- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов.
|
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
|
||||||
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
||||||
- `schedule_rule_slots` хранит шаблонные занятия правила.
|
- `schedule_rule_slots` хранит шаблонные занятия правила.
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам.
|
- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам.
|
||||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||||
|
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||||
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
||||||
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
||||||
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
||||||
@@ -124,6 +124,33 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-hours-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-hours-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-hours-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: end;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-hours-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-slots-panel {
|
.schedule-slots-panel {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -448,6 +475,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
.schedule-hours-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row {
|
||||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
@@ -464,6 +495,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.schedule-hours-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-slot-row {
|
.schedule-slot-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,43 @@ const SEMESTER_LABELS = {
|
|||||||
spring: 'весенний'
|
spring: 'весенний'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LESSON_TYPE_LIMITS = [
|
||||||
|
{
|
||||||
|
key: 'lecture',
|
||||||
|
label: 'Лекции',
|
||||||
|
hoursField: 'lectureAcademicHours',
|
||||||
|
startWeekField: 'lectureStartWeek',
|
||||||
|
hoursInputId: 'schedule-rule-lecture-hours',
|
||||||
|
startWeekInputId: 'schedule-rule-lecture-start-week'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'laboratory',
|
||||||
|
label: 'Лабораторные',
|
||||||
|
hoursField: 'laboratoryAcademicHours',
|
||||||
|
startWeekField: 'laboratoryStartWeek',
|
||||||
|
hoursInputId: 'schedule-rule-laboratory-hours',
|
||||||
|
startWeekInputId: 'schedule-rule-laboratory-start-week'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'practice',
|
||||||
|
label: 'Практики',
|
||||||
|
hoursField: 'practiceAcademicHours',
|
||||||
|
startWeekField: 'practiceStartWeek',
|
||||||
|
hoursInputId: 'schedule-rule-practice-hours',
|
||||||
|
startWeekInputId: 'schedule-rule-practice-start-week'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export async function initSchedule() {
|
export async function initSchedule() {
|
||||||
const ruleForm = document.getElementById('schedule-rule-form');
|
const ruleForm = document.getElementById('schedule-rule-form');
|
||||||
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
||||||
const ruleIdInput = document.getElementById('schedule-rule-id');
|
const ruleIdInput = document.getElementById('schedule-rule-id');
|
||||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||||
const ruleActiveFromInput = document.getElementById('schedule-rule-active-from');
|
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||||||
const ruleHoursInput = document.getElementById('schedule-rule-hours');
|
hours: document.getElementById(config.hoursInputId),
|
||||||
|
startWeek: document.getElementById(config.startWeekInputId)
|
||||||
|
}]));
|
||||||
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
|
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
|
||||||
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');
|
||||||
@@ -92,18 +121,18 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Загрузка...</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
rules = await api.get('/api/admin/schedule-rules');
|
rules = await api.get('/api/admin/schedule-rules');
|
||||||
renderRules();
|
renderRules();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rulesTbody.innerHTML = `<tr><td colspan="8" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRules() {
|
function renderRules() {
|
||||||
if (!rules.length) {
|
if (!rules.length) {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="8" class="loading-row">Правила расписания не созданы</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rulesTbody.innerHTML = rules.map(rule => `
|
rulesTbody.innerHTML = rules.map(rule => `
|
||||||
@@ -112,8 +141,7 @@ export async function initSchedule() {
|
|||||||
<td>${escapeHtml(rule.subjectName || '-')}</td>
|
<td>${escapeHtml(rule.subjectName || '-')}</td>
|
||||||
<td>${escapeHtml(formatSemester(rule))}</td>
|
<td>${escapeHtml(formatSemester(rule))}</td>
|
||||||
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
|
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
|
||||||
<td>${escapeHtml(rule.activeFromDate || '-')}</td>
|
<td>${renderRuleLoadSummary(rule)}</td>
|
||||||
<td>${escapeHtml(String(rule.totalAcademicHours ?? '-'))}</td>
|
|
||||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||||
@@ -140,6 +168,20 @@ export async function initSchedule() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRuleLoadSummary(rule) {
|
||||||
|
const rows = LESSON_TYPE_LIMITS
|
||||||
|
.map(config => ({
|
||||||
|
label: config.label,
|
||||||
|
hours: Number(rule[config.hoursField] || 0),
|
||||||
|
startWeek: Number(rule[config.startWeekField] || 1)
|
||||||
|
}))
|
||||||
|
.filter(item => item.hours > 0);
|
||||||
|
if (!rows.length) return '<span class="muted">Часы не указаны</span>';
|
||||||
|
return rows.map(item => (
|
||||||
|
`<div class="schedule-slot-summary">${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}</div>`
|
||||||
|
)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRuleTableClick(event) {
|
async function handleRuleTableClick(event) {
|
||||||
const editButton = event.target.closest('.btn-edit-rule');
|
const editButton = event.target.closest('.btn-edit-rule');
|
||||||
const deleteButton = event.target.closest('.btn-delete-rule');
|
const deleteButton = event.target.closest('.btn-delete-rule');
|
||||||
@@ -186,16 +228,15 @@ export async function initSchedule() {
|
|||||||
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('Выберите семестр');
|
||||||
if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила');
|
const typeLimits = readTypeLimits();
|
||||||
if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов');
|
validateTypeLimits(typeLimits, slots);
|
||||||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||||||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||||||
return {
|
return {
|
||||||
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
||||||
subjectId: Number(ruleSubjectSelect.value),
|
subjectId: Number(ruleSubjectSelect.value),
|
||||||
semesterId: Number(ruleSemesterSelect.value),
|
semesterId: Number(ruleSemesterSelect.value),
|
||||||
activeFromDate: ruleActiveFromInput.value,
|
...typeLimits,
|
||||||
totalAcademicHours: Number(ruleHoursInput.value),
|
|
||||||
groupIds,
|
groupIds,
|
||||||
slots
|
slots
|
||||||
};
|
};
|
||||||
@@ -206,8 +247,7 @@ export async function initSchedule() {
|
|||||||
ruleIdInput.value = rule.id;
|
ruleIdInput.value = rule.id;
|
||||||
ruleSubjectSelect.value = rule.subjectId || '';
|
ruleSubjectSelect.value = rule.subjectId || '';
|
||||||
ruleSemesterSelect.value = rule.semesterId || '';
|
ruleSemesterSelect.value = rule.semesterId || '';
|
||||||
ruleActiveFromInput.value = rule.activeFromDate || '';
|
fillTypeLimits(rule);
|
||||||
ruleHoursInput.value = rule.totalAcademicHours || '';
|
|
||||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||||
renderRuleGroups(rule.groupIds || []);
|
renderRuleGroups(rule.groupIds || []);
|
||||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||||
@@ -219,6 +259,7 @@ export async function initSchedule() {
|
|||||||
ruleForm.reset();
|
ruleForm.reset();
|
||||||
ruleFormTitle.textContent = 'Новое правило расписания';
|
ruleFormTitle.textContent = 'Новое правило расписания';
|
||||||
ruleIdInput.value = '';
|
ruleIdInput.value = '';
|
||||||
|
fillTypeLimits({});
|
||||||
renderRuleGroups([]);
|
renderRuleGroups([]);
|
||||||
renderRuleSlots([{}]);
|
renderRuleSlots([{}]);
|
||||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||||
@@ -277,6 +318,42 @@ export async function initSchedule() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTypeLimits() {
|
||||||
|
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
|
||||||
|
const inputs = ruleTypeInputs[config.key];
|
||||||
|
payload[config.hoursField] = Number(inputs.hours.value || 0);
|
||||||
|
payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
|
||||||
|
return payload;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillTypeLimits(rule) {
|
||||||
|
LESSON_TYPE_LIMITS.forEach(config => {
|
||||||
|
const inputs = ruleTypeInputs[config.key];
|
||||||
|
inputs.hours.value = rule[config.hoursField] ?? 0;
|
||||||
|
inputs.startWeek.value = rule[config.startWeekField] ?? 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTypeLimits(typeLimits, slots) {
|
||||||
|
const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
|
||||||
|
let hasHours = false;
|
||||||
|
for (const config of LESSON_TYPE_LIMITS) {
|
||||||
|
const hours = typeLimits[config.hoursField];
|
||||||
|
const startWeek = typeLimits[config.startWeekField];
|
||||||
|
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
|
||||||
|
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
|
||||||
|
if (hours > 0) hasHours = true;
|
||||||
|
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
|
||||||
|
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
|
||||||
|
}
|
||||||
|
if (hours === 0 && selectedTypeKeys.has(config.key)) {
|
||||||
|
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTimeSlots() {
|
async function loadTimeSlots() {
|
||||||
const slots = await api.get('/api/admin/time-slots');
|
const slots = await api.get('/api/admin/time-slots');
|
||||||
timeSlots = slots
|
timeSlots = slots
|
||||||
@@ -322,6 +399,19 @@ export async function initSchedule() {
|
|||||||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lessonTypeKeyById(lessonTypeId) {
|
||||||
|
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||||||
|
return lessonType ? lessonTypeKey(lessonType) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lessonTypeKey(lessonType) {
|
||||||
|
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||||||
|
if (name.includes('лекц')) return 'lecture';
|
||||||
|
if (name.includes('лаб')) return 'laboratory';
|
||||||
|
if (name.includes('практ')) return 'practice';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function semesterLabel(semester) {
|
function semesterLabel(semester) {
|
||||||
const type = normalizeSemesterType(semester.semesterType);
|
const type = normalizeSemesterType(semester.semesterType);
|
||||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||||
|
|||||||
@@ -18,13 +18,44 @@
|
|||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="schedule-rule-active-from">Дата начала</label>
|
|
||||||
<input type="date" id="schedule-rule-active-from" required>
|
<div class="schedule-hours-panel">
|
||||||
</div>
|
<label class="schedule-section-label">Часы и недели начала</label>
|
||||||
<div class="form-group">
|
<div class="schedule-hours-grid">
|
||||||
<label for="schedule-rule-hours">Академические часы</label>
|
<div class="schedule-hours-row">
|
||||||
<input type="number" id="schedule-rule-hours" min="1" step="1" placeholder="72" required>
|
<div class="schedule-hours-title">Лекции</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-lecture-hours">Часы</label>
|
||||||
|
<input type="number" id="schedule-rule-lecture-hours" min="0" step="1" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-lecture-start-week">Неделя начала</label>
|
||||||
|
<input type="number" id="schedule-rule-lecture-start-week" min="1" step="1" value="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-hours-row">
|
||||||
|
<div class="schedule-hours-title">Лабораторные</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-laboratory-hours">Часы</label>
|
||||||
|
<input type="number" id="schedule-rule-laboratory-hours" min="0" step="1" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-laboratory-start-week">Неделя начала</label>
|
||||||
|
<input type="number" id="schedule-rule-laboratory-start-week" min="1" step="1" value="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-hours-row">
|
||||||
|
<div class="schedule-hours-title">Практики</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-practice-hours">Часы</label>
|
||||||
|
<input type="number" id="schedule-rule-practice-hours" min="0" step="1" value="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rule-practice-start-week">Неделя начала</label>
|
||||||
|
<input type="number" id="schedule-rule-practice-start-week" min="1" step="1" value="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,15 +92,14 @@
|
|||||||
<th>Дисциплина</th>
|
<th>Дисциплина</th>
|
||||||
<th>Семестр</th>
|
<th>Семестр</th>
|
||||||
<th>Группы</th>
|
<th>Группы</th>
|
||||||
<th>Начало</th>
|
<th>Нагрузка</th>
|
||||||
<th>Часы</th>
|
|
||||||
<th>Слоты</th>
|
<th>Слоты</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="schedule-tbody">
|
<tbody id="schedule-tbody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="loading-row">Загрузка...</td>
|
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user