diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index cda7293..40258a1 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 slots) { + EnumSet 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 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 diff --git a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java index 04db846..633219d 100644 --- a/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java +++ b/backend/src/main/java/com/magistr/app/dto/RenderedLessonDto.java @@ -31,8 +31,8 @@ public record RenderedLessonDto( List groupIds, List groupNames, String activityType, - Integer totalAcademicHours, - Integer consumedAcademicHoursBeforeLesson, - Integer remainingAcademicHoursAfterLesson + Integer lessonTypeAcademicHours, + Integer consumedLessonTypeAcademicHoursBeforeLesson, + Integer remainingLessonTypeAcademicHoursAfterLesson ) { } diff --git a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java index be3b0ed..95d5ad2 100644 --- a/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java +++ b/backend/src/main/java/com/magistr/app/dto/ScheduleRuleDto.java @@ -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 groupIds, List groupNames, List slots diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java new file mode 100644 index 0000000..65a9c12 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/ScheduleLessonCategory.java @@ -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("Тип занятия должен быть лекцией, практикой или лабораторной работой"); + } +} diff --git a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java index 8cddb99..bd64c09 100644 --- a/backend/src/main/java/com/magistr/app/model/ScheduleRule.java +++ b/backend/src/main/java/com/magistr/app/model/ScheduleRule.java @@ -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 getGroups() { diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java index e8e4473..757df1c 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleGeneratorService.java @@ -157,10 +157,9 @@ public class ScheduleGeneratorService { StudentGroup targetGroup, Long targetTeacherId, List 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 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 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 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 ); diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 95a5285..8beacef 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 'Подгруппы'; diff --git a/docs/API.md b/docs/API.md index 2e1c6f7..75f8bf9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,7 +101,7 @@ ## Динамическое расписание -Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. +Новая модель расписания строится из правил (`schedule_rules`) и слотов (`schedule_rule_slots`). В правиле отдельно хранятся часы и стартовые недели для лекций, лабораторных и практик. Фактические занятия рендерятся на диапазон дат только для дней, где календарный учебный график группы имеет код, разрешающий обычные пары. ### `GET /api/schedule` @@ -151,9 +151,9 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 "groupIds": [1], "groupNames": ["ИВТ-21-1"], "activityType": "Т", - "totalAcademicHours": 72, - "consumedAcademicHoursBeforeLesson": 24, - "remainingAcademicHoursAfterLesson": 46 + "lessonTypeAcademicHours": 32, + "consumedLessonTypeAcademicHoursBeforeLesson": 12, + "remainingLessonTypeAcademicHoursAfterLesson": 18 } ] ``` @@ -279,8 +279,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 { "subjectId": 1, "semesterId": 1, - "activeFromDate": "2026-02-01", - "totalAcademicHours": 72, + "lectureAcademicHours": 32, + "laboratoryAcademicHours": 16, + "practiceAcademicHours": 24, + "lectureStartWeek": 1, + "laboratoryStartWeek": 3, + "practiceStartWeek": 2, "groupIds": [1, 2], "slots": [ { @@ -309,6 +313,8 @@ CRUD доступен по: `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. +Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля. + ### `GET /api/lesson-types` Справочник типов занятий для конструктора правил расписания. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 16402f5..940eaa8 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -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` обязателен и хранится в слоте правила. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index e5f22b5..f7f962a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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 diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md similarity index 85% rename from DYNAMIC_SCHEDULE_IMPLEMENTATION.md rename to docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 888fb93..79ed21f 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -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 календарных графиков; diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 3822004..d887377 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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}`. diff --git a/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md similarity index 96% rename from SCHEDULE_PROPOSAL.md rename to docs/SCHEDULE_PROPOSAL.md index 825b22a..3450083 100644 --- a/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -27,7 +27,7 @@ ### Слой правил расписания - `time_slots` задаёт сетку пар для тенанта. -- `schedule_rules` хранит дисциплину, семестр, дату начала и лимит академических часов. +- `schedule_rules` хранит дисциплину, семестр, отдельные лимиты часов и стартовые недели для лекций, лабораторных и практик. - `schedule_rule_groups` связывает правило с одной или несколькими группами. - `schedule_rule_slots` хранит шаблонные занятия правила. diff --git a/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md similarity index 94% rename from SCHEDULE_TASKS.md rename to docs/SCHEDULE_TASKS.md index aec40d0..3af1334 100644 --- a/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -11,6 +11,7 @@ - [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. +- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. - [x] Добавлены API профилей, календарей, дневной сетки, кодов активностей и назначений группам. - [x] Обновлены админские экраны специальностей, групп и расписания. - [x] Добавлена документация `docs/ACADEMIC_CALENDAR.md`. diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 774464e..1cf7c2f 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -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; } diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 1d7ef60..accc674 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -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 = 'Загрузка...'; + rulesTbody.innerHTML = 'Загрузка...'; try { rules = await api.get('/api/admin/schedule-rules'); renderRules(); } catch (error) { - rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + rulesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; } } function renderRules() { if (!rules.length) { - rulesTbody.innerHTML = 'Правила расписания не созданы'; + rulesTbody.innerHTML = 'Правила расписания не созданы'; return; } rulesTbody.innerHTML = rules.map(rule => ` @@ -112,8 +141,7 @@ export async function initSchedule() { ${escapeHtml(rule.subjectName || '-')} ${escapeHtml(formatSemester(rule))} ${escapeHtml((rule.groupNames || []).join(', ') || '-')} - ${escapeHtml(rule.activeFromDate || '-')} - ${escapeHtml(String(rule.totalAcademicHours ?? '-'))} + ${renderRuleLoadSummary(rule)} ${renderRuleSlotsSummary(rule.slots)} @@ -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 'Часы не указаны'; + return rows.map(item => ( + `
${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}
` + )).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})`; diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index cc5bc8e..86a5efd 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -18,13 +18,44 @@ -
- - -
-
- - +
+ +
+ +
+
+
Лекции
+
+ + +
+
+ + +
+
+
+
Лабораторные
+
+ + +
+
+ + +
+
+
+
Практики
+
+ + +
+
+ + +
+
@@ -61,15 +92,14 @@ Дисциплина Семестр Группы - Начало - Часы + Нагрузка Слоты Действия - Загрузка... + Загрузка...