написал обработку количества часов лекций лабораторных и практик по отдельности

This commit is contained in:
Zuev
2026-05-06 21:54:17 +03:00
parent 44cfe8ab6e
commit b222f3adac
17 changed files with 540 additions and 113 deletions

View File

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

View File

@@ -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
) { ) {
} }

View File

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

View File

@@ -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("Тип занятия должен быть лекцией, практикой или лабораторной работой");
}
}

View File

@@ -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() {

View File

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

View File

@@ -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 'Подгруппы';

View File

@@ -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`
Справочник типов занятий для конструктора правил расписания. Справочник типов занятий для конструктора правил расписания.

View File

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

View File

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

View File

@@ -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 календарных графиков;

View File

@@ -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}`.

View File

@@ -27,7 +27,7 @@
### Слой правил расписания ### Слой правил расписания
- `time_slots` задаёт сетку пар для тенанта. - `time_slots` задаёт сетку пар для тенанта.
- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов. - `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик.
- `schedule_rule_groups` связывает правило с одной или несколькими группами. - `schedule_rule_groups` связывает правило с одной или несколькими группами.
- `schedule_rule_slots` хранит шаблонные занятия правила. - `schedule_rule_slots` хранит шаблонные занятия правила.

View File

@@ -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`.

View File

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

View File

@@ -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})`;

View File

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