написал обработку количества часов лекций лабораторных и практик по отдельности
This commit is contained in:
@@ -124,11 +124,9 @@ public class ScheduleRuleAdminController {
|
||||
if (request.semesterId() == null) {
|
||||
return "Семестр обязателен";
|
||||
}
|
||||
if (request.activeFromDate() == null) {
|
||||
return "Дата начала правила обязательна";
|
||||
}
|
||||
if (request.totalAcademicHours() == null || request.totalAcademicHours() <= 0) {
|
||||
return "Количество академических часов должно быть больше нуля";
|
||||
String hoursValidationError = validateTypeHours(request);
|
||||
if (hoursValidationError != null) {
|
||||
return hoursValidationError;
|
||||
}
|
||||
if (request.groupIds() == null || request.groupIds().isEmpty()) {
|
||||
return "Нужно выбрать хотя бы одну группу";
|
||||
@@ -151,15 +149,23 @@ public class ScheduleRuleAdminController {
|
||||
|
||||
rule.setSubject(subject);
|
||||
rule.setSemester(semester);
|
||||
rule.setActiveFromDate(request.activeFromDate());
|
||||
rule.setTotalAcademicHours(request.totalAcademicHours());
|
||||
rule.setLectureAcademicHours(request.lectureAcademicHours());
|
||||
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().addAll(groups);
|
||||
|
||||
rule.getSlots().clear();
|
||||
List<ScheduleRuleSlot> slots = new ArrayList<>();
|
||||
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) {
|
||||
@@ -181,6 +187,7 @@ public class ScheduleRuleAdminController {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена"));
|
||||
LessonType lessonType = lessonTypesRepository.findById(slotDto.lessonTypeId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Тип занятия не найден"));
|
||||
ScheduleLessonCategory.fromLessonType(lessonType);
|
||||
if (slotDto.lessonFormat() == null || slotDto.lessonFormat().isBlank()) {
|
||||
throw new IllegalArgumentException("Формат занятия обязателен");
|
||||
}
|
||||
@@ -198,6 +205,65 @@ public class ScheduleRuleAdminController {
|
||||
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) {
|
||||
List<StudentGroup> groups = rule.getGroups().stream()
|
||||
.sorted(Comparator.comparing(StudentGroup::getName))
|
||||
@@ -217,8 +283,12 @@ public class ScheduleRuleAdminController {
|
||||
rule.getSemester().getId(),
|
||||
rule.getSemester().getAcademicYear().getTitle(),
|
||||
rule.getSemester().getSemesterType(),
|
||||
rule.getActiveFromDate(),
|
||||
rule.getTotalAcademicHours(),
|
||||
rule.getLectureAcademicHours(),
|
||||
rule.getLaboratoryAcademicHours(),
|
||||
rule.getPracticeAcademicHours(),
|
||||
rule.getLectureStartWeek(),
|
||||
rule.getLaboratoryStartWeek(),
|
||||
rule.getPracticeStartWeek(),
|
||||
groups.stream().map(StudentGroup::getId).toList(),
|
||||
groups.stream().map(StudentGroup::getName).toList(),
|
||||
slots
|
||||
|
||||
@@ -31,8 +31,8 @@ public record RenderedLessonDto(
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
String activityType,
|
||||
Integer totalAcademicHours,
|
||||
Integer consumedAcademicHoursBeforeLesson,
|
||||
Integer remainingAcademicHoursAfterLesson
|
||||
Integer lessonTypeAcademicHours,
|
||||
Integer consumedLessonTypeAcademicHoursBeforeLesson,
|
||||
Integer remainingLessonTypeAcademicHoursAfterLesson
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.magistr.app.dto;
|
||||
|
||||
import com.magistr.app.model.SemesterType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRuleDto(
|
||||
@@ -12,8 +11,12 @@ public record ScheduleRuleDto(
|
||||
Long semesterId,
|
||||
String academicYearTitle,
|
||||
SemesterType semesterType,
|
||||
LocalDate activeFromDate,
|
||||
Integer totalAcademicHours,
|
||||
Integer lectureAcademicHours,
|
||||
Integer laboratoryAcademicHours,
|
||||
Integer practiceAcademicHours,
|
||||
Integer lectureStartWeek,
|
||||
Integer laboratoryStartWeek,
|
||||
Integer practiceStartWeek,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
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 java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -22,11 +21,23 @@ public class ScheduleRule {
|
||||
@JoinColumn(name = "semester_id", nullable = false)
|
||||
private Semester semester;
|
||||
|
||||
@Column(name = "active_from_date", nullable = false)
|
||||
private LocalDate activeFromDate;
|
||||
@Column(name = "lecture_academic_hours", nullable = false)
|
||||
private Integer lectureAcademicHours = 0;
|
||||
|
||||
@Column(name = "total_academic_hours", nullable = false)
|
||||
private Integer totalAcademicHours;
|
||||
@Column(name = "laboratory_academic_hours", nullable = false)
|
||||
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
|
||||
@JoinTable(
|
||||
@@ -63,20 +74,76 @@ public class ScheduleRule {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public LocalDate getActiveFromDate() {
|
||||
return activeFromDate;
|
||||
public Integer getLectureAcademicHours() {
|
||||
return lectureAcademicHours;
|
||||
}
|
||||
|
||||
public void setActiveFromDate(LocalDate activeFromDate) {
|
||||
this.activeFromDate = activeFromDate;
|
||||
public void setLectureAcademicHours(Integer lectureAcademicHours) {
|
||||
this.lectureAcademicHours = lectureAcademicHours;
|
||||
}
|
||||
|
||||
public Integer getTotalAcademicHours() {
|
||||
return totalAcademicHours;
|
||||
public Integer getLaboratoryAcademicHours() {
|
||||
return laboratoryAcademicHours;
|
||||
}
|
||||
|
||||
public void setTotalAcademicHours(Integer totalAcademicHours) {
|
||||
this.totalAcademicHours = totalAcademicHours;
|
||||
public void setLaboratoryAcademicHours(Integer laboratoryAcademicHours) {
|
||||
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() {
|
||||
|
||||
@@ -157,10 +157,9 @@ public class ScheduleGeneratorService {
|
||||
StudentGroup targetGroup,
|
||||
Long targetTeacherId,
|
||||
List<RenderedLessonDto> result) {
|
||||
if (rule.getActiveFromDate() != null && date.isBefore(rule.getActiveFromDate())) {
|
||||
return;
|
||||
}
|
||||
if (rule.getTotalAcademicHours() == null || rule.getTotalAcademicHours() <= 0) {
|
||||
if (rule.getLectureAcademicHours() == null
|
||||
|| rule.getLaboratoryAcademicHours() == null
|
||||
|| rule.getPracticeAcademicHours() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,6 +175,7 @@ public class ScheduleGeneratorService {
|
||||
}
|
||||
|
||||
ScheduleParity dateParity = academicDateService.getParity(semester, date);
|
||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||
int dayOfWeek = date.getDayOfWeek().getValue();
|
||||
List<ScheduleRuleSlot> slots = rule.getSlots().stream()
|
||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||
@@ -190,16 +190,28 @@ public class ScheduleGeneratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
int consumedHours = calculateConsumedHoursBeforeDate(rule, targetGroup, date);
|
||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||
Map<ScheduleLessonCategory, Integer> consumedHoursByCategory = new EnumMap<>(ScheduleLessonCategory.class);
|
||||
|
||||
for (ScheduleRuleSlot slot : slots) {
|
||||
if (consumedHours >= rule.getTotalAcademicHours()) {
|
||||
break;
|
||||
ScheduleLessonCategory category = ScheduleLessonCategory.fromLessonType(slot.getLessonType());
|
||||
if (weekNumber < rule.startWeekFor(category)) {
|
||||
continue;
|
||||
}
|
||||
int remainingAfterLesson = Math.max(0, rule.getTotalAcademicHours() - consumedHours - ACADEMIC_HOURS_PER_SLOT);
|
||||
result.add(toRenderedLesson(rule, slot, date, weekNumber, dateParity, eligibleGroups, consumedHours, remainingAfterLesson));
|
||||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||||
int totalHours = rule.academicHoursFor(category);
|
||||
if (totalHours <= 0) {
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
String cacheKey = rule.getId() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
||||
return consumedHoursCache.computeIfAbsent(cacheKey, ignored -> calculateConsumedHours(rule, targetGroup, targetDate));
|
||||
}
|
||||
|
||||
private int calculateConsumedHours(ScheduleRule rule, StudentGroup targetGroup, LocalDate targetDate) {
|
||||
LocalDate start = rule.getActiveFromDate() == null
|
||||
? rule.getSemester().getStartDate()
|
||||
: rule.getActiveFromDate();
|
||||
if (start.isBefore(rule.getSemester().getStartDate())) {
|
||||
start = rule.getSemester().getStartDate();
|
||||
String cacheKey = rule.getId() + ":" + category.name() + ":" + (groupId == null ? "all" : groupId) + ":" + targetDate;
|
||||
return consumedHoursCache.computeIfAbsent(cacheKey,
|
||||
ignored -> calculateConsumedHours(rule, targetGroup, targetDate, category));
|
||||
}
|
||||
|
||||
private int calculateConsumedHours(ScheduleRule rule,
|
||||
StudentGroup targetGroup,
|
||||
LocalDate targetDate,
|
||||
ScheduleLessonCategory category) {
|
||||
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();
|
||||
if (date.isBefore(semester.getStartDate()) || date.isAfter(semester.getEndDate())) {
|
||||
continue;
|
||||
}
|
||||
int weekNumber = academicDateService.getWeekNumber(semester, date);
|
||||
if (weekNumber < rule.startWeekFor(category)) {
|
||||
continue;
|
||||
}
|
||||
if (eligibleGroups(rule, targetGroup, semester, date).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
@@ -243,9 +259,10 @@ public class ScheduleGeneratorService {
|
||||
long activeSlots = rule.getSlots().stream()
|
||||
.filter(slot -> Objects.equals(slot.getDayOfWeek(), dayOfWeek))
|
||||
.filter(slot -> slot.getParity() == ScheduleParity.BOTH || slot.getParity() == dateParity)
|
||||
.filter(slot -> ScheduleLessonCategory.fromLessonType(slot.getLessonType()) == category)
|
||||
.count();
|
||||
consumed += (int) activeSlots * ACADEMIC_HOURS_PER_SLOT;
|
||||
if (consumed >= rule.getTotalAcademicHours()) {
|
||||
if (consumed >= rule.academicHoursFor(category)) {
|
||||
return consumed;
|
||||
}
|
||||
}
|
||||
@@ -258,6 +275,7 @@ public class ScheduleGeneratorService {
|
||||
int weekNumber,
|
||||
ScheduleParity dateParity,
|
||||
List<StudentGroup> groups,
|
||||
int lessonTypeAcademicHours,
|
||||
int consumedBeforeLesson,
|
||||
int remainingAfterLesson) {
|
||||
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
|
||||
@@ -290,7 +308,7 @@ public class ScheduleGeneratorService {
|
||||
sortedGroups.stream().map(StudentGroup::getId).toList(),
|
||||
sortedGroups.stream().map(StudentGroup::getName).collect(Collectors.toList()),
|
||||
"Т",
|
||||
rule.getTotalAcademicHours(),
|
||||
lessonTypeAcademicHours,
|
||||
consumedBeforeLesson,
|
||||
remainingAfterLesson
|
||||
);
|
||||
|
||||
@@ -461,9 +461,25 @@ CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
|
||||
active_from_date DATE NOT NULL,
|
||||
total_academic_hours INT NOT NULL,
|
||||
CONSTRAINT chk_schedule_rules_hours_positive CHECK (total_academic_hours > 0)
|
||||
lecture_academic_hours INT NOT NULL,
|
||||
laboratory_academic_hours INT NOT NULL,
|
||||
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);
|
||||
@@ -504,28 +520,66 @@ SELECT
|
||||
rule_key,
|
||||
subject_id,
|
||||
semester_id,
|
||||
active_from_date,
|
||||
total_academic_hours
|
||||
lecture_academic_hours,
|
||||
laboratory_academic_hours,
|
||||
practice_academic_hours,
|
||||
lecture_start_week,
|
||||
laboratory_start_week,
|
||||
practice_start_week
|
||||
FROM (
|
||||
SELECT
|
||||
data.rule_key,
|
||||
subj.id AS subject_id,
|
||||
sem.id AS semester_id,
|
||||
data.active_from_date,
|
||||
data.total_academic_hours
|
||||
data.lecture_academic_hours,
|
||||
data.laboratory_academic_hours,
|
||||
data.practice_academic_hours,
|
||||
data.lecture_start_week,
|
||||
data.laboratory_start_week,
|
||||
data.practice_start_week
|
||||
FROM (VALUES
|
||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', DATE '2026-02-02', 72),
|
||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', DATE '2026-02-02', 54)
|
||||
) AS data(rule_key, subject_name, year_title, semester_type, active_from_date, total_academic_hours)
|
||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
||||
) 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 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
|
||||
) prepared_rules;
|
||||
|
||||
INSERT INTO schedule_rules (id, subject_id, semester_id, active_from_date, total_academic_hours)
|
||||
SELECT schedule_rule_id, subject_id, semester_id, active_from_date, total_academic_hours
|
||||
INSERT INTO schedule_rules (
|
||||
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;
|
||||
|
||||
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_rule_groups 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 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`
|
||||
|
||||
@@ -151,9 +151,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
"groupIds": [1],
|
||||
"groupNames": ["ИВТ-21-1"],
|
||||
"activityType": "Т",
|
||||
"totalAcademicHours": 72,
|
||||
"consumedAcademicHoursBeforeLesson": 24,
|
||||
"remainingAcademicHoursAfterLesson": 46
|
||||
"lessonTypeAcademicHours": 32,
|
||||
"consumedLessonTypeAcademicHoursBeforeLesson": 12,
|
||||
"remainingLessonTypeAcademicHoursAfterLesson": 18
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -279,8 +279,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
{
|
||||
"subjectId": 1,
|
||||
"semesterId": 1,
|
||||
"activeFromDate": "2026-02-01",
|
||||
"totalAcademicHours": 72,
|
||||
"lectureAcademicHours": 32,
|
||||
"laboratoryAcademicHours": 16,
|
||||
"practiceAcademicHours": 24,
|
||||
"lectureStartWeek": 1,
|
||||
"laboratoryStartWeek": 3,
|
||||
"practiceStartWeek": 2,
|
||||
"groupIds": [1, 2],
|
||||
"slots": [
|
||||
{
|
||||
@@ -309,6 +313,8 @@ CRUD доступен по:
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
|
||||
|
||||
### `GET /api/lesson-types`
|
||||
|
||||
Справочник типов занятий для конструктора правил расписания.
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
||||
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
| `schedule_rules` | Лимит часов дисциплины в семестре |
|
||||
| `schedule_rules` | Лимиты часов и недели начала по лекциям, лабораторным и практикам |
|
||||
| `schedule_rule_groups` | Группы правила, включая потоковые лекции |
|
||||
| `schedule_rule_slots` | День, чётность, слот, преподаватель, аудитория, тип и формат занятия |
|
||||
|
||||
@@ -93,9 +93,10 @@
|
||||
4. Определяет код активности по курсу группы и конкретной дате.
|
||||
5. Пропускает день, если код активности не разрешает обычные пары.
|
||||
6. Загружает правила группы или преподавателя.
|
||||
7. Симулирует уже проведённые занятия от `active_from_date`.
|
||||
7. Для каждого типа занятия проверяет свою неделю начала в семестре.
|
||||
8. Подставляет эффективную сетку времени даты: ручную, субботнюю или базовую.
|
||||
9. Останавливает вывод правила, когда достигнут `total_academic_hours`.
|
||||
9. Считает уже проведённые часы отдельно для лекций, лабораторных и практик.
|
||||
10. Останавливает вывод слотов конкретного типа, когда достигнут его лимит часов.
|
||||
|
||||
Обычные пары генерируются только на коде `Т` (`allow_schedule = true`). Экзамены, каникулы, практики, нерабочие дни, праздники `*` и дни вне учебного года `=` считаются пропуском: занятие не переносится и не списывает академические часы. Если у группы нет назначения графика на учебный год, `GET /api/schedule` возвращает пустой список для этой группы без ошибки.
|
||||
|
||||
@@ -119,7 +120,8 @@
|
||||
|
||||
### Валидация правил расписания
|
||||
|
||||
- **Правило:** обязательны дисциплина, семестр, дата начала, положительный лимит академических часов и хотя бы одна группа.
|
||||
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
|
||||
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
|
||||
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна.
|
||||
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
|
||||
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
|
||||
|
||||
@@ -207,8 +207,12 @@ erDiagram
|
||||
BIGSERIAL id PK
|
||||
BIGINT subject_id FK
|
||||
BIGINT semester_id FK
|
||||
DATE active_from_date
|
||||
INT total_academic_hours
|
||||
INT lecture_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 {
|
||||
@@ -507,8 +511,12 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||
| `active_from_date` | DATE | Дата начала действия правила |
|
||||
| `total_academic_hours` | INT | Лимит академических часов |
|
||||
| `lecture_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` — Группы правила
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -548,11 +556,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание, тестовые правила, триггеры, комментарии |
|
||||
| `V1__init.sql` | Инициализация: справочники, профили специальностей, календарные учебные графики, динамическое расписание с раздельными часами и стартовыми неделями по типам занятий, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
Для применения новой миграции к уже существующим тенантам необходимо перезапустить backend:
|
||||
Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`.
|
||||
|
||||
```bash
|
||||
# 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` и календарного учебного графика группы.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| `academic_calendar_days` | Дневная сетка графика по курсу и дате |
|
||||
| `student_group_calendar_assignments` | Назначения графиков группам по учебному году |
|
||||
| `time_slots` | Настраиваемая сетка пар |
|
||||
| `schedule_rules` | Правила проведения дисциплин |
|
||||
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||
| `schedule_rule_groups` | Группы правила |
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `AcademicDateService` | Семестр по дате, номер недели, чётность, курс группы, активность дня по назначенному графику |
|
||||
| `ScheduleGeneratorService` | Рендер расписания группы или преподавателя на диапазон дат |
|
||||
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
`ScheduleGeneratorService` пропускает день, если код активности не разрешает обычные пары. Для лекций, лабораторных и практик генератор отдельно проверяет стартовую неделю и отдельно списывает академические часы. Если у группы нет назначенного графика на учебный год, расписание этой группы возвращается пустым списком.
|
||||
|
||||
## API
|
||||
|
||||
@@ -71,6 +71,7 @@ GET /api/schedule?teacherId=2&startDate=2026-04-27&endDate=2026-05-03
|
||||
Админский экран `schedule` содержит:
|
||||
|
||||
- конструктор правил динамического расписания;
|
||||
- отдельные поля часов и недель начала для лекций, лабораторных и практик;
|
||||
- управление временными слотами;
|
||||
- учебные годы и семестры;
|
||||
- CRUD календарных графиков;
|
||||
@@ -123,7 +123,7 @@ frontend/
|
||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`.
|
||||
- Вкладка `auditorium-workload` показывает матрицу загруженности по выбранной дате: строки — реальные аудитории из `/api/classrooms`, столбцы — эффективные временные слоты выбранного дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Фильтры корпуса, вместимости и оборудования заполняются из API.
|
||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор базовых слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила.
|
||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Из календарной системы здесь используется список семестров для выбора периода действия правила.
|
||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
|
||||
- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку.
|
||||
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
### Слой правил расписания
|
||||
|
||||
- `time_slots` задаёт сетку пар для тенанта.
|
||||
- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов.
|
||||
- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
|
||||
- `schedule_rule_groups` связывает правило с одной или несколькими группами.
|
||||
- `schedule_rule_slots` хранит шаблонные занятия правила.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам.
|
||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||
- [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам.
|
||||
- [x] Обновлены админские экраны специальностей, групп и расписания.
|
||||
- [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`.
|
||||
@@ -124,6 +124,33 @@
|
||||
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 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -448,6 +475,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-hours-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-slot-row {
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
}
|
||||
@@ -464,6 +495,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.schedule-hours-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-slot-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,43 @@ const SEMESTER_LABELS = {
|
||||
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() {
|
||||
const ruleForm = document.getElementById('schedule-rule-form');
|
||||
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
||||
const ruleIdInput = document.getElementById('schedule-rule-id');
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const ruleActiveFromInput = document.getElementById('schedule-rule-active-from');
|
||||
const ruleHoursInput = document.getElementById('schedule-rule-hours');
|
||||
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||||
hours: document.getElementById(config.hoursInputId),
|
||||
startWeek: document.getElementById(config.startWeekInputId)
|
||||
}]));
|
||||
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
|
||||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||
@@ -92,18 +121,18 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
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 {
|
||||
rules = await api.get('/api/admin/schedule-rules');
|
||||
renderRules();
|
||||
} 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() {
|
||||
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;
|
||||
}
|
||||
rulesTbody.innerHTML = rules.map(rule => `
|
||||
@@ -112,8 +141,7 @@ export async function initSchedule() {
|
||||
<td>${escapeHtml(rule.subjectName || '-')}</td>
|
||||
<td>${escapeHtml(formatSemester(rule))}</td>
|
||||
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
|
||||
<td>${escapeHtml(rule.activeFromDate || '-')}</td>
|
||||
<td>${escapeHtml(String(rule.totalAcademicHours ?? '-'))}</td>
|
||||
<td>${renderRuleLoadSummary(rule)}</td>
|
||||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||||
<td>
|
||||
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||||
@@ -140,6 +168,20 @@ export async function initSchedule() {
|
||||
}).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) {
|
||||
const editButton = event.target.closest('.btn-edit-rule');
|
||||
const deleteButton = event.target.closest('.btn-delete-rule');
|
||||
@@ -186,16 +228,15 @@ export async function initSchedule() {
|
||||
const slots = readRuleSlots(true);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
if (!ruleActiveFromInput.value) throw new Error('Укажите дату начала правила');
|
||||
if (!ruleHoursInput.value || Number(ruleHoursInput.value) <= 0) throw new Error('Укажите количество академических часов');
|
||||
const typeLimits = readTypeLimits();
|
||||
validateTypeLimits(typeLimits, slots);
|
||||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||||
return {
|
||||
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
||||
subjectId: Number(ruleSubjectSelect.value),
|
||||
semesterId: Number(ruleSemesterSelect.value),
|
||||
activeFromDate: ruleActiveFromInput.value,
|
||||
totalAcademicHours: Number(ruleHoursInput.value),
|
||||
...typeLimits,
|
||||
groupIds,
|
||||
slots
|
||||
};
|
||||
@@ -206,8 +247,7 @@ export async function initSchedule() {
|
||||
ruleIdInput.value = rule.id;
|
||||
ruleSubjectSelect.value = rule.subjectId || '';
|
||||
ruleSemesterSelect.value = rule.semesterId || '';
|
||||
ruleActiveFromInput.value = rule.activeFromDate || '';
|
||||
ruleHoursInput.value = rule.totalAcademicHours || '';
|
||||
fillTypeLimits(rule);
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
renderRuleGroups(rule.groupIds || []);
|
||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||
@@ -219,6 +259,7 @@ export async function initSchedule() {
|
||||
ruleForm.reset();
|
||||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||||
ruleIdInput.value = '';
|
||||
fillTypeLimits({});
|
||||
renderRuleGroups([]);
|
||||
renderRuleSlots([{}]);
|
||||
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() {
|
||||
const slots = await api.get('/api/admin/time-slots');
|
||||
timeSlots = slots
|
||||
@@ -322,6 +399,19 @@ export async function initSchedule() {
|
||||
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) {
|
||||
const type = normalizeSemesterType(semester.semesterType);
|
||||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||||
|
||||
@@ -18,13 +18,44 @@
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-hours-panel">
|
||||
<label class="schedule-section-label">Часы и недели начала</label>
|
||||
<div class="schedule-hours-grid">
|
||||
<div class="schedule-hours-row">
|
||||
<div class="schedule-hours-title">Лекции</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rule-active-from">Дата начала</label>
|
||||
<input type="date" id="schedule-rule-active-from" required>
|
||||
<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-hours">Академические часы</label>
|
||||
<input type="number" id="schedule-rule-hours" min="1" step="1" placeholder="72" required>
|
||||
<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>
|
||||
|
||||
@@ -61,15 +92,14 @@
|
||||
<th>Дисциплина</th>
|
||||
<th>Семестр</th>
|
||||
<th>Группы</th>
|
||||
<th>Начало</th>
|
||||
<th>Часы</th>
|
||||
<th>Нагрузка</th>
|
||||
<th>Слоты</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-tbody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-row">Загрузка...</td>
|
||||
<td colspan="7" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user