From cc422255a22d519b7c3c6dae48b2c99a4ec83dd1 Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 31 Jul 2026 21:24:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AcademicCalendarAdminController.java | 8 +- .../AcademicCalendarController.java | 34 ++-- .../DatabaseConstraintViolationMapper.java | 7 +- ...arDay.java => AcademicCalendarPeriod.java} | 39 ++-- .../AcademicCalendarDayRepository.java | 71 ------- .../AcademicCalendarPeriodRepository.java | 77 +++++++ .../service/AcademicCalendarGridService.java | 72 +++++-- .../app/service/AcademicDateService.java | 123 ++++++++--- .../app/service/AcademicStructureService.java | 12 +- .../main/resources/db/migration/V1__init.sql | 108 +++++----- .../AcademicCalendarControllerTest.java | 79 +++++++- ...emicStructureInvariantIntegrationTest.java | 37 +++- ...micCalendarGridServiceIntegrationTest.java | 59 +++--- .../AcademicCalendarGridServiceTest.java | 73 ++++--- .../service/AcademicStructureServiceTest.java | 8 +- .../ScheduleGenerationQueryCountTest.java | 46 +++-- compose.yaml | 2 + docs/ACADEMIC_CALENDAR.md | 8 +- docs/API.md | 14 +- docs/ARCHITECTURE.md | 13 +- docs/BUSINESS_LOGIC.md | 23 ++- docs/DATABASE.md | 33 +-- docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 6 +- docs/FRONTEND.md | 2 +- docs/SCHEDULE_PROPOSAL.md | 4 +- docs/SCHEDULE_TASKS.md | 4 +- graphify-out/.graphify_learning.json | 191 ++++++++++++------ ..._думаешь__нужно_ли_оптимизировать_таблицу_ac.md | 25 +++ ...сделаем__один_непрерывный_период_с_одинаково.md | 25 +++ ...½о__как_мне_подключиться_к_этой_бд_через_dbea.md | 23 +++ graphify-out/reflections/LESSONS.md | 24 ++- 31 files changed, 825 insertions(+), 425 deletions(-) rename backend/src/main/java/com/magistr/app/model/{AcademicCalendarDay.java => AcademicCalendarPeriod.java} (64%) delete mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AcademicCalendarPeriodRepository.java create mode 100644 graphify-out/memory/query_20260730_234930_как_ты_думаешь__нужно_ли_оптимизировать_таблицу_ac.md create mode 100644 graphify-out/memory/query_20260731_001522_давай_сделаем__один_непрерывный_период_с_одинаково.md create mode 100644 graphify-out/memory/query_20260731_002724_отлично__как_мне_подключиться_к_этой_бд_через_dbea.md diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java index b01d608..7766e84 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarAdminController.java @@ -21,20 +21,20 @@ public class AcademicCalendarAdminController { private final AcademicYearRepository academicYearRepository; private final SemesterRepository semesterRepository; private final AcademicCalendarActivityTypeRepository activityTypeRepository; - private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final ScheduleGeneratorService scheduleGeneratorService; private final AcademicPeriodService academicPeriodService; public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, SemesterRepository semesterRepository, AcademicCalendarActivityTypeRepository activityTypeRepository, - AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarPeriodRepository calendarPeriodRepository, ScheduleGeneratorService scheduleGeneratorService, AcademicPeriodService academicPeriodService) { this.academicYearRepository = academicYearRepository; this.semesterRepository = semesterRepository; this.activityTypeRepository = activityTypeRepository; - this.calendarDayRepository = calendarDayRepository; + this.calendarPeriodRepository = calendarPeriodRepository; this.scheduleGeneratorService = scheduleGeneratorService; this.academicPeriodService = academicPeriodService; } @@ -128,7 +128,7 @@ public class AcademicCalendarAdminController { if (!activityTypeRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - if (calendarDayRepository.existsByActivityTypeId(id)) { + if (calendarPeriodRepository.existsByActivityTypeId(id)) { return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках")); } activityTypeRepository.deleteById(id); diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index e16703a..56fa24c 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -12,6 +12,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -25,7 +27,7 @@ import java.util.stream.Collectors; public class AcademicCalendarController { private final AcademicCalendarRepository calendarRepository; - private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicYearRepository academicYearRepository; private final SpecialtiesRepository specialtiesRepository; private final SpecialtyProfileRepository profileRepository; @@ -37,7 +39,7 @@ public class AcademicCalendarController { private final AcademicStructureService academicStructureService; public AcademicCalendarController(AcademicCalendarRepository calendarRepository, - AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicYearRepository academicYearRepository, SpecialtiesRepository specialtiesRepository, SpecialtyProfileRepository profileRepository, @@ -48,7 +50,7 @@ public class AcademicCalendarController { ScheduleGeneratorService scheduleGeneratorService, AcademicStructureService academicStructureService) { this.calendarRepository = calendarRepository; - this.calendarDayRepository = calendarDayRepository; + this.calendarPeriodRepository = calendarPeriodRepository; this.academicYearRepository = academicYearRepository; this.specialtiesRepository = specialtiesRepository; this.profileRepository = profileRepository; @@ -108,8 +110,10 @@ public class AcademicCalendarController { if (!calendarRepository.existsById(id)) { return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream() - .map(this::toGridDayDto) + return ResponseEntity.ok(calendarPeriodRepository.findByCalendarIdWithActivity(id).stream() + .flatMap(period -> period.getStartDate() + .datesUntil(period.getEndDate().plusDays(1)) + .map(date -> toGridDayDto(period, date))) .toList()); } @@ -254,15 +258,19 @@ public class AcademicCalendarController { ); } - private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) { - AcademicCalendarActivityType activityType = day.getActivityType(); + private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarPeriod period, LocalDate date) { + AcademicCalendarActivityType activityType = period.getActivityType(); + AcademicYear academicYear = period.getAcademicCalendar().getAcademicYear(); + long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date); + int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1; + int weekNumber = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1); return new AcademicCalendarGridDayDto( - day.getId(), - day.getAcademicCalendar().getId(), - day.getCourseNumber(), - day.getDate(), - day.getWeekNumber(), - day.getDayOfWeek(), + period.getId(), + period.getAcademicCalendar().getId(), + period.getCourseNumber(), + date, + weekNumber, + date.getDayOfWeek().getValue(), activityType.getId(), activityType.getCode(), activityType.getName(), diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java index eb0c3ca..73f4338 100644 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseConstraintViolationMapper.java @@ -34,9 +34,8 @@ final class DatabaseConstraintViolationMapper { badRequest("chk_semesters_type", "Недопустимый тип семестра"), badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"), badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"), - badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"), - badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"), - badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"), + badRequest("chk_calendar_periods_course_positive", "Номер курса должен быть положительным"), + badRequest("chk_calendar_periods_dates", "Дата окончания периода раньше даты начала"), badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"), badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"), badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"), @@ -62,7 +61,7 @@ final class DatabaseConstraintViolationMapper { conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"), conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"), conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"), - conflict("uq_calendar_days", "День календарного графика уже существует"), + conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"), conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"), conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"), conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"), diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendarPeriod.java similarity index 64% rename from backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java rename to backend/src/main/java/com/magistr/app/model/AcademicCalendarPeriod.java index b256ccb..6a4a4dd 100644 --- a/backend/src/main/java/com/magistr/app/model/AcademicCalendarDay.java +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendarPeriod.java @@ -5,8 +5,8 @@ import jakarta.persistence.*; import java.time.LocalDate; @Entity -@Table(name = "academic_calendar_days") -public class AcademicCalendarDay { +@Table(name = "academic_calendar_periods") +public class AcademicCalendarPeriod { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -19,14 +19,11 @@ public class AcademicCalendarDay { @Column(name = "course_number", nullable = false) private Integer courseNumber; - @Column(nullable = false) - private LocalDate date; + @Column(name = "start_date", nullable = false) + private LocalDate startDate; - @Column(name = "week_number", nullable = false) - private Integer weekNumber; - - @Column(name = "day_of_week", nullable = false) - private Integer dayOfWeek; + @Column(name = "end_date", nullable = false) + private LocalDate endDate; @ManyToOne(optional = false) @JoinColumn(name = "activity_type_id", nullable = false) @@ -56,28 +53,20 @@ public class AcademicCalendarDay { this.courseNumber = courseNumber; } - public LocalDate getDate() { - return date; + public LocalDate getStartDate() { + return startDate; } - public void setDate(LocalDate date) { - this.date = date; + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; } - public Integer getWeekNumber() { - return weekNumber; + public LocalDate getEndDate() { + return endDate; } - public void setWeekNumber(Integer weekNumber) { - this.weekNumber = weekNumber; - } - - public Integer getDayOfWeek() { - return dayOfWeek; - } - - public void setDayOfWeek(Integer dayOfWeek) { - this.dayOfWeek = dayOfWeek; + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; } public AcademicCalendarActivityType getActivityType() { diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java deleted file mode 100644 index 03f062e..0000000 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.AcademicCalendarDay; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.time.LocalDate; -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface AcademicCalendarDayRepository extends JpaRepository { - - @Query(""" - select day - from AcademicCalendarDay day - join fetch day.activityType - where day.academicCalendar.id = :calendarId - order by day.courseNumber asc, day.date asc - """) - List findByCalendarIdWithActivity(@Param("calendarId") Long calendarId); - - @Query(""" - select day - from AcademicCalendarDay day - join fetch day.activityType - where day.academicCalendar.id = :calendarId - and day.courseNumber = :courseNumber - and day.date = :date - """) - Optional findByCalendarIdAndCourseNumberAndDate( - @Param("calendarId") Long calendarId, - @Param("courseNumber") Integer courseNumber, - @Param("date") LocalDate date - ); - - @Query(""" - select day - from AcademicCalendarDay day - join fetch day.academicCalendar - join fetch day.activityType - where day.academicCalendar.id in :calendarIds - and day.date between :startDate and :endDate - order by day.academicCalendar.id, day.courseNumber, day.date - """) - List findForScheduleBatch( - @Param("calendarIds") Collection calendarIds, - @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate - ); - - boolean existsByActivityTypeId(Long activityTypeId); - - boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber); - - @Query(""" - select case when count(day) > 0 then true else false end - from AcademicCalendarDay day - where day.academicCalendar.id = :calendarId - and (day.date < :startDate or day.date > :endDate) - """) - boolean existsOutsideDateRange(@Param("calendarId") Long calendarId, - @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate); - - @Modifying - @Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId") - void deleteByCalendarId(@Param("calendarId") Long calendarId); -} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarPeriodRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarPeriodRepository.java new file mode 100644 index 0000000..61129c0 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarPeriodRepository.java @@ -0,0 +1,77 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AcademicCalendarPeriod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface AcademicCalendarPeriodRepository extends JpaRepository { + + @Query(""" + select period + from AcademicCalendarPeriod period + join fetch period.academicCalendar calendar + join fetch calendar.academicYear + join fetch period.activityType + where period.academicCalendar.id = :calendarId + order by period.courseNumber asc, period.startDate asc + """) + List findByCalendarIdWithActivity(@Param("calendarId") Long calendarId); + + @Query(""" + select period + from AcademicCalendarPeriod period + join fetch period.activityType + where period.academicCalendar.id = :calendarId + and period.courseNumber = :courseNumber + and period.startDate <= :date + and period.endDate >= :date + """) + Optional findByCalendarIdAndCourseNumberAndDate( + @Param("calendarId") Long calendarId, + @Param("courseNumber") Integer courseNumber, + @Param("date") LocalDate date + ); + + @Query(""" + select period + from AcademicCalendarPeriod period + join fetch period.academicCalendar + join fetch period.activityType + where period.academicCalendar.id in :calendarIds + and period.courseNumber = :courseNumber + and period.endDate >= :startDate + and period.startDate <= :endDate + order by period.academicCalendar.id, period.startDate + """) + List findForScheduleBatch( + @Param("calendarIds") Collection calendarIds, + @Param("courseNumber") Integer courseNumber, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + boolean existsByActivityTypeId(Long activityTypeId); + + boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber); + + @Query(""" + select case when count(period) > 0 then true else false end + from AcademicCalendarPeriod period + where period.academicCalendar.id = :calendarId + and (period.startDate < :startDate or period.endDate > :endDate) + """) + boolean existsOutsideDateRange(@Param("calendarId") Long calendarId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Modifying + @Query("delete from AcademicCalendarPeriod period where period.academicCalendar.id = :calendarId") + void deleteByCalendarId(@Param("calendarId") Long calendarId); +} diff --git a/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java index 17a9103..1d70025 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicCalendarGridService.java @@ -3,10 +3,10 @@ package com.magistr.app.service; import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendarActivityType; -import com.magistr.app.model.AcademicCalendarDay; +import com.magistr.app.model.AcademicCalendarPeriod; import com.magistr.app.model.AcademicYear; import com.magistr.app.repository.AcademicCalendarActivityTypeRepository; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,25 +16,27 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Set; @Service public class AcademicCalendarGridService { private final AcademicCalendarRepository calendarRepository; - private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicCalendarActivityTypeRepository activityTypeRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository, - AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarActivityTypeRepository activityTypeRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; - this.calendarDayRepository = calendarDayRepository; + this.calendarPeriodRepository = calendarPeriodRepository; this.activityTypeRepository = activityTypeRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -47,18 +49,18 @@ public class AcademicCalendarGridService { throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика"); } - List replacement = buildReplacement(calendar, rows); + List replacement = buildReplacement(calendar, rows); - calendarDayRepository.deleteByCalendarId(id); - calendarDayRepository.flush(); - calendarDayRepository.saveAll(replacement); - calendarDayRepository.flush(); + calendarPeriodRepository.deleteByCalendarId(id); + calendarPeriodRepository.flush(); + calendarPeriodRepository.saveAll(replacement); + calendarPeriodRepository.flush(); clearScheduleCacheAfterCommit(); } - private List buildReplacement(AcademicCalendar calendar, - List rows) { - List replacement = new ArrayList<>(rows.size()); + private List buildReplacement(AcademicCalendar calendar, + List rows) { + List validatedDays = new ArrayList<>(rows.size()); Set uniqueRows = new HashSet<>(); for (AcademicCalendarGridDayDto row : rows) { @@ -75,19 +77,42 @@ public class AcademicCalendarGridService { if (!uniqueRows.add(businessKey)) { throw new IllegalArgumentException("Для курса и даты можно указать только одну активность"); } + validatedDays.add(new ValidatedGridDay( + row.courseNumber(), + row.date(), + resolveActivityType(row) + )); + } - AcademicCalendarDay day = new AcademicCalendarDay(); - day.setAcademicCalendar(calendar); - day.setCourseNumber(row.courseNumber()); - day.setDate(row.date()); - day.setWeekNumber(row.weekNumber()); - day.setDayOfWeek(row.dayOfWeek()); - day.setActivityType(resolveActivityType(row)); - replacement.add(day); + validatedDays.sort(Comparator + .comparing(ValidatedGridDay::courseNumber) + .thenComparing(ValidatedGridDay::date)); + + List replacement = new ArrayList<>(); + AcademicCalendarPeriod currentPeriod = null; + for (ValidatedGridDay day : validatedDays) { + if (canExtend(currentPeriod, day)) { + currentPeriod.setEndDate(day.date()); + continue; + } + currentPeriod = new AcademicCalendarPeriod(); + currentPeriod.setAcademicCalendar(calendar); + currentPeriod.setCourseNumber(day.courseNumber()); + currentPeriod.setStartDate(day.date()); + currentPeriod.setEndDate(day.date()); + currentPeriod.setActivityType(day.activityType()); + replacement.add(currentPeriod); } return replacement; } + private boolean canExtend(AcademicCalendarPeriod period, ValidatedGridDay day) { + return period != null + && period.getCourseNumber().equals(day.courseNumber()) + && period.getEndDate().plusDays(1).equals(day.date()) + && Objects.equals(period.getActivityType().getId(), day.activityType().getId()); + } + private void validateRequiredFields(AcademicCalendarGridDayDto row) { if (row.courseNumber() == null || row.date() == null @@ -155,4 +180,9 @@ public class AcademicCalendarGridService { private record GridBusinessKey(Integer courseNumber, LocalDate date) { } + + private record ValidatedGridDay(Integer courseNumber, + LocalDate date, + AcademicCalendarActivityType activityType) { + } } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java index 0d9a0ad..ad601a9 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicDateService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicDateService.java @@ -1,7 +1,7 @@ package com.magistr.app.service; import com.magistr.app.model.*; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.SemesterRepository; import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import org.springframework.beans.factory.annotation.Value; @@ -17,16 +17,16 @@ import java.util.stream.Collectors; public class AcademicDateService { private final SemesterRepository semesterRepository; - private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final boolean evenWeekIsUpper; public AcademicDateService(SemesterRepository semesterRepository, - AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarPeriodRepository calendarPeriodRepository, StudentGroupCalendarAssignmentRepository assignmentRepository, @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { this.semesterRepository = semesterRepository; - this.calendarDayRepository = calendarDayRepository; + this.calendarPeriodRepository = calendarPeriodRepository; this.assignmentRepository = assignmentRepository; this.evenWeekIsUpper = evenWeekIsUpper; } @@ -77,20 +77,54 @@ public class AcademicDateService { List assignments = groupIds.isEmpty() || academicYearIds.isEmpty() ? List.of() : assignmentRepository.findForScheduleBatch(groupIds, academicYearIds); - Set calendarIds = assignments.stream() - .map(StudentGroupCalendarAssignment::getAcademicCalendar) - .filter(Objects::nonNull) - .map(AcademicCalendar::getId) - .filter(Objects::nonNull) - .collect(Collectors.toCollection(LinkedHashSet::new)); - List calendarDays = calendarIds.isEmpty() - || snapshotStart == null - || endDate == null - || endDate.isBefore(snapshotStart) - ? List.of() - : calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate); + List calendarPeriods = loadCalendarPeriods( + assignments, + snapshotStart, + endDate + ); - return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate); + return new ScheduleSnapshot(semesterSnapshot, assignments, calendarPeriods, snapshotStart, endDate); + } + + private List loadCalendarPeriods( + Collection assignments, + LocalDate startDate, + LocalDate endDate) { + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + return List.of(); + } + + Map> calendarIdsByCourse = new TreeMap<>(); + for (StudentGroupCalendarAssignment assignment : assignments) { + if (assignment == null + || assignment.getStudentGroup() == null + || assignment.getAcademicYear() == null + || assignment.getAcademicYear().getStartDate() == null + || assignment.getAcademicCalendar() == null + || assignment.getAcademicCalendar().getId() == null) { + continue; + } + int courseNumber = getCourseNumber( + assignment.getStudentGroup(), + assignment.getAcademicYear().getStartDate() + ); + if (courseNumber < 1) { + continue; + } + calendarIdsByCourse.computeIfAbsent(courseNumber, ignored -> new LinkedHashSet<>()) + .add(assignment.getAcademicCalendar().getId()); + } + + List result = new ArrayList<>(); + calendarIdsByCourse.forEach((courseNumber, calendarIds) -> + result.addAll(calendarPeriodRepository.findForScheduleBatch( + calendarIds, + courseNumber, + startDate, + endDate + )) + ); + return result; } public int getWeekNumber(Semester semester, LocalDate date) { @@ -126,12 +160,12 @@ public class AcademicDateService { } return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()) - .flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate( + .flatMap(assignment -> calendarPeriodRepository.findByCalendarIdAndCourseNumberAndDate( assignment.getAcademicCalendar().getId(), courseNumber, date )) - .map(AcademicCalendarDay::getActivityType); + .map(AcademicCalendarPeriod::getActivityType); } public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { @@ -151,13 +185,13 @@ public class AcademicDateService { private final Map semesterByDate; private final Map assignmentByGroupAndYear; - private final Map activityByCalendarCourseAndDate; + private final Map> periodsByCalendarAndCourse; private final LocalDate startDate; private final LocalDate endDate; private ScheduleSnapshot(Collection semesters, Collection assignments, - Collection calendarDays, + Collection calendarPeriods, LocalDate startDate, LocalDate endDate) { this.startDate = startDate; @@ -171,14 +205,18 @@ public class AcademicDateService { ), Function.identity() )); - this.activityByCalendarCourseAndDate = calendarDays.stream() - .collect(Collectors.toUnmodifiableMap( - day -> new CalendarDayKey( - day.getAcademicCalendar().getId(), - day.getCourseNumber(), - day.getDate() + this.periodsByCalendarAndCourse = calendarPeriods.stream() + .collect(Collectors.groupingBy( + period -> new CalendarCourseKey( + period.getAcademicCalendar().getId(), + period.getCourseNumber() ), - AcademicCalendarDay::getActivityType + Collectors.collectingAndThen( + Collectors.toList(), + periods -> periods.stream() + .sorted(Comparator.comparing(AcademicCalendarPeriod::getStartDate)) + .toList() + ) )); } @@ -200,9 +238,30 @@ public class AcademicDateService { return findAssignment(group, semester) .map(StudentGroupCalendarAssignment::getAcademicCalendar) .map(AcademicCalendar::getId) - .map(calendarId -> activityByCalendarCourseAndDate.get( - new CalendarDayKey(calendarId, courseNumber, date) - )); + .flatMap(calendarId -> findActivityType(calendarId, courseNumber, date)); + } + + private Optional findActivityType(Long calendarId, + int courseNumber, + LocalDate date) { + List periods = periodsByCalendarAndCourse.getOrDefault( + new CalendarCourseKey(calendarId, courseNumber), + List.of() + ); + int left = 0; + int right = periods.size() - 1; + while (left <= right) { + int middle = (left + right) >>> 1; + AcademicCalendarPeriod period = periods.get(middle); + if (date.isBefore(period.getStartDate())) { + right = middle - 1; + } else if (date.isAfter(period.getEndDate())) { + left = middle + 1; + } else { + return Optional.of(period.getActivityType()); + } + } + return Optional.empty(); } public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { @@ -268,6 +327,6 @@ public class AcademicDateService { private record GroupAcademicYearKey(Long groupId, Long academicYearId) { } - private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) { + private record CalendarCourseKey(Long calendarId, Integer courseNumber) { } } diff --git a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java index edd81de..eafd172 100644 --- a/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java +++ b/backend/src/main/java/com/magistr/app/service/AcademicStructureService.java @@ -10,7 +10,7 @@ import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroupCalendarAssignment; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicYearRepository; @@ -35,7 +35,7 @@ import java.util.NoSuchElementException; public class AcademicStructureService { private final AcademicCalendarRepository calendarRepository; - private final AcademicCalendarDayRepository calendarDayRepository; + private final AcademicCalendarPeriodRepository calendarPeriodRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicYearRepository academicYearRepository; private final SpecialtiesRepository specialtiesRepository; @@ -47,7 +47,7 @@ public class AcademicStructureService { private final ScheduleGeneratorService scheduleGeneratorService; public AcademicStructureService(AcademicCalendarRepository calendarRepository, - AcademicCalendarDayRepository calendarDayRepository, + AcademicCalendarPeriodRepository calendarPeriodRepository, AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicYearRepository academicYearRepository, SpecialtiesRepository specialtiesRepository, @@ -58,7 +58,7 @@ public class AcademicStructureService { SubgroupRepository subgroupRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; - this.calendarDayRepository = calendarDayRepository; + this.calendarPeriodRepository = calendarPeriodRepository; this.calendarSubjectRepository = calendarSubjectRepository; this.academicYearRepository = academicYearRepository; this.specialtiesRepository = specialtiesRepository; @@ -106,7 +106,7 @@ public class AcademicStructureService { "Нельзя изменить график: сохранённые назначения групп станут несовместимыми" ); } - if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( + if (calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( id, request.courseCount())) { throw new ScheduleConflictException( "Нельзя уменьшить количество курсов: в сетке есть данные старших курсов" @@ -118,7 +118,7 @@ public class AcademicStructureService { "Нельзя уменьшить количество курсов: есть дисциплины старших семестров" ); } - if (calendarDayRepository.existsOutsideDateRange( + if (calendarPeriodRepository.existsOutsideDateRange( id, candidate.academicYear().getStartDate(), candidate.academicYear().getEndDate())) { diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 50868e5..15f9194 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -2,6 +2,7 @@ -- Инициализация расширений -- ========================================== CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS btree_gist; -- =============================== -- Справочники высшего уровня @@ -714,22 +715,25 @@ JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profi WHERE ay.title = '2025-2026' ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; -CREATE TABLE IF NOT EXISTS academic_calendar_days ( +CREATE TABLE IF NOT EXISTS academic_calendar_periods ( id BIGSERIAL PRIMARY KEY, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, course_number INT NOT NULL, - date DATE NOT NULL, - week_number INT NOT NULL, - day_of_week INT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id), - CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0), - CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0), - CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7), - CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date) + CONSTRAINT chk_calendar_periods_course_positive CHECK (course_number > 0), + CONSTRAINT chk_calendar_periods_dates CHECK (end_date >= start_date), + CONSTRAINT ex_calendar_periods_no_overlap + EXCLUDE USING gist ( + calendar_id WITH =, + course_number WITH =, + (daterange(start_date, end_date, '[]')) WITH && + ) ); -CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup - ON academic_calendar_days(calendar_id, course_number, date); +CREATE INDEX IF NOT EXISTS idx_calendar_periods_schedule + ON academic_calendar_periods(calendar_id, course_number, start_date, end_date); CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number( academic_year_start DATE, @@ -750,20 +754,23 @@ AS $$ ) + 1 $$; -INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id) +INSERT INTO academic_calendar_periods ( + calendar_id, + course_number, + start_date, + end_date, + activity_type_id +) SELECT calendar.id, course.course_number, - days.date::DATE, - calculate_academic_calendar_week_number(ay.start_date, days.date::DATE), - EXTRACT(ISODOW FROM days.date::DATE)::INT, + ay.start_date, + ay.end_date, activity.id FROM academic_calendars calendar JOIN academic_years ay ON ay.id = calendar.academic_year_id JOIN academic_calendar_activity_types activity ON activity.code = 'Т' -CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number) -CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date) -ON CONFLICT (calendar_id, course_number, date) DO NOTHING; +CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number); CREATE TABLE IF NOT EXISTS academic_calendar_subjects ( id BIGSERIAL PRIMARY KEY, @@ -1134,7 +1141,7 @@ COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; -COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; +COMMENT ON TABLE academic_calendar_periods IS 'Непересекающиеся периоды активности календарного учебного графика'; COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров'; COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания'; @@ -1275,12 +1282,11 @@ COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms'; COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; -COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; -COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса'; -COMMENT ON COLUMN academic_calendar_days.date IS 'Дата'; -COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году'; -COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; -COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности'; +COMMENT ON COLUMN academic_calendar_periods.calendar_id IS 'ID календарного учебного графика'; +COMMENT ON COLUMN academic_calendar_periods.course_number IS 'Номер курса'; +COMMENT ON COLUMN academic_calendar_periods.start_date IS 'Первая дата периода включительно'; +COMMENT ON COLUMN academic_calendar_periods.end_date IS 'Последняя дата периода включительно'; +COMMENT ON COLUMN academic_calendar_periods.activity_type_id IS 'ID кода активности'; COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE) IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье'; @@ -1579,8 +1585,6 @@ BEGIN END IF; END $$; -CREATE EXTENSION IF NOT EXISTS btree_gist; - ALTER TABLE time_slots ADD CONSTRAINT chk_time_slots_duration_matches_range CHECK ( @@ -1818,7 +1822,7 @@ CREATE TRIGGER trg_academic_years_include_semesters DO $$ DECLARE assignment_violations BIGINT; - calendar_day_violations BIGINT; + calendar_period_violations BIGINT; calendar_subject_violations BIGINT; BEGIN SELECT count(*) @@ -1840,13 +1844,13 @@ BEGIN END NOT BETWEEN 1 AND calendar.course_count; SELECT count(*) - INTO calendar_day_violations - FROM academic_calendar_days calendar_day - JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id + INTO calendar_period_violations + FROM academic_calendar_periods calendar_period + JOIN academic_calendars calendar ON calendar.id = calendar_period.calendar_id JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id - WHERE calendar_day.course_number > calendar.course_count - OR calendar_day.date < academic_year.start_date - OR calendar_day.date > academic_year.end_date; + WHERE calendar_period.course_number > calendar.course_count + OR calendar_period.start_date < academic_year.start_date + OR calendar_period.end_date > academic_year.end_date; SELECT count(*) INTO calendar_subject_violations @@ -1855,14 +1859,14 @@ BEGIN WHERE calendar_subject.semester_number > calendar.course_count * 2; IF assignment_violations > 0 - OR calendar_day_violations > 0 + OR calendar_period_violations > 0 OR calendar_subject_violations > 0 THEN RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = format( - 'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', + 'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', assignment_violations, - calendar_day_violations, + calendar_period_violations, calendar_subject_violations ); END IF; @@ -1990,12 +1994,12 @@ BEGIN IF EXISTS ( SELECT 1 - FROM academic_calendar_days calendar_day - WHERE calendar_day.calendar_id = OLD.id + FROM academic_calendar_periods calendar_period + WHERE calendar_period.calendar_id = OLD.id AND ( - calendar_day.course_number > NEW.course_count - OR calendar_day.date < academic_year_start - OR calendar_day.date > academic_year_end + calendar_period.course_number > NEW.course_count + OR calendar_period.start_date < academic_year_start + OR calendar_period.end_date > academic_year_end ) ) THEN RAISE EXCEPTION USING @@ -2059,7 +2063,7 @@ CREATE TRIGGER trg_student_groups_protect_calendar_assignments FOR EACH ROW EXECUTE FUNCTION prevent_incompatible_student_group_change(); -CREATE OR REPLACE FUNCTION enforce_calendar_day_dimensions() +CREATE OR REPLACE FUNCTION enforce_calendar_period_dimensions() RETURNS TRIGGER LANGUAGE plpgsql AS $$ @@ -2078,22 +2082,22 @@ BEGIN IF calendar_course_count IS NOT NULL AND ( NEW.course_number > calendar_course_count - OR NEW.date < academic_year_start - OR NEW.date > academic_year_end + OR NEW.start_date < academic_year_start + OR NEW.end_date > academic_year_end ) THEN RAISE EXCEPTION USING ERRCODE = '23514', - MESSAGE = 'Строка сетки выходит за измерения календарного графика'; + MESSAGE = 'Период активности выходит за измерения календарного графика'; END IF; RETURN NEW; END $$; -CREATE TRIGGER trg_calendar_days_dimensions - BEFORE INSERT OR UPDATE OF calendar_id, course_number, date - ON academic_calendar_days +CREATE TRIGGER trg_calendar_periods_dimensions + BEFORE INSERT OR UPDATE OF calendar_id, course_number, start_date, end_date + ON academic_calendar_periods FOR EACH ROW - EXECUTE FUNCTION enforce_calendar_day_dimensions(); + EXECUTE FUNCTION enforce_calendar_period_dimensions(); CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions() RETURNS TRIGGER @@ -2132,11 +2136,11 @@ BEGIN IF EXISTS ( SELECT 1 FROM academic_calendars calendar - JOIN academic_calendar_days calendar_day ON calendar_day.calendar_id = calendar.id + JOIN academic_calendar_periods calendar_period ON calendar_period.calendar_id = calendar.id WHERE calendar.academic_year_id = OLD.id AND ( - calendar_day.date < NEW.start_date - OR calendar_day.date > NEW.end_date + calendar_period.start_date < NEW.start_date + OR calendar_period.end_date > NEW.end_date ) ) OR EXISTS ( SELECT 1 diff --git a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java index bc95735..26ce688 100644 --- a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java @@ -1,12 +1,15 @@ package com.magistr.app.controller; import com.magistr.app.dto.AcademicCalendarDto; +import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.model.AcademicCalendar; +import com.magistr.app.model.AcademicCalendarActivityType; +import com.magistr.app.model.AcademicCalendarPeriod; import com.magistr.app.model.AcademicYear; import com.magistr.app.model.EducationForm; import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicYearRepository; @@ -20,6 +23,7 @@ import com.magistr.app.service.ScheduleGeneratorService; import org.junit.jupiter.api.Test; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -60,7 +64,7 @@ class AcademicCalendarControllerTest { AcademicCalendarController controller = new AcademicCalendarController( calendarRepository, - mock(AcademicCalendarDayRepository.class), + mock(AcademicCalendarPeriodRepository.class), academicYearRepository, specialtiesRepository, profileRepository, @@ -95,4 +99,75 @@ class AcademicCalendarControllerTest { assertThat(((AcademicCalendarDto) response.getBody()).title()) .isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027"); } + + @Test + void getGridExpandsPeriodIntoDailyRows() { + AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class); + AcademicCalendarPeriodRepository periodRepository = mock(AcademicCalendarPeriodRepository.class); + + AcademicYear year = new AcademicYear(); + year.setId(1L); + year.setStartDate(LocalDate.of(2026, 9, 1)); + year.setEndDate(LocalDate.of(2027, 6, 30)); + + AcademicCalendar calendar = new AcademicCalendar(); + calendar.setId(10L); + calendar.setAcademicYear(year); + + AcademicCalendarActivityType activityType = new AcademicCalendarActivityType(); + activityType.setId(20L); + activityType.setCode("Т"); + activityType.setName("Теоретическое обучение"); + activityType.setAllowSchedule(true); + + AcademicCalendarPeriod period = new AcademicCalendarPeriod(); + period.setId(30L); + period.setAcademicCalendar(calendar); + period.setCourseNumber(1); + period.setStartDate(LocalDate.of(2026, 9, 6)); + period.setEndDate(LocalDate.of(2026, 9, 8)); + period.setActivityType(activityType); + + when(calendarRepository.existsById(10L)).thenReturn(true); + when(periodRepository.findByCalendarIdWithActivity(10L)).thenReturn(List.of(period)); + + AcademicCalendarController controller = new AcademicCalendarController( + calendarRepository, + periodRepository, + mock(AcademicYearRepository.class), + mock(SpecialtiesRepository.class), + mock(SpecialtyProfileRepository.class), + mock(EducationFormRepository.class), + mock(SubjectRepository.class), + mock(AcademicCalendarSubjectRepository.class), + mock(AcademicCalendarGridService.class), + mock(ScheduleGeneratorService.class), + mock(AcademicStructureService.class) + ); + + var response = controller.getGrid(10L); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List rows = (List) response.getBody(); + assertThat(rows) + .extracting(AcademicCalendarGridDayDto::date) + .containsExactly( + LocalDate.of(2026, 9, 6), + LocalDate.of(2026, 9, 7), + LocalDate.of(2026, 9, 8) + ); + assertThat(rows) + .extracting(AcademicCalendarGridDayDto::weekNumber) + .containsExactly(1, 2, 2); + assertThat(rows) + .extracting(AcademicCalendarGridDayDto::dayOfWeek) + .containsExactly(7, 1, 2); + assertThat(rows) + .allSatisfy(row -> { + assertThat(row.id()).isEqualTo(30L); + assertThat(row.activityTypeId()).isEqualTo(20L); + }); + } } diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java index 4edeefa..3627b12 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java @@ -92,9 +92,14 @@ class AcademicStructureInvariantIntegrationTest { "сохранённая сетка выходит за новые измерения" ); assertSqlState( - () -> insertOutOfRangeCalendarDay(connection, calendarId), + () -> insertOutOfRangeCalendarPeriod(connection, calendarId), "23514", - "Строка сетки выходит за измерения календарного графика" + "Период активности выходит за измерения календарного графика" + ); + assertSqlState( + () -> insertOverlappingCalendarPeriod(connection, calendarId), + "23P01", + "ex_calendar_periods_no_overlap" ); assertSqlState( () -> insertOutOfRangeCalendarSubject(connection, calendarId), @@ -479,16 +484,15 @@ class AcademicStructureInvariantIntegrationTest { } } - private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException { + private void insertOutOfRangeCalendarPeriod(Connection connection, long calendarId) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(""" - INSERT INTO academic_calendar_days ( + INSERT INTO academic_calendar_periods ( calendar_id, course_number, - date, - week_number, - day_of_week, + start_date, + end_date, activity_type_id - ) VALUES (?, 5, DATE '2025-09-01', 1, 1, ( + ) VALUES (?, 5, DATE '2025-09-01', DATE '2025-09-01', ( SELECT id FROM academic_calendar_activity_types WHERE code = 'Т' )) """)) { @@ -497,6 +501,23 @@ class AcademicStructureInvariantIntegrationTest { } } + private void insertOverlappingCalendarPeriod(Connection connection, long calendarId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO academic_calendar_periods ( + calendar_id, + course_number, + start_date, + end_date, + activity_type_id + ) VALUES (?, 1, DATE '2025-09-15', DATE '2025-09-30', ( + SELECT id FROM academic_calendar_activity_types WHERE code = 'К' + )) + """)) { + statement.setLong(1, calendarId); + statement.executeUpdate(); + } + } + private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(""" INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id) diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java index d87b827..d1264f4 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java @@ -1,8 +1,8 @@ package com.magistr.app.service; import com.magistr.app.dto.AcademicCalendarGridDayDto; -import com.magistr.app.model.AcademicCalendarDay; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.model.AcademicCalendarPeriod; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import org.junit.jupiter.api.Test; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -110,12 +110,13 @@ class AcademicCalendarGridServiceIntegrationTest { assertThat(fingerprint(calendarId)).isEqualTo(before); assertThat(queryLong(""" SELECT count(*) - FROM academic_calendar_days day - WHERE day.calendar_id = ? - AND day.course_number = 1 - AND day.date = ? - AND day.activity_type_id = ? - """, calendarId, yearStart, holidayActivityId)).isZero(); + FROM academic_calendar_periods period + WHERE period.calendar_id = ? + AND period.course_number = 1 + AND period.start_date <= ? + AND period.end_date >= ? + AND period.activity_type_id = ? + """, calendarId, yearStart, yearStart, holidayActivityId)).isZero(); verify(scheduleGeneratorService, never()).clearCache(); AcademicCalendarGridDayDto secondValid = row( @@ -131,15 +132,26 @@ class AcademicCalendarGridServiceIntegrationTest { assertThat(after).isNotEqualTo(before); assertThat(queryLong(""" SELECT count(*) - FROM academic_calendar_days day - WHERE day.calendar_id = ? + FROM academic_calendar_periods period + WHERE period.calendar_id = ? AND ( - (day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?) + ( + period.course_number = 1 + AND period.start_date = ? + AND period.end_date = ? + AND period.activity_type_id = ? + ) OR - (day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?) + ( + period.course_number = 1 + AND period.start_date = ? + AND period.end_date = ? + AND period.activity_type_id = ? + ) ) - """, calendarId, yearStart, holidayActivityId, - yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L); + """, calendarId, + yearStart, yearStart, holidayActivityId, + yearStart.plusDays(1), yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L); verify(scheduleGeneratorService, times(1)).clearCache(); } @@ -149,16 +161,15 @@ class AcademicCalendarGridServiceIntegrationTest { count(*) AS row_count, md5(string_agg( concat_ws('|', - day.course_number::text, - day.date::text, - day.week_number::text, - day.day_of_week::text, - day.activity_type_id::text + period.course_number::text, + period.start_date::text, + period.end_date::text, + period.activity_type_id::text ), - E'\\n' ORDER BY day.course_number, day.date, day.id + E'\\n' ORDER BY period.course_number, period.start_date, period.id )) AS content_hash - FROM academic_calendar_days day - WHERE day.calendar_id = ? + FROM academic_calendar_periods period + WHERE period.calendar_id = ? """, (result, rowNumber) -> new GridFingerprint( result.getLong("row_count"), result.getString("content_hash") @@ -196,8 +207,8 @@ class AcademicCalendarGridServiceIntegrationTest { @SpringBootConfiguration @EnableAutoConfiguration - @EntityScan(basePackageClasses = AcademicCalendarDay.class) - @EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class) + @EntityScan(basePackageClasses = AcademicCalendarPeriod.class) + @EnableJpaRepositories(basePackageClasses = AcademicCalendarPeriodRepository.class) @Import({AcademicCalendarGridService.class, TestDependencies.class}) static class TestApplication { } diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java index 3bab8fe..2086b0c 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceTest.java @@ -3,10 +3,10 @@ package com.magistr.app.service; import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendarActivityType; -import com.magistr.app.model.AcademicCalendarDay; +import com.magistr.app.model.AcademicCalendarPeriod; import com.magistr.app.model.AcademicYear; import com.magistr.app.repository.AcademicCalendarActivityTypeRepository; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -41,7 +41,7 @@ class AcademicCalendarGridServiceTest { @Mock private AcademicCalendarRepository calendarRepository; @Mock - private AcademicCalendarDayRepository calendarDayRepository; + private AcademicCalendarPeriodRepository calendarPeriodRepository; @Mock private AcademicCalendarActivityTypeRepository activityTypeRepository; @Mock @@ -73,7 +73,7 @@ class AcademicCalendarGridServiceTest { service = new AcademicCalendarGridService( calendarRepository, - calendarDayRepository, + calendarPeriodRepository, activityTypeRepository, scheduleGeneratorService ); @@ -180,17 +180,18 @@ class AcademicCalendarGridServiceTest { service.replaceGrid(CALENDAR_ID, List.of(first, second)); @SuppressWarnings("unchecked") - ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); - InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService); - mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID); - mutationOrder.verify(calendarDayRepository).flush(); - mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture()); - mutationOrder.verify(calendarDayRepository).flush(); + ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); + InOrder mutationOrder = inOrder(calendarPeriodRepository, scheduleGeneratorService); + mutationOrder.verify(calendarPeriodRepository).deleteByCalendarId(CALENDAR_ID); + mutationOrder.verify(calendarPeriodRepository).flush(); + mutationOrder.verify(calendarPeriodRepository).saveAll(replacementCaptor.capture()); + mutationOrder.verify(calendarPeriodRepository).flush(); mutationOrder.verify(scheduleGeneratorService).clearCache(); - assertThat(replacementCaptor.getValue()) - .extracting(AcademicCalendarDay::getDate) - .containsExactly(YEAR_START, YEAR_START.plusDays(1)); + assertThat(replacementCaptor.getValue()).singleElement().satisfies(period -> { + assertThat(period.getStartDate()).isEqualTo(YEAR_START); + assertThat(period.getEndDate()).isEqualTo(YEAR_START.plusDays(1)); + }); assertThat(replacementCaptor.getValue()) .allSatisfy(day -> { assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID); @@ -212,11 +213,22 @@ class AcademicCalendarGridServiceTest { )); @SuppressWarnings("unchecked") - ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); - verify(calendarDayRepository).saveAll(replacementCaptor.capture()); + ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); + verify(calendarPeriodRepository).saveAll(replacementCaptor.capture()); assertThat(replacementCaptor.getValue()) - .extracting(AcademicCalendarDay::getWeekNumber) - .containsExactly(1, 1, 2, 3); + .extracting(AcademicCalendarPeriod::getStartDate) + .containsExactly( + yearStart, + LocalDate.of(2026, 9, 6), + LocalDate.of(2026, 9, 14) + ); + assertThat(replacementCaptor.getValue()) + .extracting(AcademicCalendarPeriod::getEndDate) + .containsExactly( + yearStart, + LocalDate.of(2026, 9, 7), + LocalDate.of(2026, 9, 14) + ); } @Test @@ -233,11 +245,22 @@ class AcademicCalendarGridServiceTest { )); @SuppressWarnings("unchecked") - ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); - verify(calendarDayRepository).saveAll(replacementCaptor.capture()); + ArgumentCaptor> replacementCaptor = ArgumentCaptor.forClass(List.class); + verify(calendarPeriodRepository).saveAll(replacementCaptor.capture()); assertThat(replacementCaptor.getValue()) - .extracting(AcademicCalendarDay::getWeekNumber) - .containsExactly(1, 1, 2, 3); + .extracting(AcademicCalendarPeriod::getStartDate) + .containsExactly( + yearStart, + LocalDate.of(2027, 9, 5), + LocalDate.of(2027, 9, 13) + ); + assertThat(replacementCaptor.getValue()) + .extracting(AcademicCalendarPeriod::getEndDate) + .containsExactly( + yearStart, + LocalDate.of(2027, 9, 6), + LocalDate.of(2027, 9, 13) + ); } private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) { @@ -263,10 +286,10 @@ class AcademicCalendarGridServiceTest { } private void verifyNoMutation() { - verify(calendarDayRepository, never()).deleteByCalendarId(any()); - verify(calendarDayRepository, never()).flush(); - verify(calendarDayRepository, never()).save(any()); - verify(calendarDayRepository, never()).saveAll(any()); + verify(calendarPeriodRepository, never()).deleteByCalendarId(any()); + verify(calendarPeriodRepository, never()).flush(); + verify(calendarPeriodRepository, never()).save(any()); + verify(calendarPeriodRepository, never()).saveAll(any()); verify(scheduleGeneratorService, never()).clearCache(); } } diff --git a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java index 31c5596..37b536f 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicStructureServiceTest.java @@ -10,7 +10,7 @@ import com.magistr.app.model.Speciality; import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroupCalendarAssignment; -import com.magistr.app.repository.AcademicCalendarDayRepository; +import com.magistr.app.repository.AcademicCalendarPeriodRepository; import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicYearRepository; @@ -54,7 +54,7 @@ class AcademicStructureServiceTest { @Mock private AcademicCalendarRepository calendarRepository; @Mock - private AcademicCalendarDayRepository calendarDayRepository; + private AcademicCalendarPeriodRepository calendarPeriodRepository; @Mock private AcademicCalendarSubjectRepository calendarSubjectRepository; @Mock @@ -87,7 +87,7 @@ class AcademicStructureServiceTest { void setUp() { service = new AcademicStructureService( calendarRepository, - calendarDayRepository, + calendarPeriodRepository, calendarSubjectRepository, academicYearRepository, specialtiesRepository, @@ -142,7 +142,7 @@ class AcademicStructureServiceTest { @Test void rejectsCourseCountBelowExistingGrid() { - when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( + when(calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( CALENDAR_ID, 3)).thenReturn(true); assertThatThrownBy(() -> service.updateCalendar( diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java index 41dd467..29fd8eb 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleGenerationQueryCountTest.java @@ -32,7 +32,7 @@ class ScheduleGenerationQueryCountTest { SemesterRepository semesterRepository = mock(SemesterRepository.class); StudentGroupCalendarAssignmentRepository assignmentRepository = mock(StudentGroupCalendarAssignmentRepository.class); - AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class); + AcademicCalendarPeriodRepository calendarPeriodRepository = mock(AcademicCalendarPeriodRepository.class); ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class); TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class); TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class); @@ -47,9 +47,9 @@ class ScheduleGenerationQueryCountTest { List assignments = groups.stream() .map(group -> assignment(group, academicYear, calendar)) .toList(); - List calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1)) - .map(date -> calendarDay(calendar, date)) - .toList(); + List calendarPeriods = List.of( + calendarPeriod(calendar, START_DATE, END_DATE) + ); List rules = IntStream.rangeClosed(1, RULE_COUNT) .mapToObj(index -> rule(index, semester, groups)) .toList(); @@ -63,8 +63,12 @@ class ScheduleGenerationQueryCountTest { .thenReturn(List.of(semester)); when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection())) .thenReturn(assignments); - when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE))) - .thenReturn(calendarDays); + when(calendarPeriodRepository.findForScheduleBatch( + anyCollection(), + any(), + eq(START_DATE), + eq(END_DATE) + )).thenReturn(calendarPeriods); when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection())) .thenReturn(rules); when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of()); @@ -74,7 +78,7 @@ class ScheduleGenerationQueryCountTest { AcademicDateService academicDateService = new AcademicDateService( semesterRepository, - calendarDayRepository, + calendarPeriodRepository, assignmentRepository, false ); @@ -103,7 +107,12 @@ class ScheduleGenerationQueryCountTest { verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE); verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection()); - verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)); + verify(calendarPeriodRepository).findForScheduleBatch( + anyCollection(), + any(), + eq(START_DATE), + eq(END_DATE) + ); verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()); verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE); verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY"); @@ -112,7 +121,7 @@ class ScheduleGenerationQueryCountTest { verify(semesterRepository, never()) .findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any()); verify(assignmentRepository, never()).findForSchedule(any(), any()); - verify(calendarDayRepository, never()) + verify(calendarPeriodRepository, never()) .findByCalendarIdAndCourseNumberAndDate(any(), any(), any()); verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any()); verify(dateAssignmentRepository, never()).findByDate(any()); @@ -122,7 +131,7 @@ class ScheduleGenerationQueryCountTest { int repositoryCalls = Stream.of( semesterRepository, assignmentRepository, - calendarDayRepository, + calendarPeriodRepository, ruleRepository, dateAssignmentRepository, scopeRepository, @@ -209,19 +218,22 @@ class ScheduleGenerationQueryCountTest { return assignment; } - private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) { + private AcademicCalendarPeriod calendarPeriod(AcademicCalendar calendar, + LocalDate startDate, + LocalDate endDate) { AcademicCalendarActivityType theory = new AcademicCalendarActivityType(); theory.setId(30L); theory.setCode("Т"); theory.setName("Теоретическое обучение"); theory.setAllowSchedule(true); - AcademicCalendarDay day = new AcademicCalendarDay(); - day.setAcademicCalendar(calendar); - day.setCourseNumber(1); - day.setDate(date); - day.setActivityType(theory); - return day; + AcademicCalendarPeriod period = new AcademicCalendarPeriod(); + period.setAcademicCalendar(calendar); + period.setCourseNumber(1); + period.setStartDate(startDate); + period.setEndDate(endDate); + period.setActivityType(theory); + return period; } private ScheduleRule rule(int index, Semester semester, List groups) { diff --git a/compose.yaml b/compose.yaml index e243f6b..3e1a969 100755 --- a/compose.yaml +++ b/compose.yaml @@ -73,6 +73,8 @@ services: retries: 5 networks: - magistr + ports: + - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index 8b0c498..439091b 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -2,7 +2,7 @@ ## Назначение -Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения. +Календарный учебный график представляет правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. В базе последовательные даты с одинаковой активностью хранятся одним периодом. Динамическое расписание использует график как фильтр: обычные пары выводятся только в дни теоретического обучения. График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели. @@ -18,7 +18,7 @@ - `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. - `academic_calendars` хранит график профиля, формы обучения и учебного года. - Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. -- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. +- `academic_calendar_periods` хранит непересекающиеся периоды активности по курсу с включительными границами дат. - `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`. - `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год. @@ -52,7 +52,7 @@ 1. Определяется учебный год и семестр даты. 2. Для группы находится назначение графика на этот учебный год. 3. По году начала обучения группы вычисляется текущий курс. -4. В `academic_calendar_days` ищется ячейка для курса и даты. +4. В `academic_calendar_periods` ищется период курса, содержащий дату. 5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`. 6. Если код не разрешает пары, день пропускается и академические часы не списываются. @@ -74,6 +74,8 @@ - Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. - Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. +- Дневной REST-контракт сохранён: при чтении backend разворачивает периоды в ячейки, а при полной замене объединяет соседние даты с одинаковой активностью в одну строку `academic_calendar_periods`. +- Отсутствие периода для даты означает отсутствие разрешённой активности; периоды одного курса и графика не могут пересекаться. - График привязан к профилю обучения, а не только к коду специальности. - Форма обучения графика должна совпадать с формой обучения группы при назначении графика. - Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. diff --git a/docs/API.md b/docs/API.md index 2cc2f3d..85394ba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -551,8 +551,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | `POST` | `/api/admin/academic-calendars` | Создать график | | `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | | `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | -| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | -| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | +| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика, развёрнутая из периодов | +| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки с объединением периодов | | `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров | | `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика | @@ -574,7 +574,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 Отдельного пользовательского названия у календарного графика нет. При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет -все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, +все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности, профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает `409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить `courseCount`, пока существуют строки старших курсов или дисциплины семестров выше @@ -599,8 +599,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 `понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели до начала года не входят в JSON и отображаются пустыми ячейками. Ключ `(courseNumber, date)` не должен повторяться, каждый -`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка -остаётся без изменений; `calendarId` из строки не переопределяет ID в URL. +`activityTypeId` или `activityCode` должен существовать. После проверки соседние даты +одного курса с одинаковой активностью объединяются в один период с включительными +границами. Разрыв хотя бы в один день или смена активности начинает новый период. +При `GET` сохранённые периоды разворачиваются обратно в тот же дневной JSON-контракт, +а `weekNumber` и `dayOfWeek` рассчитываются по дате. При любом `400` старая сетка остаётся +без изменений; `calendarId` из строки не переопределяет ID в URL. **Привязка дисциплин к графику:** ```json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cd4be95..ce33eda 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -317,10 +317,12 @@ pod не сохраняют два конфликтующих правила п `AcademicCalendarController` делегирует полную замену дневной сетки `AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный -Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются -`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает -rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется -только после успешного commit. +Spring proxy: весь payload и все activity types проверяются до bulk delete, затем соседние +даты одного курса с одинаковой активностью сжимаются в периоды и выполняются +`delete → flush → saveAll → flush`. При чтении контроллер разворачивает интервалы обратно +в дневной REST-контракт и вычисляет номер недели и ISO-день. Исключение не перехватывается +внутри сервиса и вызывает rollback. Инвалидация кэша зарегистрирована через transaction +synchronization и выполняется только после успешного commit. `ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет `ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`. @@ -339,7 +341,8 @@ lost update или проверке устаревшего снимка. `endDate - startDate + 1` одинаково в query- и generator-слоях. `ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`. `AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор -назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона. +назначений календарей и пересекающиеся периоды активности от начала затронутого семестра +до конца диапазона. `ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует. diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index ac5fb1d..c27634e 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -143,7 +143,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие | `specialty_profiles` | Профили обучения внутри специальности | | `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | | `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | -| `academic_calendar_days` | Дневная сетка по курсу и дате | +| `academic_calendar_periods` | Непересекающиеся непрерывные периоды активности по курсу | | `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров | | `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `time_slots` | Настраиваемая сетка пар для тенанта | @@ -165,12 +165,15 @@ Bearer-токен проверяется на backend. Frontend-скрытие глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк. -Полная замена `academic_calendar_days` выполняется через транзакционный -`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая +Полная замена дневной сетки выполняется через транзакционный +`AcademicCalendarGridService`. Сервис сначала проверяет весь дневной payload, включая уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, -и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор -записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш -расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе +и разрешает все коды активностей. Затем соседние даты одного курса с одинаковой активностью +объединяются в один включительный период `academic_calendar_periods`. Только после этого +прежние периоды удаляются и новый набор записывается одной транзакцией. Ошибка любой строки +сохраняет прежнюю сетку целиком, а кэш расписания очищается только после commit. База +дополнительно запрещает пересекающиеся периоды одного курса и графика через exclusion +constraint. При чтении API разворачивает периоды обратно в дневные ячейки. Недели дневной сетки выровнены по ISO-неделе `понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже к неделе 2. @@ -189,10 +192,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие 11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения -календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки -звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск -семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только -по lookup-картам. `ScheduleQueryService` передаёт все группы в один +календарей всех выбранных групп, пересекающиеся периоды активности, правила и эффективные +сетки звонков загружаются batch-запросами. Периоды группируются по календарю и курсу, а +поиск периода для даты выполняется бинарным поиском. Поиск семестра и подстановка времени +внутри циклов выполняются только по снимку запроса. `ScheduleQueryService` передаёт все группы в один `buildScheduleForGroups()`, поэтому число запросов не растёт как `дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index a179a95..86fba7c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -5,7 +5,7 @@ - **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL) - **Управление схемой:** Flyway (программный запуск) - **Hibernate DDL:** Отключён (`ddl-auto=none`) -- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов) +- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraints временных слотов и календарных периодов) - **Мультитенантность:** Каждый тенант = отдельная БД - **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant` - **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow` @@ -262,13 +262,12 @@ erDiagram TIMESTAMPTZ updated_at } - academic_calendar_days { + academic_calendar_periods { BIGSERIAL id PK BIGINT calendar_id FK INT course_number - DATE date - INT week_number - INT day_of_week + DATE start_date + DATE end_date BIGINT activity_type_id FK } @@ -375,10 +374,10 @@ erDiagram specialties ||--o{ academic_calendars : "specialty_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" education_forms ||--o{ academic_calendars : "study_form_id" - academic_calendars ||--o{ academic_calendar_days : "calendar_id" + academic_calendars ||--o{ academic_calendar_periods : "calendar_id" academic_calendars ||--o{ academic_calendar_subjects : "calendar_id" academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" - academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" + academic_calendar_activity_types ||--o{ academic_calendar_periods : "activity_type_id" schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id" @@ -768,19 +767,22 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке остаются строки старших курсов или дисциплины старших семестров. -#### `academic_calendar_days` — Дневная сетка календарного графика +#### `academic_calendar_periods` — Периоды активности календарного графика | Колонка | Тип | Описание | |---------|-----|----------| | `id` | BIGSERIAL PK | ID | | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | | `course_number` | INT | Номер курса | -| `date` | DATE | Дата учебного года | -| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года | -| `day_of_week` | INT CHECK(1–7) | День недели ISO | +| `start_date` | DATE | Первая дата периода, включительно | +| `end_date` | DATE | Последняя дата периода, включительно | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | -Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал -`academic_calendars.course_count`, а дата находилась внутри учебного года графика. +Триггер `trg_calendar_periods_dimensions` требует, чтобы `course_number` не превышал +`academic_calendars.course_count`, обе границы находились внутри учебного года графика, +а `end_date` была не раньше `start_date`. Exclusion constraint +`ex_calendar_periods_no_overlap` запрещает пересечение периодов одного курса и графика. +Соседние даты с одинаковой активностью объединяет backend; номер недели и ISO-день +вычисляются при разворачивании периода в дневной REST-ответ и в таблице не дублируются. #### `academic_calendar_subjects` — Дисциплины календарного графика | Колонка | Тип | Описание | @@ -921,12 +923,13 @@ lifecycle ресурсов, эффективная сетка целевого | Файл | Описание | |------|----------| -| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | +| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | ### Этап разработки По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline -`V1`. Правильная нумерация недель календарного графика также входит непосредственно в V1. +`V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика +также входят непосредственно в V1. Перед применением этой редакции требуется полностью пустая tenant-схема. ### Полный сброс БД (локально) diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 3987c1a..d165d8d 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -20,7 +20,7 @@ | `education_forms` | Общий справочник форм обучения для групп и календарных графиков | | `academic_calendar_activity_types` | Коды активностей из Excel-графика | | `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | -| `academic_calendar_days` | Дневная сетка графика по курсу и дате | +| `academic_calendar_periods` | Непересекающиеся периоды активности графика по курсу | | `student_group_calendar_assignments` | Назначения графиков группам по учебному году | | `time_slots` | Настраиваемая сетка пар | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | @@ -28,11 +28,11 @@ | `schedule_rule_slots` | Шаблонные слоты правила | | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | -Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. +Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневном редакторе, а в БД сохраняются непрерывными периодами. ## Backend -Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам. +Добавлены сущности и репозитории для профилей, календарей, кодов активностей, периодов активности и назначений группам. Дневной API сжимает соседние одинаковые ячейки при записи и разворачивает периоды при чтении. Ключевые сервисы: diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 475af37..ea9ac1e 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -188,7 +188,7 @@ frontend/ - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. -- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. REST-контракт остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index c064c40..c8c2cc8 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -21,7 +21,7 @@ - `specialties` и `specialty_profiles` определяют образовательную программу группы. - `academic_calendars` хранит график профиля и формы обучения. - `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`. -- `academic_calendar_days` хранит дневную сетку по курсу и дате. +- `academic_calendar_periods` хранит непрерывные непересекающиеся периоды активности по курсу. - `student_group_calendar_assignments` выбирает график для группы на учебный год. ### Слой правил расписания @@ -47,4 +47,4 @@ ## Административный сценарий -Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. +Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. Backend объединяет соседние одинаковые дни в периоды, не меняя дневной интерфейс. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index 86221ef..ac36a22 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -1,6 +1,6 @@ # Задачи: динамическое расписание и календарный учебный график -Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год. +Актуальная декомпозиция относится к модели, где календарный учебный график редактируется дневной сеткой, хранится непрерывными периодами активности и назначается группе на учебный год. ## Выполнено @@ -8,7 +8,7 @@ - [x] Добавлены специальности, профили обучения и связь группы с профилем. - [x] Добавлены формы обучения календарного графика. - [x] Добавлен справочник кодов активностей Excel-графика. -- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. +- [x] Добавлены календарные графики, интервальное хранение дневной сетки и назначения графиков группам. - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. diff --git a/graphify-out/.graphify_learning.json b/graphify-out/.graphify_learning.json index a44deeb..9f739be 100644 --- a/graphify-out/.graphify_learning.json +++ b/graphify-out/.graphify_learning.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-07-30T00:10:04.894284+00:00", + "generated_at": "2026-07-31T00:26:15.582806+00:00", "nodes": { "_gitea_workflows_docker_build_backend_image_pipeline": { - "code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", + "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077", "label": "Pipeline backend-образа", "last": "2026-07-29T21:50:22.465033+00:00", "provenance": [ @@ -22,16 +22,22 @@ "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." } ], - "score": 2.991771942, + "score": 2.92268101, "source_file": ".gitea/workflows/docker-build.yaml", "status": "preferred", "uses": 3 }, "_gitea_workflows_docker_build_build_and_push_docker_images": { - "code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", + "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077", "label": "Workflow сборки и публикации Docker-образов", - "last": "2026-07-30T00:01:24.718235+00:00", + "last": "2026-07-30T00:15:03.724071+00:00", + "neg": 1, "provenance": [ + { + "date": "2026-07-30T00:15:03.724071+00:00", + "outcome": "corrected", + "q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." + }, { "date": "2026-07-30T00:01:24.718235+00:00", "outcome": "useful", @@ -51,23 +57,25 @@ "date": "2026-07-29T21:27:26.637336+00:00", "outcome": "useful", "q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" - }, - { - "date": "2026-07-29T20:38:53.488440+00:00", - "outcome": "useful", - "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." } ], - "score": 7.977548417, + "score": 6.81633329, "source_file": ".gitea/workflows/docker-build.yaml", - "status": "preferred", - "uses": 8 + "status": "contested", + "uses": 8, + "verdict": "useful" }, "_gitea_workflows_docker_build_deploy_to_k8s": { - "code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", + "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077", "label": "Job деплоя в Kubernetes", - "last": "2026-07-30T00:01:24.718235+00:00", + "last": "2026-07-30T00:15:03.724071+00:00", + "neg": 1, "provenance": [ + { + "date": "2026-07-30T00:15:03.724071+00:00", + "outcome": "corrected", + "q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." + }, { "date": "2026-07-30T00:01:24.718235+00:00", "outcome": "useful", @@ -79,13 +87,14 @@ "q": "сам можешь выполнить эти действия?" } ], - "score": 1.996058514, + "score": 0.972977818, "source_file": ".gitea/workflows/docker-build.yaml", - "status": "preferred", - "uses": 2 + "status": "contested", + "uses": 2, + "verdict": "useful" }, "_gitea_workflows_docker_build_frontend_image_pipeline": { - "code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", + "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077", "label": "Pipeline frontend-образа", "last": "2026-07-29T22:22:22.155742+00:00", "provenance": [ @@ -105,7 +114,7 @@ "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." } ], - "score": 2.992651314, + "score": 2.923540074, "source_file": ".gitea/workflows/docker-build.yaml", "status": "preferred", "uses": 3 @@ -131,7 +140,7 @@ "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" } ], - "score": 1.85377087, + "score": 1.810960536, "source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java", "status": "preferred", "uses": 3 @@ -147,7 +156,7 @@ "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" } ], - "score": 0.658614913, + "score": 0.643405092, "source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java", "status": "tentative", "uses": 1 @@ -163,7 +172,7 @@ "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" } ], - "score": 0.554143065, + "score": 0.541345879, "source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java", "status": "tentative", "uses": 1 @@ -179,7 +188,7 @@ "q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?" } ], - "score": 0.67398734, + "score": 0.658422513, "source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java", "status": "tentative", "uses": 1 @@ -195,7 +204,7 @@ "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" } ], - "score": 0.641012892, + "score": 0.626209565, "source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java", "status": "tentative", "uses": 1 @@ -211,7 +220,7 @@ "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" } ], - "score": 0.673873064, + "score": 0.658310876, "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java", "status": "tentative", "uses": 1 @@ -237,7 +246,7 @@ "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" } ], - "score": 1.988873297, + "score": 1.942942954, "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java", "status": "preferred", "uses": 3 @@ -263,11 +272,27 @@ "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" } ], - "score": 2.006475318, + "score": 1.960138481, "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java", "status": "preferred", "uses": 3 }, + "backend_src_main_java_com_magistr_app_controller_academiccalendarcontroller_academiccalendarcontroller": { + "code_fingerprint": "0bc8973ea8083a479c690c40e12fb178df49b4f88dc62c7788fb64e2069e1b5f", + "label": "AcademicCalendarController", + "last": "2026-07-31T00:15:22.545259+00:00", + "provenance": [ + { + "date": "2026-07-31T00:15:22.545259+00:00", + "outcome": "useful", + "q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается." + } + ], + "score": 0.999825381, + "source_file": "backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java", + "status": "tentative", + "uses": 1 + }, "backend_src_main_java_com_magistr_app_controller_classroomcontroller": { "code_fingerprint": "a768ef91dfe89f82197f53d85b348187a340c3dc8043bf009fa2f08418fabcba", "label": "ClassroomController.java", @@ -279,7 +304,7 @@ "q": "Почему в выборе аудитории показаны корпус и этаж?" } ], - "score": 0.828570992, + "score": 0.809436264, "source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java", "status": "tentative", "uses": 1 @@ -295,7 +320,7 @@ "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" } ], - "score": 0.658614913, + "score": 0.643405092, "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "status": "tentative", "uses": 1 @@ -316,7 +341,7 @@ "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" } ], - "score": 1.347860405, + "score": 1.316733389, "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "status": "preferred", "uses": 2 @@ -332,7 +357,7 @@ "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" } ], - "score": 0.641012892, + "score": 0.626209565, "source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java", "status": "tentative", "uses": 1 @@ -353,7 +378,7 @@ "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" } ], - "score": 1.48642084, + "score": 1.452093958, "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java", "status": "preferred", "uses": 2 @@ -369,7 +394,7 @@ "q": "Как безопасно валидировать и сохранять точечные изменения расписания?" } ], - "score": 0.669157537, + "score": 0.653704248, "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java", "status": "tentative", "uses": 1 @@ -390,7 +415,7 @@ "q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" } ], - "score": 1.314657272, + "score": 1.284297038, "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java", "status": "preferred", "uses": 2 @@ -398,18 +423,28 @@ "backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": { "code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d", "label": "AcademicCalendar", - "last": "2026-07-22T21:09:57.015227+00:00", + "last": "2026-07-31T00:15:22.545259+00:00", "provenance": [ + { + "date": "2026-07-31T00:15:22.545259+00:00", + "outcome": "useful", + "q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается." + }, + { + "date": "2026-07-30T23:49:30.491131+00:00", + "outcome": "useful", + "q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело" + }, { "date": "2026-07-22T21:09:57.015227+00:00", "outcome": "useful", "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" } ], - "score": 0.848212095, + "score": 2.827859657, "source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java", - "status": "tentative", - "uses": 1 + "status": "preferred", + "uses": 3 }, "backend_src_main_java_com_magistr_app_model_classroom_classroom": { "code_fingerprint": "5614d83b8b7e20fd2b76271b28a8ef11ffc137808ec2d7ed7cd23e42e0ab93ec", @@ -422,7 +457,7 @@ "q": "Почему в выборе аудитории показаны корпус и этаж?" } ], - "score": 0.828570992, + "score": 0.809436264, "source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java", "status": "tentative", "uses": 1 @@ -438,7 +473,7 @@ "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" } ], - "score": 0.848212095, + "score": 0.828623782, "source_file": "backend/src/main/java/com/magistr/app/model/Department.java", "status": "tentative", "uses": 1 @@ -459,7 +494,7 @@ "q": "Корректны ли inferred-связи вокруг ScheduleRule?" } ], - "score": 1.228405956, + "score": 1.200037579, "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java", "status": "preferred", "uses": 2 @@ -475,7 +510,7 @@ "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" } ], - "score": 0.67364438, + "score": 0.658087473, "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java", "status": "tentative", "uses": 1 @@ -496,7 +531,7 @@ "q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра" } ], - "score": 1.676018021, + "score": 1.637312649, "source_file": "backend/src/main/java/com/magistr/app/model/Semester.java", "status": "preferred", "uses": 2 @@ -512,11 +547,32 @@ "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" } ], - "score": 0.848212095, + "score": 0.828623782, "source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java", "status": "tentative", "uses": 1 }, + "backend_src_main_java_com_magistr_app_repository_academiccalendardayrepository_academiccalendardayrepository": { + "code_fingerprint": "", + "label": "AcademicCalendarDayRepository", + "last": "2026-07-31T00:15:22.545259+00:00", + "provenance": [ + { + "date": "2026-07-31T00:15:22.545259+00:00", + "outcome": "useful", + "q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается." + }, + { + "date": "2026-07-30T23:49:30.491131+00:00", + "outcome": "useful", + "q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело" + } + ], + "score": 1.999235874, + "source_file": "backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java", + "status": "preferred", + "uses": 2 + }, "backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": { "code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10", "label": "SemesterRepository", @@ -528,7 +584,7 @@ "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" } ], - "score": 0.67364438, + "score": 0.658087473, "source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java", "status": "tentative", "uses": 1 @@ -549,7 +605,7 @@ "q": "Как безопасно валидировать и сохранять точечные изменения расписания?" } ], - "score": 1.496963464, + "score": 1.462393114, "source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java", "status": "preferred", "uses": 2 @@ -565,7 +621,7 @@ "q": "Корректны ли inferred-связи вокруг ScheduleRule?" } ], - "score": 0.554761576, + "score": 0.541950106, "source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java", "status": "tentative", "uses": 1 @@ -581,7 +637,7 @@ "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" } ], - "score": 0.658614913, + "score": 0.643405092, "source_file": "compose.yaml", "status": "tentative", "uses": 1 @@ -597,13 +653,13 @@ "q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше" } ], - "score": 0.995980694, + "score": 0.972979865, "source_file": "compose.yaml", "status": "tentative", "uses": 1 }, "docs_database_flyway_migrations": { - "code_fingerprint": "2f5d4ce61ba0f5d8a9141f4ac33375dfc26a68e3bdccdc4269cf2a4f45a50176", + "code_fingerprint": "2b50799351b008ee1b60bba24a352e8cb6c0faf710e5af8ed780a5c2d3a1fc87", "label": "Flyway-миграции", "last": "2026-07-29T19:26:44.188240+00:00", "provenance": [ @@ -613,13 +669,13 @@ "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" } ], - "score": 0.995464026, + "score": 0.972475129, "source_file": "docs/DATABASE.md", "status": "tentative", "uses": 1 }, "docs_infrastructure_gitea_actions_cicd": { - "code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", + "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc", "label": "CI/CD Gitea Actions в инфраструктуре", "last": "2026-07-29T22:22:22.155742+00:00", "provenance": [ @@ -644,13 +700,13 @@ "q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." } ], - "score": 3.990045183, + "score": 3.897900479, "source_file": "docs/INFRASTRUCTURE.md", "status": "preferred", "uses": 4 }, "docs_infrastructure_jwt_secret_policy": { - "code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", + "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc", "label": "Политика JWT-секрета", "last": "2026-07-30T00:01:24.718235+00:00", "provenance": [ @@ -670,16 +726,22 @@ "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" } ], - "score": 2.99152254, + "score": 2.922437367, "source_file": "docs/INFRASTRUCTURE.md", "status": "preferred", "uses": 3 }, "docs_infrastructure_kubernetes_production": { - "code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", + "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc", "label": "Production-инфраструктура Kubernetes", - "last": "2026-07-30T00:01:24.718235+00:00", + "last": "2026-07-30T00:15:03.724071+00:00", + "neg": 1, "provenance": [ + { + "date": "2026-07-30T00:15:03.724071+00:00", + "outcome": "corrected", + "q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." + }, { "date": "2026-07-30T00:01:24.718235+00:00", "outcome": "useful", @@ -701,13 +763,14 @@ "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" } ], - "score": 3.987503234, + "score": 2.918432812, "source_file": "docs/INFRASTRUCTURE.md", - "status": "preferred", - "uses": 4 + "status": "contested", + "uses": 4, + "verdict": "useful" }, "docs_infrastructure_tenants_configmap": { - "code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", + "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc", "label": "Tenants ConfigMap", "last": "2026-07-29T19:26:44.188240+00:00", "provenance": [ @@ -717,7 +780,7 @@ "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" } ], - "score": 0.995464026, + "score": 0.972475129, "source_file": "docs/INFRASTRUCTURE.md", "status": "tentative", "uses": 1 @@ -733,7 +796,7 @@ "q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" } ], - "score": 0.997393869, + "score": 0.974360405, "source_file": "", "status": "tentative", "uses": 1 diff --git a/graphify-out/memory/query_20260730_234930_как_ты_думаешь__нужно_ли_оптимизировать_таблицу_ac.md b/graphify-out/memory/query_20260730_234930_как_ты_думаешь__нужно_ли_оптимизировать_таблицу_ac.md new file mode 100644 index 0000000..b8dd0f1 --- /dev/null +++ b/graphify-out/memory/query_20260730_234930_как_ты_думаешь__нужно_ли_оптимизировать_таблицу_ac.md @@ -0,0 +1,25 @@ +--- +type: "query" +date: "2026-07-30T23:49:30.491131+00:00" +question: "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело" +contributor: "graphify" +outcome: "useful" +source_nodes: ["AcademicCalendar", "AcademicDateService", "AcademicCalendarDayRepository", "ScheduleGeneratorService"] +--- + +# Q: как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело + +## Answer + +Expanded from original query via graph vocab: academic, calendar, days, schedule, generator, semester, date, repository. Граф помог найти AcademicDateService, AcademicCalendarDayRepository и ScheduleGeneratorService. Сверка исходников показала: рост таблицы зависит от числа уникальных календарных графиков, а не от числа групп или запросов расписания; текущая дневная материализация создаёт примерно 1460 строк на четырёхкурсовый годовой график. Рекомендация: при ожидаемых 1000 уникальных графиках перейти на непересекающиеся интервалы активности по курсу, сохранив дневной DTO на границе API на первом этапе; отдельно убрать дублирующий индекс и ограничить batch-чтение нужными парами график-курс. Партиционирование для такого объёма не требуется. + +## Outcome + +- Signal: useful + +## Source Nodes + +- AcademicCalendar +- AcademicDateService +- AcademicCalendarDayRepository +- ScheduleGeneratorService \ No newline at end of file diff --git a/graphify-out/memory/query_20260731_001522_давай_сделаем__один_непрерывный_период_с_одинаково.md b/graphify-out/memory/query_20260731_001522_давай_сделаем__один_непрерывный_период_с_одинаково.md new file mode 100644 index 0000000..cbb0e9a --- /dev/null +++ b/graphify-out/memory/query_20260731_001522_давай_сделаем__один_непрерывный_период_с_одинаково.md @@ -0,0 +1,25 @@ +--- +type: "query" +date: "2026-07-31T00:15:22.545259+00:00" +question: "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается." +contributor: "graphify" +outcome: "useful" +source_nodes: ["AcademicCalendarController", "AcademicCalendarDayRepository", "ScheduleGeneratorService", "AcademicCalendar"] +--- + +# Q: давай сделаем "Один непрерывный период с одинаковой активностью становится одной строкой". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается. + +## Answer + +Expanded from original query via vocab: [academic, calendar, days, grid, activity, repository, schedule, date]. Дневные строки заменены сущностью AcademicCalendarPeriod и таблицей academic_calendar_periods с включительными start_date/end_date. PUT дневной сетки проверяет payload и объединяет соседние даты одного курса с одинаковой активностью; GET разворачивает периоды обратно, поэтому frontend-контракт не изменён. Генератор загружает только пересекающиеся интервалы нужных курсов и ищет активность бинарным поиском. V1 содержит GiST exclusion constraint против пересечений; отдельная миграция не создана. Полные backend-тесты и frontend-проверки прошли. + +## Outcome + +- Signal: useful + +## Source Nodes + +- AcademicCalendarController +- AcademicCalendarDayRepository +- ScheduleGeneratorService +- AcademicCalendar \ No newline at end of file diff --git a/graphify-out/memory/query_20260731_002724_отлично__как_мне_подключиться_к_этой_бд_через_dbea.md b/graphify-out/memory/query_20260731_002724_отлично__как_мне_подключиться_к_этой_бд_через_dbea.md new file mode 100644 index 0000000..d958be3 --- /dev/null +++ b/graphify-out/memory/query_20260731_002724_отлично__как_мне_подключиться_к_этой_бд_через_dbea.md @@ -0,0 +1,23 @@ +--- +type: "query" +date: "2026-07-31T00:27:24.418402+00:00" +question: "отлично, как мне подключиться к этой бд через dbeaver?" +contributor: "graphify" +outcome: "useful" +source_nodes: ["PostgreSQL-сервис Docker Compose", "Docker Compose инфраструктура"] +--- + +# Q: отлично, как мне подключиться к этой бд через dbeaver? + +## Answer + +Expanded from original query via vocab: [postgres, database, tenant, compose, docker, connection, config, service]. Локальный PostgreSQL-сервис Docker Compose сейчас не публикует порт на хост, поэтому DBeaver не сможет подключиться к localhost без изменения compose.yaml. Нужно добавить сервису db привязку 127.0.0.1:15432:5432, выполнить docker compose up -d db, затем создать PostgreSQL-подключение: host localhost, port 15432, database POSTGRES_DB, username POSTGRES_USER, password POSTGRES_PASSWORD из корневого .env, SSL отключён. Контейнерное имя db применимо только внутри Docker-сети. Каждая tenant-БД подключается отдельным именем базы. + +## Outcome + +- Signal: useful + +## Source Nodes + +- PostgreSQL-сервис Docker Compose +- Docker Compose инфраструктура \ No newline at end of file diff --git a/graphify-out/reflections/LESSONS.md b/graphify-out/reflections/LESSONS.md index 04facc4..e0b5eef 100644 --- a/graphify-out/reflections/LESSONS.md +++ b/graphify-out/reflections/LESSONS.md @@ -1,24 +1,23 @@ # Lessons -_Auto-generated by `graphify reflect` from 20 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._ +_Auto-generated by `graphify reflect` from 23 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._ ## Summary -- 19 useful · 1 dead ends · 0 corrected · 0 unmarked +- 21 useful · 1 dead ends · 1 corrected · 0 unmarked ## Lessons **Preferred sources** — corroborated by ≥2 useful results; start here. -- `Workflow сборки и публикации Docker-образов` (8× useful) +- `ScheduleGeneratorService` (5× useful) - `CI/CD Gitea Actions в инфраструктуре` (4× useful) -- `Production-инфраструктура Kubernetes` (4× useful) - `Pipeline frontend-образа` (3× useful) - `Pipeline backend-образа` (3× useful) - `Политика JWT-секрета` (3× useful) -- `ScheduleGeneratorService` (3× useful) +- `AcademicCalendar` (3× useful) +- `AcademicCalendarDayRepository` (2× useful) - `TenantRoutingDataSource` (3× useful) -- `Job деплоя в Kubernetes` (2× useful) - `TenantConfigWatcher` (3× useful) - `AuthorizationInterceptor` (3× useful) - `Semester` (2× useful) @@ -30,11 +29,12 @@ _Auto-generated by `graphify reflect` from 20 session memories in graphify-out/m **Tentative** — useful in fewer than 2 results; verify before relying. +- `AcademicCalendarController` (1× useful) +- `AcademicDateService` (1× useful) - `SpringBootApplication` (1× useful) - `PostgreSQL-сервис Docker Compose` (1× useful) - `Flyway-миграции` (1× useful) - `Tenants ConfigMap` (1× useful) -- `AcademicCalendar` (1× useful) - `Department` (1× useful) - `StudentGroup` (1× useful) - `Classroom` (1× useful) @@ -52,6 +52,16 @@ _Auto-generated by `graphify reflect` from 20 session memories in graphify-out/m - `ScheduleRuleTest` (1× useful) - `RequireRoles` (1× useful) +**Contested** — mixed signals; recency decides. + +- `Workflow сборки и публикации Docker-образов` — 8× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30) +- `Production-инфраструктура Kubernetes` — 4× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30) +- `Job деплоя в Kubernetes` — 2× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30) + **Known dead ends** — led nowhere; don't re-derive. - "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?" — `Override` + +**Corrections** — do these differently. + +- "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." → Kubernetes manifests остаются вне репозитория; CI автоматически выполняет только image digest rollout после push в main.