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

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));
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) {
LocalDate start = rule.getActiveFromDate() == null
? rule.getSemester().getStartDate()
: rule.getActiveFromDate();
if (start.isBefore(rule.getSemester().getStartDate())) {
start = rule.getSemester().getStartDate();
}
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 'Подгруппы';