исправление хранения календаря

This commit is contained in:
Zuev
2026-07-31 21:24:11 +03:00
parent 87f3d98621
commit cc422255a2
31 changed files with 825 additions and 425 deletions

View File

@@ -21,20 +21,20 @@ public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository; private final SemesterRepository semesterRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository; private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicPeriodService academicPeriodService; private final AcademicPeriodService academicPeriodService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository, public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository, SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository, AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
ScheduleGeneratorService scheduleGeneratorService, ScheduleGeneratorService scheduleGeneratorService,
AcademicPeriodService academicPeriodService) { AcademicPeriodService academicPeriodService) {
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository; this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository; this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
this.academicPeriodService = academicPeriodService; this.academicPeriodService = academicPeriodService;
} }
@@ -128,7 +128,7 @@ public class AcademicCalendarAdminController {
if (!activityTypeRepository.existsById(id)) { if (!activityTypeRepository.existsById(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
if (calendarDayRepository.existsByActivityTypeId(id)) { if (calendarPeriodRepository.existsByActivityTypeId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках")); return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках"));
} }
activityTypeRepository.deleteById(id); activityTypeRepository.deleteById(id);

View File

@@ -12,6 +12,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -25,7 +27,7 @@ import java.util.stream.Collectors;
public class AcademicCalendarController { public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository; private final SpecialtyProfileRepository profileRepository;
@@ -37,7 +39,7 @@ public class AcademicCalendarController {
private final AcademicStructureService academicStructureService; private final AcademicStructureService academicStructureService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository, public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository, SpecialtyProfileRepository profileRepository,
@@ -48,7 +50,7 @@ public class AcademicCalendarController {
ScheduleGeneratorService scheduleGeneratorService, ScheduleGeneratorService scheduleGeneratorService,
AcademicStructureService academicStructureService) { AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository; this.profileRepository = profileRepository;
@@ -108,8 +110,10 @@ public class AcademicCalendarController {
if (!calendarRepository.existsById(id)) { if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream() return ResponseEntity.ok(calendarPeriodRepository.findByCalendarIdWithActivity(id).stream()
.map(this::toGridDayDto) .flatMap(period -> period.getStartDate()
.datesUntil(period.getEndDate().plusDays(1))
.map(date -> toGridDayDto(period, date)))
.toList()); .toList());
} }
@@ -254,15 +258,19 @@ public class AcademicCalendarController {
); );
} }
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) { private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarPeriod period, LocalDate date) {
AcademicCalendarActivityType activityType = day.getActivityType(); 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( return new AcademicCalendarGridDayDto(
day.getId(), period.getId(),
day.getAcademicCalendar().getId(), period.getAcademicCalendar().getId(),
day.getCourseNumber(), period.getCourseNumber(),
day.getDate(), date,
day.getWeekNumber(), weekNumber,
day.getDayOfWeek(), date.getDayOfWeek().getValue(),
activityType.getId(), activityType.getId(),
activityType.getCode(), activityType.getCode(),
activityType.getName(), activityType.getName(),

View File

@@ -34,9 +34,8 @@ final class DatabaseConstraintViolationMapper {
badRequest("chk_semesters_type", "Недопустимый тип семестра"), badRequest("chk_semesters_type", "Недопустимый тип семестра"),
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"), badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"), badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"), badRequest("chk_calendar_periods_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"), badRequest("chk_calendar_periods_dates", "Дата окончания периода раньше даты начала"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"), badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"), badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"),
badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"), badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"),
@@ -62,7 +61,7 @@ final class DatabaseConstraintViolationMapper {
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"), conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"), conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"), conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("uq_calendar_days", "День календарного графика уже существует"), conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"), conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"), conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"), conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),

View File

@@ -5,8 +5,8 @@ import jakarta.persistence.*;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "academic_calendar_days") @Table(name = "academic_calendar_periods")
public class AcademicCalendarDay { public class AcademicCalendarPeriod {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -19,14 +19,11 @@ public class AcademicCalendarDay {
@Column(name = "course_number", nullable = false) @Column(name = "course_number", nullable = false)
private Integer courseNumber; private Integer courseNumber;
@Column(nullable = false) @Column(name = "start_date", nullable = false)
private LocalDate date; private LocalDate startDate;
@Column(name = "week_number", nullable = false) @Column(name = "end_date", nullable = false)
private Integer weekNumber; private LocalDate endDate;
@Column(name = "day_of_week", nullable = false)
private Integer dayOfWeek;
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "activity_type_id", nullable = false) @JoinColumn(name = "activity_type_id", nullable = false)
@@ -56,28 +53,20 @@ public class AcademicCalendarDay {
this.courseNumber = courseNumber; this.courseNumber = courseNumber;
} }
public LocalDate getDate() { public LocalDate getStartDate() {
return date; return startDate;
} }
public void setDate(LocalDate date) { public void setStartDate(LocalDate startDate) {
this.date = date; this.startDate = startDate;
} }
public Integer getWeekNumber() { public LocalDate getEndDate() {
return weekNumber; return endDate;
} }
public void setWeekNumber(Integer weekNumber) { public void setEndDate(LocalDate endDate) {
this.weekNumber = weekNumber; this.endDate = endDate;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
} }
public AcademicCalendarActivityType getActivityType() { public AcademicCalendarActivityType getActivityType() {

View File

@@ -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<AcademicCalendarDay, Long> {
@Query("""
select day
from AcademicCalendarDay day
join fetch day.activityType
where day.academicCalendar.id = :calendarId
order by day.courseNumber asc, day.date asc
""")
List<AcademicCalendarDay> 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<AcademicCalendarDay> 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<AcademicCalendarDay> findForScheduleBatch(
@Param("calendarIds") Collection<Long> 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);
}

View File

@@ -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<AcademicCalendarPeriod, Long> {
@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<AcademicCalendarPeriod> 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<AcademicCalendarPeriod> 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<AcademicCalendarPeriod> findForScheduleBatch(
@Param("calendarIds") Collection<Long> 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);
}

View File

@@ -3,10 +3,10 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType; 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.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository; 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 com.magistr.app.repository.AcademicCalendarRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -16,25 +16,27 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set; import java.util.Set;
@Service @Service
public class AcademicCalendarGridService { public class AcademicCalendarGridService {
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository; private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository, public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository, AcademicCalendarActivityTypeRepository activityTypeRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository; this.activityTypeRepository = activityTypeRepository;
this.scheduleGeneratorService = scheduleGeneratorService; this.scheduleGeneratorService = scheduleGeneratorService;
} }
@@ -47,18 +49,18 @@ public class AcademicCalendarGridService {
throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика"); throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика");
} }
List<AcademicCalendarDay> replacement = buildReplacement(calendar, rows); List<AcademicCalendarPeriod> replacement = buildReplacement(calendar, rows);
calendarDayRepository.deleteByCalendarId(id); calendarPeriodRepository.deleteByCalendarId(id);
calendarDayRepository.flush(); calendarPeriodRepository.flush();
calendarDayRepository.saveAll(replacement); calendarPeriodRepository.saveAll(replacement);
calendarDayRepository.flush(); calendarPeriodRepository.flush();
clearScheduleCacheAfterCommit(); clearScheduleCacheAfterCommit();
} }
private List<AcademicCalendarDay> buildReplacement(AcademicCalendar calendar, private List<AcademicCalendarPeriod> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) { List<AcademicCalendarGridDayDto> rows) {
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size()); List<ValidatedGridDay> validatedDays = new ArrayList<>(rows.size());
Set<GridBusinessKey> uniqueRows = new HashSet<>(); Set<GridBusinessKey> uniqueRows = new HashSet<>();
for (AcademicCalendarGridDayDto row : rows) { for (AcademicCalendarGridDayDto row : rows) {
@@ -75,19 +77,42 @@ public class AcademicCalendarGridService {
if (!uniqueRows.add(businessKey)) { if (!uniqueRows.add(businessKey)) {
throw new IllegalArgumentException("Для курса и даты можно указать только одну активность"); throw new IllegalArgumentException("Для курса и даты можно указать только одну активность");
} }
validatedDays.add(new ValidatedGridDay(
row.courseNumber(),
row.date(),
resolveActivityType(row)
));
}
AcademicCalendarDay day = new AcademicCalendarDay(); validatedDays.sort(Comparator
day.setAcademicCalendar(calendar); .comparing(ValidatedGridDay::courseNumber)
day.setCourseNumber(row.courseNumber()); .thenComparing(ValidatedGridDay::date));
day.setDate(row.date());
day.setWeekNumber(row.weekNumber()); List<AcademicCalendarPeriod> replacement = new ArrayList<>();
day.setDayOfWeek(row.dayOfWeek()); AcademicCalendarPeriod currentPeriod = null;
day.setActivityType(resolveActivityType(row)); for (ValidatedGridDay day : validatedDays) {
replacement.add(day); 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; 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) { private void validateRequiredFields(AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null if (row.courseNumber() == null
|| row.date() == null || row.date() == null
@@ -155,4 +180,9 @@ public class AcademicCalendarGridService {
private record GridBusinessKey(Integer courseNumber, LocalDate date) { private record GridBusinessKey(Integer courseNumber, LocalDate date) {
} }
private record ValidatedGridDay(Integer courseNumber,
LocalDate date,
AcademicCalendarActivityType activityType) {
}
} }

View File

@@ -1,7 +1,7 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.model.*; 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.SemesterRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository; import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -17,16 +17,16 @@ import java.util.stream.Collectors;
public class AcademicDateService { public class AcademicDateService {
private final SemesterRepository semesterRepository; private final SemesterRepository semesterRepository;
private final AcademicCalendarDayRepository calendarDayRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository; private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final boolean evenWeekIsUpper; private final boolean evenWeekIsUpper;
public AcademicDateService(SemesterRepository semesterRepository, public AcademicDateService(SemesterRepository semesterRepository,
AcademicCalendarDayRepository calendarDayRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository, StudentGroupCalendarAssignmentRepository assignmentRepository,
@Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) { @Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) {
this.semesterRepository = semesterRepository; this.semesterRepository = semesterRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.assignmentRepository = assignmentRepository; this.assignmentRepository = assignmentRepository;
this.evenWeekIsUpper = evenWeekIsUpper; this.evenWeekIsUpper = evenWeekIsUpper;
} }
@@ -77,20 +77,54 @@ public class AcademicDateService {
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty() List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
? List.of() ? List.of()
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds); : assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
Set<Long> calendarIds = assignments.stream() List<AcademicCalendarPeriod> calendarPeriods = loadCalendarPeriods(
.map(StudentGroupCalendarAssignment::getAcademicCalendar) assignments,
.filter(Objects::nonNull) snapshotStart,
.map(AcademicCalendar::getId) endDate
.filter(Objects::nonNull) );
.collect(Collectors.toCollection(LinkedHashSet::new));
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|| snapshotStart == null
|| endDate == null
|| endDate.isBefore(snapshotStart)
? List.of()
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate); return new ScheduleSnapshot(semesterSnapshot, assignments, calendarPeriods, snapshotStart, endDate);
}
private List<AcademicCalendarPeriod> loadCalendarPeriods(
Collection<StudentGroupCalendarAssignment> assignments,
LocalDate startDate,
LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return List.of();
}
Map<Integer, Set<Long>> 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<AcademicCalendarPeriod> result = new ArrayList<>();
calendarIdsByCourse.forEach((courseNumber, calendarIds) ->
result.addAll(calendarPeriodRepository.findForScheduleBatch(
calendarIds,
courseNumber,
startDate,
endDate
))
);
return result;
} }
public int getWeekNumber(Semester semester, LocalDate date) { public int getWeekNumber(Semester semester, LocalDate date) {
@@ -126,12 +160,12 @@ public class AcademicDateService {
} }
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId()) return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId())
.flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate( .flatMap(assignment -> calendarPeriodRepository.findByCalendarIdAndCourseNumberAndDate(
assignment.getAcademicCalendar().getId(), assignment.getAcademicCalendar().getId(),
courseNumber, courseNumber,
date date
)) ))
.map(AcademicCalendarDay::getActivityType); .map(AcademicCalendarPeriod::getActivityType);
} }
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) { public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
@@ -151,13 +185,13 @@ public class AcademicDateService {
private final Map<LocalDate, Semester> semesterByDate; private final Map<LocalDate, Semester> semesterByDate;
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear; private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate; private final Map<CalendarCourseKey, List<AcademicCalendarPeriod>> periodsByCalendarAndCourse;
private final LocalDate startDate; private final LocalDate startDate;
private final LocalDate endDate; private final LocalDate endDate;
private ScheduleSnapshot(Collection<Semester> semesters, private ScheduleSnapshot(Collection<Semester> semesters,
Collection<StudentGroupCalendarAssignment> assignments, Collection<StudentGroupCalendarAssignment> assignments,
Collection<AcademicCalendarDay> calendarDays, Collection<AcademicCalendarPeriod> calendarPeriods,
LocalDate startDate, LocalDate startDate,
LocalDate endDate) { LocalDate endDate) {
this.startDate = startDate; this.startDate = startDate;
@@ -171,14 +205,18 @@ public class AcademicDateService {
), ),
Function.identity() Function.identity()
)); ));
this.activityByCalendarCourseAndDate = calendarDays.stream() this.periodsByCalendarAndCourse = calendarPeriods.stream()
.collect(Collectors.toUnmodifiableMap( .collect(Collectors.groupingBy(
day -> new CalendarDayKey( period -> new CalendarCourseKey(
day.getAcademicCalendar().getId(), period.getAcademicCalendar().getId(),
day.getCourseNumber(), period.getCourseNumber()
day.getDate()
), ),
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) return findAssignment(group, semester)
.map(StudentGroupCalendarAssignment::getAcademicCalendar) .map(StudentGroupCalendarAssignment::getAcademicCalendar)
.map(AcademicCalendar::getId) .map(AcademicCalendar::getId)
.map(calendarId -> activityByCalendarCourseAndDate.get( .flatMap(calendarId -> findActivityType(calendarId, courseNumber, date));
new CalendarDayKey(calendarId, courseNumber, date) }
));
private Optional<AcademicCalendarActivityType> findActivityType(Long calendarId,
int courseNumber,
LocalDate date) {
List<AcademicCalendarPeriod> 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) { 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 GroupAcademicYearKey(Long groupId, Long academicYearId) {
} }
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) { private record CalendarCourseKey(Long calendarId, Integer courseNumber) {
} }
} }

View File

@@ -10,7 +10,7 @@ import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; 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.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
@@ -35,7 +35,7 @@ import java.util.NoSuchElementException;
public class AcademicStructureService { public class AcademicStructureService {
private final AcademicCalendarRepository calendarRepository; private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository; private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository; private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository; private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository; private final SpecialtiesRepository specialtiesRepository;
@@ -47,7 +47,7 @@ public class AcademicStructureService {
private final ScheduleGeneratorService scheduleGeneratorService; private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicStructureService(AcademicCalendarRepository calendarRepository, public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository, AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository, AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository, AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository, SpecialtiesRepository specialtiesRepository,
@@ -58,7 +58,7 @@ public class AcademicStructureService {
SubgroupRepository subgroupRepository, SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) { ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository; this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository; this.calendarPeriodRepository = calendarPeriodRepository;
this.calendarSubjectRepository = calendarSubjectRepository; this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository; this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository; this.specialtiesRepository = specialtiesRepository;
@@ -106,7 +106,7 @@ public class AcademicStructureService {
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми" "Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
); );
} }
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( if (calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
id, request.courseCount())) { id, request.courseCount())) {
throw new ScheduleConflictException( throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов" "Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
@@ -118,7 +118,7 @@ public class AcademicStructureService {
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров" "Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
); );
} }
if (calendarDayRepository.existsOutsideDateRange( if (calendarPeriodRepository.existsOutsideDateRange(
id, id,
candidate.academicYear().getStartDate(), candidate.academicYear().getStartDate(),
candidate.academicYear().getEndDate())) { candidate.academicYear().getEndDate())) {

View File

@@ -2,6 +2,7 @@
-- Инициализация расширений -- Инициализация расширений
-- ========================================== -- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto; 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' WHERE ay.title = '2025-2026'
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; 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, id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
course_number INT NOT NULL, course_number INT NOT NULL,
date DATE NOT NULL, start_date DATE NOT NULL,
week_number INT NOT NULL, end_date DATE NOT NULL,
day_of_week INT NOT NULL,
activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id), 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_periods_course_positive CHECK (course_number > 0),
CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0), CONSTRAINT chk_calendar_periods_dates CHECK (end_date >= start_date),
CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7), CONSTRAINT ex_calendar_periods_no_overlap
CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date) EXCLUDE USING gist (
calendar_id WITH =,
course_number WITH =,
(daterange(start_date, end_date, '[]')) WITH &&
)
); );
CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup CREATE INDEX IF NOT EXISTS idx_calendar_periods_schedule
ON academic_calendar_days(calendar_id, course_number, date); ON academic_calendar_periods(calendar_id, course_number, start_date, end_date);
CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number( CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number(
academic_year_start DATE, academic_year_start DATE,
@@ -750,20 +754,23 @@ AS $$
) + 1 ) + 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 SELECT
calendar.id, calendar.id,
course.course_number, course.course_number,
days.date::DATE, ay.start_date,
calculate_academic_calendar_week_number(ay.start_date, days.date::DATE), ay.end_date,
EXTRACT(ISODOW FROM days.date::DATE)::INT,
activity.id activity.id
FROM academic_calendars calendar FROM academic_calendars calendar
JOIN academic_years ay ON ay.id = calendar.academic_year_id JOIN academic_years ay ON ay.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'Т' 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(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;
CREATE TABLE IF NOT EXISTS academic_calendar_subjects ( CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -1134,7 +1141,7 @@ COMMENT ON TABLE academic_years IS 'Учебные годы';
COMMENT ON TABLE semesters IS 'Семестры учебного года'; COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars 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 academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году'; COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules 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.study_form_id IS 'ID формы обучения из education_forms';
COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике';
COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; COMMENT ON COLUMN academic_calendar_periods.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса'; COMMENT ON COLUMN academic_calendar_periods.course_number IS 'Номер курса';
COMMENT ON COLUMN academic_calendar_days.date IS 'Дата'; COMMENT ON COLUMN academic_calendar_periods.start_date IS 'Первая дата периода включительно';
COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году'; COMMENT ON COLUMN academic_calendar_periods.end_date IS 'Последняя дата периода включительно';
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO'; COMMENT ON COLUMN academic_calendar_periods.activity_type_id IS 'ID кода активности';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE) COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE)
IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье'; IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье';
@@ -1579,8 +1585,6 @@ BEGIN
END IF; END IF;
END $$; END $$;
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE time_slots ALTER TABLE time_slots
ADD CONSTRAINT chk_time_slots_duration_matches_range ADD CONSTRAINT chk_time_slots_duration_matches_range
CHECK ( CHECK (
@@ -1818,7 +1822,7 @@ CREATE TRIGGER trg_academic_years_include_semesters
DO $$ DO $$
DECLARE DECLARE
assignment_violations BIGINT; assignment_violations BIGINT;
calendar_day_violations BIGINT; calendar_period_violations BIGINT;
calendar_subject_violations BIGINT; calendar_subject_violations BIGINT;
BEGIN BEGIN
SELECT count(*) SELECT count(*)
@@ -1840,13 +1844,13 @@ BEGIN
END NOT BETWEEN 1 AND calendar.course_count; END NOT BETWEEN 1 AND calendar.course_count;
SELECT count(*) SELECT count(*)
INTO calendar_day_violations INTO calendar_period_violations
FROM academic_calendar_days calendar_day FROM academic_calendar_periods calendar_period
JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id JOIN academic_calendars calendar ON calendar.id = calendar_period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
WHERE calendar_day.course_number > calendar.course_count WHERE calendar_period.course_number > calendar.course_count
OR calendar_day.date < academic_year.start_date OR calendar_period.start_date < academic_year.start_date
OR calendar_day.date > academic_year.end_date; OR calendar_period.end_date > academic_year.end_date;
SELECT count(*) SELECT count(*)
INTO calendar_subject_violations INTO calendar_subject_violations
@@ -1855,14 +1859,14 @@ BEGIN
WHERE calendar_subject.semester_number > calendar.course_count * 2; WHERE calendar_subject.semester_number > calendar.course_count * 2;
IF assignment_violations > 0 IF assignment_violations > 0
OR calendar_day_violations > 0 OR calendar_period_violations > 0
OR calendar_subject_violations > 0 THEN OR calendar_subject_violations > 0 THEN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
assignment_violations, assignment_violations,
calendar_day_violations, calendar_period_violations,
calendar_subject_violations calendar_subject_violations
); );
END IF; END IF;
@@ -1990,12 +1994,12 @@ BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1 SELECT 1
FROM academic_calendar_days calendar_day FROM academic_calendar_periods calendar_period
WHERE calendar_day.calendar_id = OLD.id WHERE calendar_period.calendar_id = OLD.id
AND ( AND (
calendar_day.course_number > NEW.course_count calendar_period.course_number > NEW.course_count
OR calendar_day.date < academic_year_start OR calendar_period.start_date < academic_year_start
OR calendar_day.date > academic_year_end OR calendar_period.end_date > academic_year_end
) )
) THEN ) THEN
RAISE EXCEPTION USING RAISE EXCEPTION USING
@@ -2059,7 +2063,7 @@ CREATE TRIGGER trg_student_groups_protect_calendar_assignments
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION prevent_incompatible_student_group_change(); 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 RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
@@ -2078,22 +2082,22 @@ BEGIN
IF calendar_course_count IS NOT NULL IF calendar_course_count IS NOT NULL
AND ( AND (
NEW.course_number > calendar_course_count NEW.course_number > calendar_course_count
OR NEW.date < academic_year_start OR NEW.start_date < academic_year_start
OR NEW.date > academic_year_end OR NEW.end_date > academic_year_end
) THEN ) THEN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = 'Строка сетки выходит за измерения календарного графика'; MESSAGE = 'Период активности выходит за измерения календарного графика';
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
CREATE TRIGGER trg_calendar_days_dimensions CREATE TRIGGER trg_calendar_periods_dimensions
BEFORE INSERT OR UPDATE OF calendar_id, course_number, date BEFORE INSERT OR UPDATE OF calendar_id, course_number, start_date, end_date
ON academic_calendar_days ON academic_calendar_periods
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION enforce_calendar_day_dimensions(); EXECUTE FUNCTION enforce_calendar_period_dimensions();
CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions() CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions()
RETURNS TRIGGER RETURNS TRIGGER
@@ -2132,11 +2136,11 @@ BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1 SELECT 1
FROM academic_calendars calendar 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 WHERE calendar.academic_year_id = OLD.id
AND ( AND (
calendar_day.date < NEW.start_date calendar_period.start_date < NEW.start_date
OR calendar_day.date > NEW.end_date OR calendar_period.end_date > NEW.end_date
) )
) OR EXISTS ( ) OR EXISTS (
SELECT 1 SELECT 1

View File

@@ -1,12 +1,15 @@
package com.magistr.app.controller; package com.magistr.app.controller;
import com.magistr.app.dto.AcademicCalendarDto; import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar; 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.AcademicYear;
import com.magistr.app.model.EducationForm; import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality; import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; 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.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
@@ -20,6 +23,7 @@ import com.magistr.app.service.ScheduleGeneratorService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -60,7 +64,7 @@ class AcademicCalendarControllerTest {
AcademicCalendarController controller = new AcademicCalendarController( AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository, calendarRepository,
mock(AcademicCalendarDayRepository.class), mock(AcademicCalendarPeriodRepository.class),
academicYearRepository, academicYearRepository,
specialtiesRepository, specialtiesRepository,
profileRepository, profileRepository,
@@ -95,4 +99,75 @@ class AcademicCalendarControllerTest {
assertThat(((AcademicCalendarDto) response.getBody()).title()) assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027"); .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<AcademicCalendarGridDayDto> rows = (List<AcademicCalendarGridDayDto>) 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);
});
}
} }

View File

@@ -92,9 +92,14 @@ class AcademicStructureInvariantIntegrationTest {
"сохранённая сетка выходит за новые измерения" "сохранённая сетка выходит за новые измерения"
); );
assertSqlState( assertSqlState(
() -> insertOutOfRangeCalendarDay(connection, calendarId), () -> insertOutOfRangeCalendarPeriod(connection, calendarId),
"23514", "23514",
"Строка сетки выходит за измерения календарного графика" "Период активности выходит за измерения календарного графика"
);
assertSqlState(
() -> insertOverlappingCalendarPeriod(connection, calendarId),
"23P01",
"ex_calendar_periods_no_overlap"
); );
assertSqlState( assertSqlState(
() -> insertOutOfRangeCalendarSubject(connection, calendarId), () -> 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(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days ( INSERT INTO academic_calendar_periods (
calendar_id, calendar_id,
course_number, course_number,
date, start_date,
week_number, end_date,
day_of_week,
activity_type_id 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 = 'Т' 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 { private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id) INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)

View File

@@ -1,8 +1,8 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendarDay; import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.repository.AcademicCalendarDayRepository; import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils; import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -110,12 +110,13 @@ class AcademicCalendarGridServiceIntegrationTest {
assertThat(fingerprint(calendarId)).isEqualTo(before); assertThat(fingerprint(calendarId)).isEqualTo(before);
assertThat(queryLong(""" assertThat(queryLong("""
SELECT count(*) SELECT count(*)
FROM academic_calendar_days day FROM academic_calendar_periods period
WHERE day.calendar_id = ? WHERE period.calendar_id = ?
AND day.course_number = 1 AND period.course_number = 1
AND day.date = ? AND period.start_date <= ?
AND day.activity_type_id = ? AND period.end_date >= ?
""", calendarId, yearStart, holidayActivityId)).isZero(); AND period.activity_type_id = ?
""", calendarId, yearStart, yearStart, holidayActivityId)).isZero();
verify(scheduleGeneratorService, never()).clearCache(); verify(scheduleGeneratorService, never()).clearCache();
AcademicCalendarGridDayDto secondValid = row( AcademicCalendarGridDayDto secondValid = row(
@@ -131,15 +132,26 @@ class AcademicCalendarGridServiceIntegrationTest {
assertThat(after).isNotEqualTo(before); assertThat(after).isNotEqualTo(before);
assertThat(queryLong(""" assertThat(queryLong("""
SELECT count(*) SELECT count(*)
FROM academic_calendar_days day FROM academic_calendar_periods period
WHERE day.calendar_id = ? WHERE period.calendar_id = ?
AND ( AND (
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?) (
OR period.course_number = 1
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?) AND period.start_date = ?
AND period.end_date = ?
AND period.activity_type_id = ?
) )
""", calendarId, yearStart, holidayActivityId, OR
yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L); (
period.course_number = 1
AND period.start_date = ?
AND period.end_date = ?
AND period.activity_type_id = ?
)
)
""", calendarId,
yearStart, yearStart, holidayActivityId,
yearStart.plusDays(1), yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
verify(scheduleGeneratorService, times(1)).clearCache(); verify(scheduleGeneratorService, times(1)).clearCache();
} }
@@ -149,16 +161,15 @@ class AcademicCalendarGridServiceIntegrationTest {
count(*) AS row_count, count(*) AS row_count,
md5(string_agg( md5(string_agg(
concat_ws('|', concat_ws('|',
day.course_number::text, period.course_number::text,
day.date::text, period.start_date::text,
day.week_number::text, period.end_date::text,
day.day_of_week::text, period.activity_type_id::text
day.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 )) AS content_hash
FROM academic_calendar_days day FROM academic_calendar_periods period
WHERE day.calendar_id = ? WHERE period.calendar_id = ?
""", (result, rowNumber) -> new GridFingerprint( """, (result, rowNumber) -> new GridFingerprint(
result.getLong("row_count"), result.getLong("row_count"),
result.getString("content_hash") result.getString("content_hash")
@@ -196,8 +207,8 @@ class AcademicCalendarGridServiceIntegrationTest {
@SpringBootConfiguration @SpringBootConfiguration
@EnableAutoConfiguration @EnableAutoConfiguration
@EntityScan(basePackageClasses = AcademicCalendarDay.class) @EntityScan(basePackageClasses = AcademicCalendarPeriod.class)
@EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class) @EnableJpaRepositories(basePackageClasses = AcademicCalendarPeriodRepository.class)
@Import({AcademicCalendarGridService.class, TestDependencies.class}) @Import({AcademicCalendarGridService.class, TestDependencies.class})
static class TestApplication { static class TestApplication {
} }

View File

@@ -3,10 +3,10 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto; import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar; import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType; 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.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository; 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 com.magistr.app.repository.AcademicCalendarRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -41,7 +41,7 @@ class AcademicCalendarGridServiceTest {
@Mock @Mock
private AcademicCalendarRepository calendarRepository; private AcademicCalendarRepository calendarRepository;
@Mock @Mock
private AcademicCalendarDayRepository calendarDayRepository; private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock @Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository; private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock @Mock
@@ -73,7 +73,7 @@ class AcademicCalendarGridServiceTest {
service = new AcademicCalendarGridService( service = new AcademicCalendarGridService(
calendarRepository, calendarRepository,
calendarDayRepository, calendarPeriodRepository,
activityTypeRepository, activityTypeRepository,
scheduleGeneratorService scheduleGeneratorService
); );
@@ -180,17 +180,18 @@ class AcademicCalendarGridServiceTest {
service.replaceGrid(CALENDAR_ID, List.of(first, second)); service.replaceGrid(CALENDAR_ID, List.of(first, second));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService); InOrder mutationOrder = inOrder(calendarPeriodRepository, scheduleGeneratorService);
mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID); mutationOrder.verify(calendarPeriodRepository).deleteByCalendarId(CALENDAR_ID);
mutationOrder.verify(calendarDayRepository).flush(); mutationOrder.verify(calendarPeriodRepository).flush();
mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture()); mutationOrder.verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
mutationOrder.verify(calendarDayRepository).flush(); mutationOrder.verify(calendarPeriodRepository).flush();
mutationOrder.verify(scheduleGeneratorService).clearCache(); mutationOrder.verify(scheduleGeneratorService).clearCache();
assertThat(replacementCaptor.getValue()) assertThat(replacementCaptor.getValue()).singleElement().satisfies(period -> {
.extracting(AcademicCalendarDay::getDate) assertThat(period.getStartDate()).isEqualTo(YEAR_START);
.containsExactly(YEAR_START, YEAR_START.plusDays(1)); assertThat(period.getEndDate()).isEqualTo(YEAR_START.plusDays(1));
});
assertThat(replacementCaptor.getValue()) assertThat(replacementCaptor.getValue())
.allSatisfy(day -> { .allSatisfy(day -> {
assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID); assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID);
@@ -212,11 +213,22 @@ class AcademicCalendarGridServiceTest {
)); ));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture()); verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue()) assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber) .extracting(AcademicCalendarPeriod::getStartDate)
.containsExactly(1, 1, 2, 3); .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 @Test
@@ -233,11 +245,22 @@ class AcademicCalendarGridServiceTest {
)); ));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture()); verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue()) assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber) .extracting(AcademicCalendarPeriod::getStartDate)
.containsExactly(1, 1, 2, 3); .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) { private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
@@ -263,10 +286,10 @@ class AcademicCalendarGridServiceTest {
} }
private void verifyNoMutation() { private void verifyNoMutation() {
verify(calendarDayRepository, never()).deleteByCalendarId(any()); verify(calendarPeriodRepository, never()).deleteByCalendarId(any());
verify(calendarDayRepository, never()).flush(); verify(calendarPeriodRepository, never()).flush();
verify(calendarDayRepository, never()).save(any()); verify(calendarPeriodRepository, never()).save(any());
verify(calendarDayRepository, never()).saveAll(any()); verify(calendarPeriodRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache(); verify(scheduleGeneratorService, never()).clearCache();
} }
} }

View File

@@ -10,7 +10,7 @@ import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile; import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup; import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment; 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.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository; import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository; import com.magistr.app.repository.AcademicYearRepository;
@@ -54,7 +54,7 @@ class AcademicStructureServiceTest {
@Mock @Mock
private AcademicCalendarRepository calendarRepository; private AcademicCalendarRepository calendarRepository;
@Mock @Mock
private AcademicCalendarDayRepository calendarDayRepository; private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock @Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository; private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock @Mock
@@ -87,7 +87,7 @@ class AcademicStructureServiceTest {
void setUp() { void setUp() {
service = new AcademicStructureService( service = new AcademicStructureService(
calendarRepository, calendarRepository,
calendarDayRepository, calendarPeriodRepository,
calendarSubjectRepository, calendarSubjectRepository,
academicYearRepository, academicYearRepository,
specialtiesRepository, specialtiesRepository,
@@ -142,7 +142,7 @@ class AcademicStructureServiceTest {
@Test @Test
void rejectsCourseCountBelowExistingGrid() { void rejectsCourseCountBelowExistingGrid() {
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan( when(calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
CALENDAR_ID, 3)).thenReturn(true); CALENDAR_ID, 3)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar( assertThatThrownBy(() -> service.updateCalendar(

View File

@@ -32,7 +32,7 @@ class ScheduleGenerationQueryCountTest {
SemesterRepository semesterRepository = mock(SemesterRepository.class); SemesterRepository semesterRepository = mock(SemesterRepository.class);
StudentGroupCalendarAssignmentRepository assignmentRepository = StudentGroupCalendarAssignmentRepository assignmentRepository =
mock(StudentGroupCalendarAssignmentRepository.class); mock(StudentGroupCalendarAssignmentRepository.class);
AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class); AcademicCalendarPeriodRepository calendarPeriodRepository = mock(AcademicCalendarPeriodRepository.class);
ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class); ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class);
TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class); TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class);
TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class); TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class);
@@ -47,9 +47,9 @@ class ScheduleGenerationQueryCountTest {
List<StudentGroupCalendarAssignment> assignments = groups.stream() List<StudentGroupCalendarAssignment> assignments = groups.stream()
.map(group -> assignment(group, academicYear, calendar)) .map(group -> assignment(group, academicYear, calendar))
.toList(); .toList();
List<AcademicCalendarDay> calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1)) List<AcademicCalendarPeriod> calendarPeriods = List.of(
.map(date -> calendarDay(calendar, date)) calendarPeriod(calendar, START_DATE, END_DATE)
.toList(); );
List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT) List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT)
.mapToObj(index -> rule(index, semester, groups)) .mapToObj(index -> rule(index, semester, groups))
.toList(); .toList();
@@ -63,8 +63,12 @@ class ScheduleGenerationQueryCountTest {
.thenReturn(List.of(semester)); .thenReturn(List.of(semester));
when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection())) when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection()))
.thenReturn(assignments); .thenReturn(assignments);
when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE))) when(calendarPeriodRepository.findForScheduleBatch(
.thenReturn(calendarDays); anyCollection(),
any(),
eq(START_DATE),
eq(END_DATE)
)).thenReturn(calendarPeriods);
when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection())) when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()))
.thenReturn(rules); .thenReturn(rules);
when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of()); when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of());
@@ -74,7 +78,7 @@ class ScheduleGenerationQueryCountTest {
AcademicDateService academicDateService = new AcademicDateService( AcademicDateService academicDateService = new AcademicDateService(
semesterRepository, semesterRepository,
calendarDayRepository, calendarPeriodRepository,
assignmentRepository, assignmentRepository,
false false
); );
@@ -103,7 +107,12 @@ class ScheduleGenerationQueryCountTest {
verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE); verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE);
verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection()); 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(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection());
verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE); verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE);
verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY"); verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY");
@@ -112,7 +121,7 @@ class ScheduleGenerationQueryCountTest {
verify(semesterRepository, never()) verify(semesterRepository, never())
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any()); .findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any());
verify(assignmentRepository, never()).findForSchedule(any(), any()); verify(assignmentRepository, never()).findForSchedule(any(), any());
verify(calendarDayRepository, never()) verify(calendarPeriodRepository, never())
.findByCalendarIdAndCourseNumberAndDate(any(), any(), any()); .findByCalendarIdAndCourseNumberAndDate(any(), any(), any());
verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any()); verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any());
verify(dateAssignmentRepository, never()).findByDate(any()); verify(dateAssignmentRepository, never()).findByDate(any());
@@ -122,7 +131,7 @@ class ScheduleGenerationQueryCountTest {
int repositoryCalls = Stream.of( int repositoryCalls = Stream.of(
semesterRepository, semesterRepository,
assignmentRepository, assignmentRepository,
calendarDayRepository, calendarPeriodRepository,
ruleRepository, ruleRepository,
dateAssignmentRepository, dateAssignmentRepository,
scopeRepository, scopeRepository,
@@ -209,19 +218,22 @@ class ScheduleGenerationQueryCountTest {
return assignment; return assignment;
} }
private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) { private AcademicCalendarPeriod calendarPeriod(AcademicCalendar calendar,
LocalDate startDate,
LocalDate endDate) {
AcademicCalendarActivityType theory = new AcademicCalendarActivityType(); AcademicCalendarActivityType theory = new AcademicCalendarActivityType();
theory.setId(30L); theory.setId(30L);
theory.setCode("Т"); theory.setCode("Т");
theory.setName("Теоретическое обучение"); theory.setName("Теоретическое обучение");
theory.setAllowSchedule(true); theory.setAllowSchedule(true);
AcademicCalendarDay day = new AcademicCalendarDay(); AcademicCalendarPeriod period = new AcademicCalendarPeriod();
day.setAcademicCalendar(calendar); period.setAcademicCalendar(calendar);
day.setCourseNumber(1); period.setCourseNumber(1);
day.setDate(date); period.setStartDate(startDate);
day.setActivityType(theory); period.setEndDate(endDate);
return day; period.setActivityType(theory);
return period;
} }
private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) { private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) {

View File

@@ -73,6 +73,8 @@ services:
retries: 5 retries: 5
networks: networks:
- magistr - magistr
ports:
- "127.0.0.1:5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data

View File

@@ -2,7 +2,7 @@
## Назначение ## Назначение
Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения. Календарный учебный график представляет правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. В базе последовательные даты с одинаковой активностью хранятся одним периодом. Динамическое расписание использует график как фильтр: обычные пары выводятся только в дни теоретического обучения.
График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели. График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели.
@@ -18,7 +18,7 @@
- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. - `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей.
- `academic_calendars` хранит график профиля, формы обучения и учебного года. - `academic_calendars` хранит график профиля, формы обучения и учебного года.
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. - Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате. - `academic_calendar_periods` хранит непересекающиеся периоды активности по курсу с включительными границами дат.
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`. - `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
@@ -52,7 +52,7 @@
1. Определяется учебный год и семестр даты. 1. Определяется учебный год и семестр даты.
2. Для группы находится назначение графика на этот учебный год. 2. Для группы находится назначение графика на этот учебный год.
3. По году начала обучения группы вычисляется текущий курс. 3. По году начала обучения группы вычисляется текущий курс.
4. В `academic_calendar_days` ищется ячейка для курса и даты. 4. В `academic_calendar_periods` ищется период курса, содержащий дату.
5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`. 5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`.
6. Если код не разрешает пары, день пропускается и академические часы не списываются. 6. Если код не разрешает пары, день пропускается и академические часы не списываются.
@@ -74,6 +74,8 @@
- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. - Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке.
- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. - Дни вне учебного года или пустые периоды Excel хранятся кодом `=`.
- Дневной REST-контракт сохранён: при чтении backend разворачивает периоды в ячейки, а при полной замене объединяет соседние даты с одинаковой активностью в одну строку `academic_calendar_periods`.
- Отсутствие периода для даты означает отсутствие разрешённой активности; периоды одного курса и графика не могут пересекаться.
- График привязан к профилю обучения, а не только к коду специальности. - График привязан к профилю обучения, а не только к коду специальности.
- Форма обучения графика должна совпадать с формой обучения группы при назначении графика. - Форма обучения графика должна совпадать с формой обучения группы при назначении графика.
- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. - Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры.

View File

@@ -551,8 +551,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| `POST` | `/api/admin/academic-calendars` | Создать график | | `POST` | `/api/admin/academic-calendars` | Создать график |
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | | `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график | | `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика | | `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика, развёрнутая из периодов |
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки | | `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки с объединением периодов |
| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров | | `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
| `PUT` | `/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 блокирует график и повторно проверяет При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности, все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить `409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить
`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше `courseCount`, пока существуют строки старших курсов или дисциплины семестров выше
@@ -599,8 +599,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
`понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели `понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
`(courseNumber, date)` не должен повторяться, каждый `(courseNumber, date)` не должен повторяться, каждый
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка `activityTypeId` или `activityCode` должен существовать. После проверки соседние даты
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL. одного курса с одинаковой активностью объединяются в один период с включительными
границами. Разрыв хотя бы в один день или смена активности начинает новый период.
При `GET` сохранённые периоды разворачиваются обратно в тот же дневной JSON-контракт,
а `weekNumber` и `dayOfWeek` рассчитываются по дате. При любом `400` старая сетка остаётся
без изменений; `calendarId` из строки не переопределяет ID в URL.
**Привязка дисциплин к графику:** **Привязка дисциплин к графику:**
```json ```json

View File

@@ -317,10 +317,12 @@ pod не сохраняют два конфликтующих правила п
`AcademicCalendarController` делегирует полную замену дневной сетки `AcademicCalendarController` делегирует полную замену дневной сетки
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный `AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются Spring proxy: весь payload и все activity types проверяются до bulk delete, затем соседние
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает даты одного курса с одинаковой активностью сжимаются в периоды и выполняются
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется `delete → flush → saveAll → flush`. При чтении контроллер разворачивает интервалы обратно
только после успешного commit. в дневной REST-контракт и вычисляет номер недели и ISO-день. Исключение не перехватывается
внутри сервиса и вызывает rollback. Инвалидация кэша зарегистрирована через transaction
synchronization и выполняется только после успешного commit.
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет `ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`. `ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
@@ -339,7 +341,8 @@ lost update или проверке устаревшего снимка.
`endDate - startDate + 1` одинаково в query- и generator-слоях. `endDate - startDate + 1` одинаково в query- и generator-слоях.
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`. `ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор `AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона. назначений календарей и пересекающиеся периоды активности от начала затронутого семестра
до конца диапазона.
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup `ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует. одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.

View File

@@ -143,7 +143,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `specialty_profiles` | Профили обучения внутри специальности | | `specialty_profiles` | Профили обучения внутри специальности |
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года | | `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` | | `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
| `academic_calendar_days` | Дневная сетка по курсу и дате | | `academic_calendar_periods` | Непересекающиеся непрерывные периоды активности по курсу |
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров | | `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год | | `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
| `time_slots` | Настраиваемая сетка пар для тенанта | | `time_slots` | Настраиваемая сетка пар для тенанта |
@@ -165,12 +165,15 @@ Bearer-токен проверяется на backend. Frontend-скрытие
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк. поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
Полная замена `academic_calendar_days` выполняется через транзакционный Полная замена дневной сетки выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая `AcademicCalendarGridService`. Сервис сначала проверяет весь дневной payload, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню, уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор и разрешает все коды активностей. Затем соседние даты одного курса с одинаковой активностью
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш объединяются в один включительный период `academic_calendar_periods`. Только после этого
расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе прежние периоды удаляются и новый набор записывается одной транзакцией. Ошибка любой строки
сохраняет прежнюю сетку целиком, а кэш расписания очищается только после commit. База
дополнительно запрещает пересекающиеся периоды одного курса и графика через exclusion
constraint. При чтении API разворачивает периоды обратно в дневные ячейки. Недели дневной сетки выровнены по ISO-неделе
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него `понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
к неделе 2. к неделе 2.
@@ -189,10 +192,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах. 11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки календарей всех выбранных групп, пересекающиеся периоды активности, правила и эффективные
звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск сетки звонков загружаются batch-запросами. Периоды группируются по календарю и курсу, а
семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только поиск периода для даты выполняется бинарным поиском. Поиск семестра и подстановка времени
по lookup-картам. `ScheduleQueryService` передаёт все группы в один внутри циклов выполняются только по снимку запроса. `ScheduleQueryService` передаёт все группы в один
`buildScheduleForGroups()`, поэтому число запросов не растёт как `buildScheduleForGroups()`, поэтому число запросов не растёт как
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются. `дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.

View File

@@ -5,7 +5,7 @@
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL) - **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
- **Управление схемой:** Flyway (программный запуск) - **Управление схемой:** Flyway (программный запуск)
- **Hibernate DDL:** Отключён (`ddl-auto=none`) - **Hibernate DDL:** Отключён (`ddl-auto=none`)
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов) - **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraints временных слотов и календарных периодов)
- **Мультитенантность:** Каждый тенант = отдельная БД - **Мультитенантность:** Каждый тенант = отдельная БД
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant` - **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow` - **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
@@ -262,13 +262,12 @@ erDiagram
TIMESTAMPTZ updated_at TIMESTAMPTZ updated_at
} }
academic_calendar_days { academic_calendar_periods {
BIGSERIAL id PK BIGSERIAL id PK
BIGINT calendar_id FK BIGINT calendar_id FK
INT course_number INT course_number
DATE date DATE start_date
INT week_number DATE end_date
INT day_of_week
BIGINT activity_type_id FK BIGINT activity_type_id FK
} }
@@ -375,10 +374,10 @@ erDiagram
specialties ||--o{ academic_calendars : "specialty_id" specialties ||--o{ academic_calendars : "specialty_id"
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
education_forms ||--o{ academic_calendars : "study_form_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{ academic_calendar_subjects : "calendar_id"
academic_calendars ||--o{ student_group_calendar_assignments : "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_groups : "schedule_rule_id"
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id" schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_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` также запрещается, если в сетке группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
остаются строки старших курсов или дисциплины старших семестров. остаются строки старших курсов или дисциплины старших семестров.
#### `academic_calendar_days` — Дневная сетка календарного графика #### `academic_calendar_periods` — Периоды активности календарного графика
| Колонка | Тип | Описание | | Колонка | Тип | Описание |
|---------|-----|----------| |---------|-----|----------|
| `id` | BIGSERIAL PK | ID | | `id` | BIGSERIAL PK | ID |
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график | | `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
| `course_number` | INT | Номер курса | | `course_number` | INT | Номер курса |
| `date` | DATE | Дата учебного года | | `start_date` | DATE | Первая дата периода, включительно |
| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года | | `end_date` | DATE | Последняя дата периода, включительно |
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности | | `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал Триггер `trg_calendar_periods_dimensions` требует, чтобы `course_number` не превышал
`academic_calendars.course_count`, а дата находилась внутри учебного года графика. `academic_calendars.course_count`, обе границы находились внутри учебного года графика,
а `end_date` была не раньше `start_date`. Exclusion constraint
`ex_calendar_periods_no_overlap` запрещает пересечение периодов одного курса и графика.
Соседние даты с одинаковой активностью объединяет backend; номер недели и ISO-день
вычисляются при разворачивании периода в дневной REST-ответ и в таблице не дублируются.
#### `academic_calendar_subjects` — Дисциплины календарного графика #### `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 По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline
`V1`. Правильная нумерация недель календарного графика также входит непосредственно в V1. `V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика
также входят непосредственно в V1.
Перед применением этой редакции требуется полностью пустая tenant-схема. Перед применением этой редакции требуется полностью пустая tenant-схема.
### Полный сброс БД (локально) ### Полный сброс БД (локально)

View File

@@ -20,7 +20,7 @@
| `education_forms` | Общий справочник форм обучения для групп и календарных графиков | | `education_forms` | Общий справочник форм обучения для групп и календарных графиков |
| `academic_calendar_activity_types` | Коды активностей из Excel-графика | | `academic_calendar_activity_types` | Коды активностей из Excel-графика |
| `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | | `academic_calendars` | Календарные графики профиля, формы обучения и учебного года |
| `academic_calendar_days` | Дневная сетка графика по курсу и дате | | `academic_calendar_periods` | Непересекающиеся периоды активности графика по курсу |
| `student_group_calendar_assignments` | Назначения графиков группам по учебному году | | `student_group_calendar_assignments` | Назначения графиков группам по учебному году |
| `time_slots` | Настраиваемая сетка пар | | `time_slots` | Настраиваемая сетка пар |
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик | | `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
@@ -28,11 +28,11 @@
| `schedule_rule_slots` | Шаблонные слоты правила | | `schedule_rule_slots` | Шаблонные слоты правила |
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота | | `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке. Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневном редакторе, а в БД сохраняются непрерывными периодами.
## Backend ## Backend
Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам. Добавлены сущности и репозитории для профилей, календарей, кодов активностей, периодов активности и назначений группам. Дневной API сжимает соседние одинаковые ячейки при записи и разворачивает периоды при чтении.
Ключевые сервисы: Ключевые сервисы:

View File

@@ -188,7 +188,7 @@ frontend/
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `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`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.

View File

@@ -21,7 +21,7 @@
- `specialties` и `specialty_profiles` определяют образовательную программу группы. - `specialties` и `specialty_profiles` определяют образовательную программу группы.
- `academic_calendars` хранит график профиля и формы обучения. - `academic_calendars` хранит график профиля и формы обучения.
- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`. - `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`.
- `academic_calendar_days` хранит дневную сетку по курсу и дате. - `academic_calendar_periods` хранит непрерывные непересекающиеся периоды активности по курсу.
- `student_group_calendar_assignments` выбирает график для группы на учебный год. - `student_group_calendar_assignments` выбирает график для группы на учебный год.
### Слой правил расписания ### Слой правил расписания
@@ -47,4 +47,4 @@
## Административный сценарий ## Административный сценарий
Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу. Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. Backend объединяет соседние одинаковые дни в периоды, не меняя дневной интерфейс. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу.

View File

@@ -1,6 +1,6 @@
# Задачи: динамическое расписание и календарный учебный график # Задачи: динамическое расписание и календарный учебный график
Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год. Актуальная декомпозиция относится к модели, где календарный учебный график редактируется дневной сеткой, хранится непрерывными периодами активности и назначается группе на учебный год.
## Выполнено ## Выполнено
@@ -8,7 +8,7 @@
- [x] Добавлены специальности, профили обучения и связь группы с профилем. - [x] Добавлены специальности, профили обучения и связь группы с профилем.
- [x] Добавлены формы обучения календарного графика. - [x] Добавлены формы обучения календарного графика.
- [x] Добавлен справочник кодов активностей Excel-графика. - [x] Добавлен справочник кодов активностей Excel-графика.
- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам. - [x] Добавлены календарные графики, интервальное хранение дневной сетки и назначения графиков группам.
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику. - [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях. - [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик. - [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.

View File

@@ -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": { "nodes": {
"_gitea_workflows_docker_build_backend_image_pipeline": { "_gitea_workflows_docker_build_backend_image_pipeline": {
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
"label": "Pipeline backend-образа", "label": "Pipeline backend-образа",
"last": "2026-07-29T21:50:22.465033+00:00", "last": "2026-07-29T21:50:22.465033+00:00",
"provenance": [ "provenance": [
@@ -22,16 +22,22 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "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", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
}, },
"_gitea_workflows_docker_build_build_and_push_docker_images": { "_gitea_workflows_docker_build_build_and_push_docker_images": {
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
"label": "Workflow сборки и публикации Docker-образов", "label": "Workflow сборки и публикации Docker-образов",
"last": "2026-07-30T00:01:24.718235+00:00", "last": "2026-07-30T00:15:03.724071+00:00",
"neg": 1,
"provenance": [ "provenance": [
{
"date": "2026-07-30T00:15:03.724071+00:00",
"outcome": "corrected",
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
},
{ {
"date": "2026-07-30T00:01:24.718235+00:00", "date": "2026-07-30T00:01:24.718235+00:00",
"outcome": "useful", "outcome": "useful",
@@ -51,23 +57,25 @@
"date": "2026-07-29T21:27:26.637336+00:00", "date": "2026-07-29T21:27:26.637336+00:00",
"outcome": "useful", "outcome": "useful",
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" "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", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "contested",
"uses": 8 "uses": 8,
"verdict": "useful"
}, },
"_gitea_workflows_docker_build_deploy_to_k8s": { "_gitea_workflows_docker_build_deploy_to_k8s": {
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
"label": "Job деплоя в Kubernetes", "label": "Job деплоя в Kubernetes",
"last": "2026-07-30T00:01:24.718235+00:00", "last": "2026-07-30T00:15:03.724071+00:00",
"neg": 1,
"provenance": [ "provenance": [
{
"date": "2026-07-30T00:15:03.724071+00:00",
"outcome": "corrected",
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
},
{ {
"date": "2026-07-30T00:01:24.718235+00:00", "date": "2026-07-30T00:01:24.718235+00:00",
"outcome": "useful", "outcome": "useful",
@@ -79,13 +87,14 @@
"q": "сам можешь выполнить эти действия?" "q": "сам можешь выполнить эти действия?"
} }
], ],
"score": 1.996058514, "score": 0.972977818,
"source_file": ".gitea/workflows/docker-build.yaml", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "contested",
"uses": 2 "uses": 2,
"verdict": "useful"
}, },
"_gitea_workflows_docker_build_frontend_image_pipeline": { "_gitea_workflows_docker_build_frontend_image_pipeline": {
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd", "code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
"label": "Pipeline frontend-образа", "label": "Pipeline frontend-образа",
"last": "2026-07-29T22:22:22.155742+00:00", "last": "2026-07-29T22:22:22.155742+00:00",
"provenance": [ "provenance": [
@@ -105,7 +114,7 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "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", "source_file": ".gitea/workflows/docker-build.yaml",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -131,7 +140,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 1.85377087, "score": 1.810960536,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -147,7 +156,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.658614913, "score": 0.643405092,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -163,7 +172,7 @@
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?" "q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
} }
], ],
"score": 0.554143065, "score": 0.541345879,
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java", "source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -179,7 +188,7 @@
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?" "q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 0.67398734, "score": 0.658422513,
"source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java", "source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -195,7 +204,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "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", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -211,7 +220,7 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 0.673873064, "score": 0.658310876,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -237,7 +246,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "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", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
@@ -263,11 +272,27 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 2.006475318, "score": 1.960138481,
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java", "source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
"status": "preferred", "status": "preferred",
"uses": 3 "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": { "backend_src_main_java_com_magistr_app_controller_classroomcontroller": {
"code_fingerprint": "a768ef91dfe89f82197f53d85b348187a340c3dc8043bf009fa2f08418fabcba", "code_fingerprint": "a768ef91dfe89f82197f53d85b348187a340c3dc8043bf009fa2f08418fabcba",
"label": "ClassroomController.java", "label": "ClassroomController.java",
@@ -279,7 +304,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?" "q": "Почему в выборе аудитории показаны корпус и этаж?"
} }
], ],
"score": 0.828570992, "score": 0.809436264,
"source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -295,7 +320,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.658614913, "score": 0.643405092,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -316,7 +341,7 @@
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?" "q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
} }
], ],
"score": 1.347860405, "score": 1.316733389,
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -332,7 +357,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "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", "source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -353,7 +378,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 1.48642084, "score": 1.452093958,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -369,7 +394,7 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 0.669157537, "score": 0.653704248,
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -390,7 +415,7 @@
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'" "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", "source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -398,18 +423,28 @@
"backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": { "backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": {
"code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d", "code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d",
"label": "AcademicCalendar", "label": "AcademicCalendar",
"last": "2026-07-22T21:09:57.015227+00:00", "last": "2026-07-31T00:15:22.545259+00:00",
"provenance": [ "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", "date": "2026-07-22T21:09:57.015227+00:00",
"outcome": "useful", "outcome": "useful",
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 0.848212095, "score": 2.827859657,
"source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java", "source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java",
"status": "tentative", "status": "preferred",
"uses": 1 "uses": 3
}, },
"backend_src_main_java_com_magistr_app_model_classroom_classroom": { "backend_src_main_java_com_magistr_app_model_classroom_classroom": {
"code_fingerprint": "5614d83b8b7e20fd2b76271b28a8ef11ffc137808ec2d7ed7cd23e42e0ab93ec", "code_fingerprint": "5614d83b8b7e20fd2b76271b28a8ef11ffc137808ec2d7ed7cd23e42e0ab93ec",
@@ -422,7 +457,7 @@
"q": "Почему в выборе аудитории показаны корпус и этаж?" "q": "Почему в выборе аудитории показаны корпус и этаж?"
} }
], ],
"score": 0.828570992, "score": 0.809436264,
"source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java", "source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -438,7 +473,7 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 0.848212095, "score": 0.828623782,
"source_file": "backend/src/main/java/com/magistr/app/model/Department.java", "source_file": "backend/src/main/java/com/magistr/app/model/Department.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -459,7 +494,7 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 1.228405956, "score": 1.200037579,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -475,7 +510,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.67364438, "score": 0.658087473,
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java", "source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -496,7 +531,7 @@
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра" "q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
} }
], ],
"score": 1.676018021, "score": 1.637312649,
"source_file": "backend/src/main/java/com/magistr/app/model/Semester.java", "source_file": "backend/src/main/java/com/magistr/app/model/Semester.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -512,11 +547,32 @@
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу" "q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
} }
], ],
"score": 0.848212095, "score": 0.828623782,
"source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java", "source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java",
"status": "tentative", "status": "tentative",
"uses": 1 "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": { "backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": {
"code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10", "code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10",
"label": "SemesterRepository", "label": "SemesterRepository",
@@ -528,7 +584,7 @@
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?" "q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
} }
], ],
"score": 0.67364438, "score": 0.658087473,
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java", "source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -549,7 +605,7 @@
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?" "q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
} }
], ],
"score": 1.496963464, "score": 1.462393114,
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java", "source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
"status": "preferred", "status": "preferred",
"uses": 2 "uses": 2
@@ -565,7 +621,7 @@
"q": "Корректны ли inferred-связи вокруг ScheduleRule?" "q": "Корректны ли inferred-связи вокруг ScheduleRule?"
} }
], ],
"score": 0.554761576, "score": 0.541950106,
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java", "source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -581,7 +637,7 @@
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?" "q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
} }
], ],
"score": 0.658614913, "score": 0.643405092,
"source_file": "compose.yaml", "source_file": "compose.yaml",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -597,13 +653,13 @@
"q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше" "q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше"
} }
], ],
"score": 0.995980694, "score": 0.972979865,
"source_file": "compose.yaml", "source_file": "compose.yaml",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"docs_database_flyway_migrations": { "docs_database_flyway_migrations": {
"code_fingerprint": "2f5d4ce61ba0f5d8a9141f4ac33375dfc26a68e3bdccdc4269cf2a4f45a50176", "code_fingerprint": "2b50799351b008ee1b60bba24a352e8cb6c0faf710e5af8ed780a5c2d3a1fc87",
"label": "Flyway-миграции", "label": "Flyway-миграции",
"last": "2026-07-29T19:26:44.188240+00:00", "last": "2026-07-29T19:26:44.188240+00:00",
"provenance": [ "provenance": [
@@ -613,13 +669,13 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 0.995464026, "score": 0.972475129,
"source_file": "docs/DATABASE.md", "source_file": "docs/DATABASE.md",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
}, },
"docs_infrastructure_gitea_actions_cicd": { "docs_infrastructure_gitea_actions_cicd": {
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "CI/CD Gitea Actions в инфраструктуре", "label": "CI/CD Gitea Actions в инфраструктуре",
"last": "2026-07-29T22:22:22.155742+00:00", "last": "2026-07-29T22:22:22.155742+00:00",
"provenance": [ "provenance": [
@@ -644,13 +700,13 @@
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)." "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", "source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred", "status": "preferred",
"uses": 4 "uses": 4
}, },
"docs_infrastructure_jwt_secret_policy": { "docs_infrastructure_jwt_secret_policy": {
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "Политика JWT-секрета", "label": "Политика JWT-секрета",
"last": "2026-07-30T00:01:24.718235+00:00", "last": "2026-07-30T00:01:24.718235+00:00",
"provenance": [ "provenance": [
@@ -670,16 +726,22 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 2.99152254, "score": 2.922437367,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred", "status": "preferred",
"uses": 3 "uses": 3
}, },
"docs_infrastructure_kubernetes_production": { "docs_infrastructure_kubernetes_production": {
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "Production-инфраструктура Kubernetes", "label": "Production-инфраструктура Kubernetes",
"last": "2026-07-30T00:01:24.718235+00:00", "last": "2026-07-30T00:15:03.724071+00:00",
"neg": 1,
"provenance": [ "provenance": [
{
"date": "2026-07-30T00:15:03.724071+00:00",
"outcome": "corrected",
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
},
{ {
"date": "2026-07-30T00:01:24.718235+00:00", "date": "2026-07-30T00:01:24.718235+00:00",
"outcome": "useful", "outcome": "useful",
@@ -701,13 +763,14 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 3.987503234, "score": 2.918432812,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "preferred", "status": "contested",
"uses": 4 "uses": 4,
"verdict": "useful"
}, },
"docs_infrastructure_tenants_configmap": { "docs_infrastructure_tenants_configmap": {
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2", "code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
"label": "Tenants ConfigMap", "label": "Tenants ConfigMap",
"last": "2026-07-29T19:26:44.188240+00:00", "last": "2026-07-29T19:26:44.188240+00:00",
"provenance": [ "provenance": [
@@ -717,7 +780,7 @@
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?" "q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
} }
], ],
"score": 0.995464026, "score": 0.972475129,
"source_file": "docs/INFRASTRUCTURE.md", "source_file": "docs/INFRASTRUCTURE.md",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1
@@ -733,7 +796,7 @@
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1" "q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
} }
], ],
"score": 0.997393869, "score": 0.974360405,
"source_file": "", "source_file": "",
"status": "tentative", "status": "tentative",
"uses": 1 "uses": 1

View File

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

View File

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

View File

@@ -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 инфраструктура

View File

@@ -1,24 +1,23 @@
# Lessons # 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 ## Summary
- 19 useful · 1 dead ends · 0 corrected · 0 unmarked - 21 useful · 1 dead ends · 1 corrected · 0 unmarked
## Lessons ## Lessons
**Preferred sources** — corroborated by ≥2 useful results; start here. **Preferred sources** — corroborated by ≥2 useful results; start here.
- `Workflow сборки и публикации Docker-образов` (8× useful) - `ScheduleGeneratorService` (5× useful)
- `CI/CD Gitea Actions в инфраструктуре` (4× useful) - `CI/CD Gitea Actions в инфраструктуре` (4× useful)
- `Production-инфраструктура Kubernetes` (4× useful)
- `Pipeline frontend-образа` (3× useful) - `Pipeline frontend-образа` (3× useful)
- `Pipeline backend-образа` (3× useful) - `Pipeline backend-образа` (3× useful)
- `Политика JWT-секрета` (3× useful) - `Политика JWT-секрета` (3× useful)
- `ScheduleGeneratorService` (3× useful) - `AcademicCalendar` (3× useful)
- `AcademicCalendarDayRepository` (2× useful)
- `TenantRoutingDataSource` (3× useful) - `TenantRoutingDataSource` (3× useful)
- `Job деплоя в Kubernetes` (2× useful)
- `TenantConfigWatcher` (3× useful) - `TenantConfigWatcher` (3× useful)
- `AuthorizationInterceptor` (3× useful) - `AuthorizationInterceptor` (3× useful)
- `Semester` (2× 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. **Tentative** — useful in fewer than 2 results; verify before relying.
- `AcademicCalendarController` (1× useful)
- `AcademicDateService` (1× useful)
- `SpringBootApplication` (1× useful) - `SpringBootApplication` (1× useful)
- `PostgreSQL-сервис Docker Compose` (1× useful) - `PostgreSQL-сервис Docker Compose` (1× useful)
- `Flyway-миграции` (1× useful) - `Flyway-миграции` (1× useful)
- `Tenants ConfigMap` (1× useful) - `Tenants ConfigMap` (1× useful)
- `AcademicCalendar` (1× useful)
- `Department` (1× useful) - `Department` (1× useful)
- `StudentGroup` (1× useful) - `StudentGroup` (1× useful)
- `Classroom` (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) - `ScheduleRuleTest` (1× useful)
- `RequireRoles` (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. **Known dead ends** — led nowhere; don't re-derive.
- "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?" — `Override` - "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?" — `Override`
**Corrections** — do these differently.
- "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." → Kubernetes manifests остаются вне репозитория; CI автоматически выполняет только image digest rollout после push в main.