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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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