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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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