исправление хранения календаря
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", "Такой слот правила расписания уже существует"),
|
||||
|
||||
@@ -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() {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarDto;
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarActivityType;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.model.EducationForm;
|
||||
import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
@@ -20,6 +23,7 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -60,7 +64,7 @@ class AcademicCalendarControllerTest {
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
mock(AcademicCalendarDayRepository.class),
|
||||
mock(AcademicCalendarPeriodRepository.class),
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
profileRepository,
|
||||
@@ -95,4 +99,75 @@ class AcademicCalendarControllerTest {
|
||||
assertThat(((AcademicCalendarDto) response.getBody()).title())
|
||||
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGridExpandsPeriodIntoDailyRows() {
|
||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||
AcademicCalendarPeriodRepository periodRepository = mock(AcademicCalendarPeriodRepository.class);
|
||||
|
||||
AcademicYear year = new AcademicYear();
|
||||
year.setId(1L);
|
||||
year.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
year.setEndDate(LocalDate.of(2027, 6, 30));
|
||||
|
||||
AcademicCalendar calendar = new AcademicCalendar();
|
||||
calendar.setId(10L);
|
||||
calendar.setAcademicYear(year);
|
||||
|
||||
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
|
||||
activityType.setId(20L);
|
||||
activityType.setCode("Т");
|
||||
activityType.setName("Теоретическое обучение");
|
||||
activityType.setAllowSchedule(true);
|
||||
|
||||
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
|
||||
period.setId(30L);
|
||||
period.setAcademicCalendar(calendar);
|
||||
period.setCourseNumber(1);
|
||||
period.setStartDate(LocalDate.of(2026, 9, 6));
|
||||
period.setEndDate(LocalDate.of(2026, 9, 8));
|
||||
period.setActivityType(activityType);
|
||||
|
||||
when(calendarRepository.existsById(10L)).thenReturn(true);
|
||||
when(periodRepository.findByCalendarIdWithActivity(10L)).thenReturn(List.of(period));
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
periodRepository,
|
||||
mock(AcademicYearRepository.class),
|
||||
mock(SpecialtiesRepository.class),
|
||||
mock(SpecialtyProfileRepository.class),
|
||||
mock(EducationFormRepository.class),
|
||||
mock(SubjectRepository.class),
|
||||
mock(AcademicCalendarSubjectRepository.class),
|
||||
mock(AcademicCalendarGridService.class),
|
||||
mock(ScheduleGeneratorService.class),
|
||||
mock(AcademicStructureService.class)
|
||||
);
|
||||
|
||||
var response = controller.getGrid(10L);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isInstanceOf(List.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,14 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
"сохранённая сетка выходит за новые измерения"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertOutOfRangeCalendarDay(connection, calendarId),
|
||||
() -> insertOutOfRangeCalendarPeriod(connection, calendarId),
|
||||
"23514",
|
||||
"Строка сетки выходит за измерения календарного графика"
|
||||
"Период активности выходит за измерения календарного графика"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertOverlappingCalendarPeriod(connection, calendarId),
|
||||
"23P01",
|
||||
"ex_calendar_periods_no_overlap"
|
||||
);
|
||||
assertSqlState(
|
||||
() -> insertOutOfRangeCalendarSubject(connection, calendarId),
|
||||
@@ -479,16 +484,15 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
|
||||
private void insertOutOfRangeCalendarPeriod(Connection connection, long calendarId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_calendar_days (
|
||||
INSERT INTO academic_calendar_periods (
|
||||
calendar_id,
|
||||
course_number,
|
||||
date,
|
||||
week_number,
|
||||
day_of_week,
|
||||
start_date,
|
||||
end_date,
|
||||
activity_type_id
|
||||
) VALUES (?, 5, DATE '2025-09-01', 1, 1, (
|
||||
) VALUES (?, 5, DATE '2025-09-01', DATE '2025-09-01', (
|
||||
SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'
|
||||
))
|
||||
""")) {
|
||||
@@ -497,6 +501,23 @@ class AcademicStructureInvariantIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOverlappingCalendarPeriod(Connection connection, long calendarId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_calendar_periods (
|
||||
calendar_id,
|
||||
course_number,
|
||||
start_date,
|
||||
end_date,
|
||||
activity_type_id
|
||||
) VALUES (?, 1, DATE '2025-09-15', DATE '2025-09-30', (
|
||||
SELECT id FROM academic_calendar_activity_types WHERE code = 'К'
|
||||
))
|
||||
""")) {
|
||||
statement.setLong(1, calendarId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendarDay;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -110,12 +110,13 @@ class AcademicCalendarGridServiceIntegrationTest {
|
||||
assertThat(fingerprint(calendarId)).isEqualTo(before);
|
||||
assertThat(queryLong("""
|
||||
SELECT count(*)
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
AND day.course_number = 1
|
||||
AND day.date = ?
|
||||
AND day.activity_type_id = ?
|
||||
""", calendarId, yearStart, holidayActivityId)).isZero();
|
||||
FROM academic_calendar_periods period
|
||||
WHERE period.calendar_id = ?
|
||||
AND period.course_number = 1
|
||||
AND period.start_date <= ?
|
||||
AND period.end_date >= ?
|
||||
AND period.activity_type_id = ?
|
||||
""", calendarId, yearStart, yearStart, holidayActivityId)).isZero();
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
|
||||
AcademicCalendarGridDayDto secondValid = row(
|
||||
@@ -131,15 +132,26 @@ class AcademicCalendarGridServiceIntegrationTest {
|
||||
assertThat(after).isNotEqualTo(before);
|
||||
assertThat(queryLong("""
|
||||
SELECT count(*)
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
FROM academic_calendar_periods period
|
||||
WHERE period.calendar_id = ?
|
||||
AND (
|
||||
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
|
||||
(
|
||||
period.course_number = 1
|
||||
AND period.start_date = ?
|
||||
AND period.end_date = ?
|
||||
AND period.activity_type_id = ?
|
||||
)
|
||||
OR
|
||||
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
|
||||
(
|
||||
period.course_number = 1
|
||||
AND period.start_date = ?
|
||||
AND period.end_date = ?
|
||||
AND period.activity_type_id = ?
|
||||
)
|
||||
)
|
||||
""", calendarId, yearStart, holidayActivityId,
|
||||
yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
|
||||
""", calendarId,
|
||||
yearStart, yearStart, holidayActivityId,
|
||||
yearStart.plusDays(1), yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
|
||||
verify(scheduleGeneratorService, times(1)).clearCache();
|
||||
}
|
||||
|
||||
@@ -149,16 +161,15 @@ class AcademicCalendarGridServiceIntegrationTest {
|
||||
count(*) AS row_count,
|
||||
md5(string_agg(
|
||||
concat_ws('|',
|
||||
day.course_number::text,
|
||||
day.date::text,
|
||||
day.week_number::text,
|
||||
day.day_of_week::text,
|
||||
day.activity_type_id::text
|
||||
period.course_number::text,
|
||||
period.start_date::text,
|
||||
period.end_date::text,
|
||||
period.activity_type_id::text
|
||||
),
|
||||
E'\\n' ORDER BY day.course_number, day.date, day.id
|
||||
E'\\n' ORDER BY period.course_number, period.start_date, period.id
|
||||
)) AS content_hash
|
||||
FROM academic_calendar_days day
|
||||
WHERE day.calendar_id = ?
|
||||
FROM academic_calendar_periods period
|
||||
WHERE period.calendar_id = ?
|
||||
""", (result, rowNumber) -> new GridFingerprint(
|
||||
result.getLong("row_count"),
|
||||
result.getString("content_hash")
|
||||
@@ -196,8 +207,8 @@ class AcademicCalendarGridServiceIntegrationTest {
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@EntityScan(basePackageClasses = AcademicCalendarDay.class)
|
||||
@EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class)
|
||||
@EntityScan(basePackageClasses = AcademicCalendarPeriod.class)
|
||||
@EnableJpaRepositories(basePackageClasses = AcademicCalendarPeriodRepository.class)
|
||||
@Import({AcademicCalendarGridService.class, TestDependencies.class})
|
||||
static class TestApplication {
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.magistr.app.service;
|
||||
import com.magistr.app.dto.AcademicCalendarGridDayDto;
|
||||
import com.magistr.app.model.AcademicCalendar;
|
||||
import com.magistr.app.model.AcademicCalendarActivityType;
|
||||
import com.magistr.app.model.AcademicCalendarDay;
|
||||
import com.magistr.app.model.AcademicCalendarPeriod;
|
||||
import com.magistr.app.model.AcademicYear;
|
||||
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,7 +41,7 @@ class AcademicCalendarGridServiceTest {
|
||||
@Mock
|
||||
private AcademicCalendarRepository calendarRepository;
|
||||
@Mock
|
||||
private AcademicCalendarDayRepository calendarDayRepository;
|
||||
private AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
@Mock
|
||||
private AcademicCalendarActivityTypeRepository activityTypeRepository;
|
||||
@Mock
|
||||
@@ -73,7 +73,7 @@ class AcademicCalendarGridServiceTest {
|
||||
|
||||
service = new AcademicCalendarGridService(
|
||||
calendarRepository,
|
||||
calendarDayRepository,
|
||||
calendarPeriodRepository,
|
||||
activityTypeRepository,
|
||||
scheduleGeneratorService
|
||||
);
|
||||
@@ -180,17 +180,18 @@ class AcademicCalendarGridServiceTest {
|
||||
service.replaceGrid(CALENDAR_ID, List.of(first, second));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService);
|
||||
mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID);
|
||||
mutationOrder.verify(calendarDayRepository).flush();
|
||||
mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||
mutationOrder.verify(calendarDayRepository).flush();
|
||||
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
InOrder mutationOrder = inOrder(calendarPeriodRepository, scheduleGeneratorService);
|
||||
mutationOrder.verify(calendarPeriodRepository).deleteByCalendarId(CALENDAR_ID);
|
||||
mutationOrder.verify(calendarPeriodRepository).flush();
|
||||
mutationOrder.verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
|
||||
mutationOrder.verify(calendarPeriodRepository).flush();
|
||||
mutationOrder.verify(scheduleGeneratorService).clearCache();
|
||||
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarDay::getDate)
|
||||
.containsExactly(YEAR_START, YEAR_START.plusDays(1));
|
||||
assertThat(replacementCaptor.getValue()).singleElement().satisfies(period -> {
|
||||
assertThat(period.getStartDate()).isEqualTo(YEAR_START);
|
||||
assertThat(period.getEndDate()).isEqualTo(YEAR_START.plusDays(1));
|
||||
});
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.allSatisfy(day -> {
|
||||
assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID);
|
||||
@@ -212,11 +213,22 @@ class AcademicCalendarGridServiceTest {
|
||||
));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarDay::getWeekNumber)
|
||||
.containsExactly(1, 1, 2, 3);
|
||||
.extracting(AcademicCalendarPeriod::getStartDate)
|
||||
.containsExactly(
|
||||
yearStart,
|
||||
LocalDate.of(2026, 9, 6),
|
||||
LocalDate.of(2026, 9, 14)
|
||||
);
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarPeriod::getEndDate)
|
||||
.containsExactly(
|
||||
yearStart,
|
||||
LocalDate.of(2026, 9, 7),
|
||||
LocalDate.of(2026, 9, 14)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -233,11 +245,22 @@ class AcademicCalendarGridServiceTest {
|
||||
));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
|
||||
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarDay::getWeekNumber)
|
||||
.containsExactly(1, 1, 2, 3);
|
||||
.extracting(AcademicCalendarPeriod::getStartDate)
|
||||
.containsExactly(
|
||||
yearStart,
|
||||
LocalDate.of(2027, 9, 5),
|
||||
LocalDate.of(2027, 9, 13)
|
||||
);
|
||||
assertThat(replacementCaptor.getValue())
|
||||
.extracting(AcademicCalendarPeriod::getEndDate)
|
||||
.containsExactly(
|
||||
yearStart,
|
||||
LocalDate.of(2027, 9, 6),
|
||||
LocalDate.of(2027, 9, 13)
|
||||
);
|
||||
}
|
||||
|
||||
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
|
||||
@@ -263,10 +286,10 @@ class AcademicCalendarGridServiceTest {
|
||||
}
|
||||
|
||||
private void verifyNoMutation() {
|
||||
verify(calendarDayRepository, never()).deleteByCalendarId(any());
|
||||
verify(calendarDayRepository, never()).flush();
|
||||
verify(calendarDayRepository, never()).save(any());
|
||||
verify(calendarDayRepository, never()).saveAll(any());
|
||||
verify(calendarPeriodRepository, never()).deleteByCalendarId(any());
|
||||
verify(calendarPeriodRepository, never()).flush();
|
||||
verify(calendarPeriodRepository, never()).save(any());
|
||||
verify(calendarPeriodRepository, never()).saveAll(any());
|
||||
verify(scheduleGeneratorService, never()).clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.magistr.app.model.Speciality;
|
||||
import com.magistr.app.model.SpecialtyProfile;
|
||||
import com.magistr.app.model.StudentGroup;
|
||||
import com.magistr.app.model.StudentGroupCalendarAssignment;
|
||||
import com.magistr.app.repository.AcademicCalendarDayRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
@@ -54,7 +54,7 @@ class AcademicStructureServiceTest {
|
||||
@Mock
|
||||
private AcademicCalendarRepository calendarRepository;
|
||||
@Mock
|
||||
private AcademicCalendarDayRepository calendarDayRepository;
|
||||
private AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
@Mock
|
||||
private AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
@Mock
|
||||
@@ -87,7 +87,7 @@ class AcademicStructureServiceTest {
|
||||
void setUp() {
|
||||
service = new AcademicStructureService(
|
||||
calendarRepository,
|
||||
calendarDayRepository,
|
||||
calendarPeriodRepository,
|
||||
calendarSubjectRepository,
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
@@ -142,7 +142,7 @@ class AcademicStructureServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsCourseCountBelowExistingGrid() {
|
||||
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
when(calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
CALENDAR_ID, 3)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalendar(
|
||||
|
||||
@@ -32,7 +32,7 @@ class ScheduleGenerationQueryCountTest {
|
||||
SemesterRepository semesterRepository = mock(SemesterRepository.class);
|
||||
StudentGroupCalendarAssignmentRepository assignmentRepository =
|
||||
mock(StudentGroupCalendarAssignmentRepository.class);
|
||||
AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class);
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository = mock(AcademicCalendarPeriodRepository.class);
|
||||
ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class);
|
||||
TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class);
|
||||
TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class);
|
||||
@@ -47,9 +47,9 @@ class ScheduleGenerationQueryCountTest {
|
||||
List<StudentGroupCalendarAssignment> assignments = groups.stream()
|
||||
.map(group -> assignment(group, academicYear, calendar))
|
||||
.toList();
|
||||
List<AcademicCalendarDay> calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1))
|
||||
.map(date -> calendarDay(calendar, date))
|
||||
.toList();
|
||||
List<AcademicCalendarPeriod> calendarPeriods = List.of(
|
||||
calendarPeriod(calendar, START_DATE, END_DATE)
|
||||
);
|
||||
List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT)
|
||||
.mapToObj(index -> rule(index, semester, groups))
|
||||
.toList();
|
||||
@@ -63,8 +63,12 @@ class ScheduleGenerationQueryCountTest {
|
||||
.thenReturn(List.of(semester));
|
||||
when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection()))
|
||||
.thenReturn(assignments);
|
||||
when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)))
|
||||
.thenReturn(calendarDays);
|
||||
when(calendarPeriodRepository.findForScheduleBatch(
|
||||
anyCollection(),
|
||||
any(),
|
||||
eq(START_DATE),
|
||||
eq(END_DATE)
|
||||
)).thenReturn(calendarPeriods);
|
||||
when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()))
|
||||
.thenReturn(rules);
|
||||
when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of());
|
||||
@@ -74,7 +78,7 @@ class ScheduleGenerationQueryCountTest {
|
||||
|
||||
AcademicDateService academicDateService = new AcademicDateService(
|
||||
semesterRepository,
|
||||
calendarDayRepository,
|
||||
calendarPeriodRepository,
|
||||
assignmentRepository,
|
||||
false
|
||||
);
|
||||
@@ -103,7 +107,12 @@ class ScheduleGenerationQueryCountTest {
|
||||
|
||||
verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE);
|
||||
verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection());
|
||||
verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE));
|
||||
verify(calendarPeriodRepository).findForScheduleBatch(
|
||||
anyCollection(),
|
||||
any(),
|
||||
eq(START_DATE),
|
||||
eq(END_DATE)
|
||||
);
|
||||
verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection());
|
||||
verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE);
|
||||
verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY");
|
||||
@@ -112,7 +121,7 @@ class ScheduleGenerationQueryCountTest {
|
||||
verify(semesterRepository, never())
|
||||
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any());
|
||||
verify(assignmentRepository, never()).findForSchedule(any(), any());
|
||||
verify(calendarDayRepository, never())
|
||||
verify(calendarPeriodRepository, never())
|
||||
.findByCalendarIdAndCourseNumberAndDate(any(), any(), any());
|
||||
verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any());
|
||||
verify(dateAssignmentRepository, never()).findByDate(any());
|
||||
@@ -122,7 +131,7 @@ class ScheduleGenerationQueryCountTest {
|
||||
int repositoryCalls = Stream.of(
|
||||
semesterRepository,
|
||||
assignmentRepository,
|
||||
calendarDayRepository,
|
||||
calendarPeriodRepository,
|
||||
ruleRepository,
|
||||
dateAssignmentRepository,
|
||||
scopeRepository,
|
||||
@@ -209,19 +218,22 @@ class ScheduleGenerationQueryCountTest {
|
||||
return assignment;
|
||||
}
|
||||
|
||||
private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) {
|
||||
private AcademicCalendarPeriod calendarPeriod(AcademicCalendar calendar,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
AcademicCalendarActivityType theory = new AcademicCalendarActivityType();
|
||||
theory.setId(30L);
|
||||
theory.setCode("Т");
|
||||
theory.setName("Теоретическое обучение");
|
||||
theory.setAllowSchedule(true);
|
||||
|
||||
AcademicCalendarDay day = new AcademicCalendarDay();
|
||||
day.setAcademicCalendar(calendar);
|
||||
day.setCourseNumber(1);
|
||||
day.setDate(date);
|
||||
day.setActivityType(theory);
|
||||
return day;
|
||||
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
|
||||
period.setAcademicCalendar(calendar);
|
||||
period.setCourseNumber(1);
|
||||
period.setStartDate(startDate);
|
||||
period.setEndDate(endDate);
|
||||
period.setActivityType(theory);
|
||||
return period;
|
||||
}
|
||||
|
||||
private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) {
|
||||
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
retries: 5
|
||||
networks:
|
||||
- magistr
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Назначение
|
||||
|
||||
Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения.
|
||||
Календарный учебный график представляет правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. В базе последовательные даты с одинаковой активностью хранятся одним периодом. Динамическое расписание использует график как фильтр: обычные пары выводятся только в дни теоретического обучения.
|
||||
|
||||
График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей.
|
||||
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
|
||||
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
|
||||
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
|
||||
- `academic_calendar_periods` хранит непересекающиеся периоды активности по курсу с включительными границами дат.
|
||||
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
|
||||
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
|
||||
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
|
||||
@@ -52,7 +52,7 @@
|
||||
1. Определяется учебный год и семестр даты.
|
||||
2. Для группы находится назначение графика на этот учебный год.
|
||||
3. По году начала обучения группы вычисляется текущий курс.
|
||||
4. В `academic_calendar_days` ищется ячейка для курса и даты.
|
||||
4. В `academic_calendar_periods` ищется период курса, содержащий дату.
|
||||
5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`.
|
||||
6. Если код не разрешает пары, день пропускается и академические часы не списываются.
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
|
||||
- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке.
|
||||
- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`.
|
||||
- Дневной REST-контракт сохранён: при чтении backend разворачивает периоды в ячейки, а при полной замене объединяет соседние даты с одинаковой активностью в одну строку `academic_calendar_periods`.
|
||||
- Отсутствие периода для даты означает отсутствие разрешённой активности; периоды одного курса и графика не могут пересекаться.
|
||||
- График привязан к профилю обучения, а не только к коду специальности.
|
||||
- Форма обучения графика должна совпадать с формой обучения группы при назначении графика.
|
||||
- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры.
|
||||
|
||||
14
docs/API.md
14
docs/API.md
@@ -551,8 +551,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
||||
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика, развёрнутая из периодов |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки с объединением периодов |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика |
|
||||
|
||||
@@ -574,7 +574,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
Отдельного пользовательского названия у календарного графика нет.
|
||||
|
||||
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
|
||||
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
||||
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
|
||||
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
||||
`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить
|
||||
`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше
|
||||
@@ -599,8 +599,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
`понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
|
||||
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
|
||||
`(courseNumber, date)` не должен повторяться, каждый
|
||||
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
|
||||
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||||
`activityTypeId` или `activityCode` должен существовать. После проверки соседние даты
|
||||
одного курса с одинаковой активностью объединяются в один период с включительными
|
||||
границами. Разрыв хотя бы в один день или смена активности начинает новый период.
|
||||
При `GET` сохранённые периоды разворачиваются обратно в тот же дневной JSON-контракт,
|
||||
а `weekNumber` и `dayOfWeek` рассчитываются по дате. При любом `400` старая сетка остаётся
|
||||
без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||||
|
||||
**Привязка дисциплин к графику:**
|
||||
```json
|
||||
|
||||
@@ -317,10 +317,12 @@ pod не сохраняют два конфликтующих правила п
|
||||
|
||||
`AcademicCalendarController` делегирует полную замену дневной сетки
|
||||
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
|
||||
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются
|
||||
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает
|
||||
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется
|
||||
только после успешного commit.
|
||||
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем соседние
|
||||
даты одного курса с одинаковой активностью сжимаются в периоды и выполняются
|
||||
`delete → flush → saveAll → flush`. При чтении контроллер разворачивает интервалы обратно
|
||||
в дневной REST-контракт и вычисляет номер недели и ISO-день. Исключение не перехватывается
|
||||
внутри сервиса и вызывает rollback. Инвалидация кэша зарегистрирована через transaction
|
||||
synchronization и выполняется только после успешного commit.
|
||||
|
||||
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
|
||||
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
|
||||
@@ -339,7 +341,8 @@ lost update или проверке устаревшего снимка.
|
||||
`endDate - startDate + 1` одинаково в query- и generator-слоях.
|
||||
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
||||
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
|
||||
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
|
||||
назначений календарей и пересекающиеся периоды активности от начала затронутого семестра
|
||||
до конца диапазона.
|
||||
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
|
||||
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
|
||||
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.
|
||||
|
||||
@@ -143,7 +143,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
| `specialty_profiles` | Профили обучения внутри специальности |
|
||||
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
|
||||
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
|
||||
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
||||
| `academic_calendar_periods` | Непересекающиеся непрерывные периоды активности по курсу |
|
||||
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
|
||||
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
@@ -165,12 +165,15 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
|
||||
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
|
||||
|
||||
Полная замена `academic_calendar_days` выполняется через транзакционный
|
||||
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
|
||||
Полная замена дневной сетки выполняется через транзакционный
|
||||
`AcademicCalendarGridService`. Сервис сначала проверяет весь дневной payload, включая
|
||||
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
|
||||
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
|
||||
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
|
||||
расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе
|
||||
и разрешает все коды активностей. Затем соседние даты одного курса с одинаковой активностью
|
||||
объединяются в один включительный период `academic_calendar_periods`. Только после этого
|
||||
прежние периоды удаляются и новый набор записывается одной транзакцией. Ошибка любой строки
|
||||
сохраняет прежнюю сетку целиком, а кэш расписания очищается только после commit. База
|
||||
дополнительно запрещает пересекающиеся периоды одного курса и графика через exclusion
|
||||
constraint. При чтении API разворачивает периоды обратно в дневные ячейки. Недели дневной сетки выровнены по ISO-неделе
|
||||
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
|
||||
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
|
||||
к неделе 2.
|
||||
@@ -189,10 +192,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||
|
||||
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
|
||||
календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки
|
||||
звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск
|
||||
семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только
|
||||
по lookup-картам. `ScheduleQueryService` передаёт все группы в один
|
||||
календарей всех выбранных групп, пересекающиеся периоды активности, правила и эффективные
|
||||
сетки звонков загружаются batch-запросами. Периоды группируются по календарю и курсу, а
|
||||
поиск периода для даты выполняется бинарным поиском. Поиск семестра и подстановка времени
|
||||
внутри циклов выполняются только по снимку запроса. `ScheduleQueryService` передаёт все группы в один
|
||||
`buildScheduleForGroups()`, поэтому число запросов не растёт как
|
||||
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
|
||||
- **Управление схемой:** Flyway (программный запуск)
|
||||
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
|
||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraints временных слотов и календарных периодов)
|
||||
- **Мультитенантность:** Каждый тенант = отдельная БД
|
||||
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
|
||||
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
|
||||
@@ -262,13 +262,12 @@ erDiagram
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
academic_calendar_days {
|
||||
academic_calendar_periods {
|
||||
BIGSERIAL id PK
|
||||
BIGINT calendar_id FK
|
||||
INT course_number
|
||||
DATE date
|
||||
INT week_number
|
||||
INT day_of_week
|
||||
DATE start_date
|
||||
DATE end_date
|
||||
BIGINT activity_type_id FK
|
||||
}
|
||||
|
||||
@@ -375,10 +374,10 @@ erDiagram
|
||||
specialties ||--o{ academic_calendars : "specialty_id"
|
||||
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
||||
education_forms ||--o{ academic_calendars : "study_form_id"
|
||||
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
|
||||
academic_calendars ||--o{ academic_calendar_periods : "calendar_id"
|
||||
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
|
||||
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
|
||||
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
||||
academic_calendar_activity_types ||--o{ academic_calendar_periods : "activity_type_id"
|
||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
|
||||
@@ -768,19 +767,22 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
|
||||
остаются строки старших курсов или дисциплины старших семестров.
|
||||
|
||||
#### `academic_calendar_days` — Дневная сетка календарного графика
|
||||
#### `academic_calendar_periods` — Периоды активности календарного графика
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||||
| `course_number` | INT | Номер курса |
|
||||
| `date` | DATE | Дата учебного года |
|
||||
| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
|
||||
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
||||
| `start_date` | DATE | Первая дата периода, включительно |
|
||||
| `end_date` | DATE | Последняя дата периода, включительно |
|
||||
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
||||
|
||||
Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал
|
||||
`academic_calendars.course_count`, а дата находилась внутри учебного года графика.
|
||||
Триггер `trg_calendar_periods_dimensions` требует, чтобы `course_number` не превышал
|
||||
`academic_calendars.course_count`, обе границы находились внутри учебного года графика,
|
||||
а `end_date` была не раньше `start_date`. Exclusion constraint
|
||||
`ex_calendar_periods_no_overlap` запрещает пересечение периодов одного курса и графика.
|
||||
Соседние даты с одинаковой активностью объединяет backend; номер недели и ISO-день
|
||||
вычисляются при разворачивании периода в дневной REST-ответ и в таблице не дублируются.
|
||||
|
||||
#### `academic_calendar_subjects` — Дисциплины календарного графика
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -921,12 +923,13 @@ lifecycle ресурсов, эффективная сетка целевого
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с интервальным хранением активностей и нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||
|
||||
### Этап разработки
|
||||
|
||||
По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline
|
||||
`V1`. Правильная нумерация недель календарного графика также входит непосредственно в V1.
|
||||
`V1`. Интервальное хранение активностей и правильная нумерация недель календарного графика
|
||||
также входят непосредственно в V1.
|
||||
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| `education_forms` | Общий справочник форм обучения для групп и календарных графиков |
|
||||
| `academic_calendar_activity_types` | Коды активностей из Excel-графика |
|
||||
| `academic_calendars` | Календарные графики профиля, формы обучения и учебного года |
|
||||
| `academic_calendar_days` | Дневная сетка графика по курсу и дате |
|
||||
| `academic_calendar_periods` | Непересекающиеся периоды активности графика по курсу |
|
||||
| `student_group_calendar_assignments` | Назначения графиков группам по учебному году |
|
||||
| `time_slots` | Настраиваемая сетка пар |
|
||||
| `schedule_rules` | Правила проведения дисциплин с отдельными часами и стартовыми неделями лекций, лабораторных и практик |
|
||||
@@ -28,11 +28,11 @@
|
||||
| `schedule_rule_slots` | Шаблонные слоты правила |
|
||||
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
|
||||
|
||||
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневной сетке.
|
||||
Праздничные и неучебные дни больше не хранятся отдельной сущностью: они задаются кодами `*` и `=` в дневном редакторе, а в БД сохраняются непрерывными периодами.
|
||||
|
||||
## Backend
|
||||
|
||||
Добавлены сущности и репозитории для профилей, календарей, кодов активностей, дневной сетки и назначений группам.
|
||||
Добавлены сущности и репозитории для профилей, календарей, кодов активностей, периодов активности и назначений группам. Дневной API сжимает соседние одинаковые ячейки при записи и разворачивает периоды при чтении.
|
||||
|
||||
Ключевые сервисы:
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ frontend/
|
||||
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||
- Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности.
|
||||
- Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
|
||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`.
|
||||
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. REST-контракт остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Все шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`.
|
||||
- При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика.
|
||||
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Недели выровнены по интервалам `понедельник–воскресенье`: если учебный год начинается не в понедельник, предшествующие позиции первой недели остаются пустыми, а следующий понедельник открывает неделю 2. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении возле курсора отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. У границ viewport подсказка автоматически раскрывается в противоположную сторону и остаётся полностью видимой, а код активности в ней центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
|
||||
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- `specialties` и `specialty_profiles` определяют образовательную программу группы.
|
||||
- `academic_calendars` хранит график профиля и формы обучения.
|
||||
- `academic_calendar_activity_types` хранит коды из Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=`.
|
||||
- `academic_calendar_days` хранит дневную сетку по курсу и дате.
|
||||
- `academic_calendar_periods` хранит непрерывные непересекающиеся периоды активности по курсу.
|
||||
- `student_group_calendar_assignments` выбирает график для группы на учебный год.
|
||||
|
||||
### Слой правил расписания
|
||||
@@ -47,4 +47,4 @@
|
||||
|
||||
## Административный сценарий
|
||||
|
||||
Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу.
|
||||
Администратор создаёт специальность и профиль, затем создаёт группу с этой связкой. В разделе расписания он создаёт учебный год, семестры и календарный график, заполняет дневную сетку кодами активностей и назначает график группе. Backend объединяет соседние одинаковые дни в периоды, не меняя дневной интерфейс. После сохранения графика или назначения кэш генератора очищается, поэтому следующие запросы расписания используют новые правила сразу.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Задачи: динамическое расписание и календарный учебный график
|
||||
|
||||
Актуальная декомпозиция относится к модели, где календарный учебный график хранится дневной сеткой и назначается группе на учебный год.
|
||||
Актуальная декомпозиция относится к модели, где календарный учебный график редактируется дневной сеткой, хранится непрерывными периодами активности и назначается группе на учебный год.
|
||||
|
||||
## Выполнено
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- [x] Добавлены специальности, профили обучения и связь группы с профилем.
|
||||
- [x] Добавлены формы обучения календарного графика.
|
||||
- [x] Добавлен справочник кодов активностей Excel-графика.
|
||||
- [x] Добавлены календарные графики, дневная сетка и назначения графиков группам.
|
||||
- [x] Добавлены календарные графики, интервальное хранение дневной сетки и назначения графиков группам.
|
||||
- [x] Обновлён `AcademicDateService`: активность дня определяется по назначенному группе графику.
|
||||
- [x] Обновлён `ScheduleGeneratorService`: обычные пары генерируются только на разрешённых днях.
|
||||
- [x] В `V1__init.sql` добавлены отдельные часы и недели начала для лекций, лабораторных и практик.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"generated_at": "2026-07-30T00:10:04.894284+00:00",
|
||||
"generated_at": "2026-07-31T00:26:15.582806+00:00",
|
||||
"nodes": {
|
||||
"_gitea_workflows_docker_build_backend_image_pipeline": {
|
||||
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd",
|
||||
"code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
|
||||
"label": "Pipeline backend-образа",
|
||||
"last": "2026-07-29T21:50:22.465033+00:00",
|
||||
"provenance": [
|
||||
@@ -22,16 +22,22 @@
|
||||
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
|
||||
}
|
||||
],
|
||||
"score": 2.991771942,
|
||||
"score": 2.92268101,
|
||||
"source_file": ".gitea/workflows/docker-build.yaml",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"_gitea_workflows_docker_build_build_and_push_docker_images": {
|
||||
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd",
|
||||
"code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
|
||||
"label": "Workflow сборки и публикации Docker-образов",
|
||||
"last": "2026-07-30T00:01:24.718235+00:00",
|
||||
"last": "2026-07-30T00:15:03.724071+00:00",
|
||||
"neg": 1,
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-30T00:15:03.724071+00:00",
|
||||
"outcome": "corrected",
|
||||
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30T00:01:24.718235+00:00",
|
||||
"outcome": "useful",
|
||||
@@ -51,23 +57,25 @@
|
||||
"date": "2026-07-29T21:27:26.637336+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29T20:38:53.488440+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
|
||||
}
|
||||
],
|
||||
"score": 7.977548417,
|
||||
"score": 6.81633329,
|
||||
"source_file": ".gitea/workflows/docker-build.yaml",
|
||||
"status": "preferred",
|
||||
"uses": 8
|
||||
"status": "contested",
|
||||
"uses": 8,
|
||||
"verdict": "useful"
|
||||
},
|
||||
"_gitea_workflows_docker_build_deploy_to_k8s": {
|
||||
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd",
|
||||
"code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
|
||||
"label": "Job деплоя в Kubernetes",
|
||||
"last": "2026-07-30T00:01:24.718235+00:00",
|
||||
"last": "2026-07-30T00:15:03.724071+00:00",
|
||||
"neg": 1,
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-30T00:15:03.724071+00:00",
|
||||
"outcome": "corrected",
|
||||
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30T00:01:24.718235+00:00",
|
||||
"outcome": "useful",
|
||||
@@ -79,13 +87,14 @@
|
||||
"q": "сам можешь выполнить эти действия?"
|
||||
}
|
||||
],
|
||||
"score": 1.996058514,
|
||||
"score": 0.972977818,
|
||||
"source_file": ".gitea/workflows/docker-build.yaml",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
"status": "contested",
|
||||
"uses": 2,
|
||||
"verdict": "useful"
|
||||
},
|
||||
"_gitea_workflows_docker_build_frontend_image_pipeline": {
|
||||
"code_fingerprint": "3b51aee192b5af2b63ee1b7f62beca1b4524fbbd2b143c9bd29eb039db90f7bd",
|
||||
"code_fingerprint": "54d549276598adddd9334dede2b54f31e8610315ee84b0b0269706bf339d6077",
|
||||
"label": "Pipeline frontend-образа",
|
||||
"last": "2026-07-29T22:22:22.155742+00:00",
|
||||
"provenance": [
|
||||
@@ -105,7 +114,7 @@
|
||||
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
|
||||
}
|
||||
],
|
||||
"score": 2.992651314,
|
||||
"score": 2.923540074,
|
||||
"source_file": ".gitea/workflows/docker-build.yaml",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
@@ -131,7 +140,7 @@
|
||||
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
|
||||
}
|
||||
],
|
||||
"score": 1.85377087,
|
||||
"score": 1.810960536,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
@@ -147,7 +156,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.658614913,
|
||||
"score": 0.643405092,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -163,7 +172,7 @@
|
||||
"q": "Почему RequireRoles связывает множество контроллеров, моделей и сервисов как cross-community bridge?"
|
||||
}
|
||||
],
|
||||
"score": 0.554143065,
|
||||
"score": 0.541345879,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/auth/RequireRoles.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -179,7 +188,7 @@
|
||||
"q": "Как безопасно обновлять tenant DataSource без потери рабочего подключения?"
|
||||
}
|
||||
],
|
||||
"score": 0.67398734,
|
||||
"score": 0.658422513,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/DataInitializer.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -195,7 +204,7 @@
|
||||
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
|
||||
}
|
||||
],
|
||||
"score": 0.641012892,
|
||||
"score": 0.626209565,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -211,7 +220,7 @@
|
||||
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
|
||||
}
|
||||
],
|
||||
"score": 0.673873064,
|
||||
"score": 0.658310876,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfig.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -237,7 +246,7 @@
|
||||
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
|
||||
}
|
||||
],
|
||||
"score": 1.988873297,
|
||||
"score": 1.942942954,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
@@ -263,11 +272,27 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 2.006475318,
|
||||
"score": 1.960138481,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/config/tenant/TenantRoutingDataSource.java",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_controller_academiccalendarcontroller_academiccalendarcontroller": {
|
||||
"code_fingerprint": "0bc8973ea8083a479c690c40e12fb178df49b4f88dc62c7788fb64e2069e1b5f",
|
||||
"label": "AcademicCalendarController",
|
||||
"last": "2026-07-31T00:15:22.545259+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-31T00:15:22.545259+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается."
|
||||
}
|
||||
],
|
||||
"score": 0.999825381,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_controller_classroomcontroller": {
|
||||
"code_fingerprint": "a768ef91dfe89f82197f53d85b348187a340c3dc8043bf009fa2f08418fabcba",
|
||||
"label": "ClassroomController.java",
|
||||
@@ -279,7 +304,7 @@
|
||||
"q": "Почему в выборе аудитории показаны корпус и этаж?"
|
||||
}
|
||||
],
|
||||
"score": 0.828570992,
|
||||
"score": 0.809436264,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ClassroomController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -295,7 +320,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.658614913,
|
||||
"score": 0.643405092,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -316,7 +341,7 @@
|
||||
"q": "Как безопасно заменить tenant DataSource без потери рабочего подключения?"
|
||||
}
|
||||
],
|
||||
"score": 1.347860405,
|
||||
"score": 1.316733389,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/DatabaseController.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -332,7 +357,7 @@
|
||||
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
|
||||
}
|
||||
],
|
||||
"score": 0.641012892,
|
||||
"score": 0.626209565,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/GlobalExceptionHandler.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -353,7 +378,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 1.48642084,
|
||||
"score": 1.452093958,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleController.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -369,7 +394,7 @@
|
||||
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
|
||||
}
|
||||
],
|
||||
"score": 0.669157537,
|
||||
"score": 0.653704248,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleOverrideController.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -390,7 +415,7 @@
|
||||
"q": "проанализируй весь проект на баги и ошибки и дополни '/mnt/HDD/ProjectMagistr/magistr/BUG_REPORT.md'"
|
||||
}
|
||||
],
|
||||
"score": 1.314657272,
|
||||
"score": 1.284297038,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -398,18 +423,28 @@
|
||||
"backend_src_main_java_com_magistr_app_model_academiccalendar_academiccalendar": {
|
||||
"code_fingerprint": "5bdd7da771fe97443661a5bd06bed94f453ea036759c4de8c927ed68eb2b723d",
|
||||
"label": "AcademicCalendar",
|
||||
"last": "2026-07-22T21:09:57.015227+00:00",
|
||||
"last": "2026-07-31T00:15:22.545259+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-31T00:15:22.545259+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30T23:49:30.491131+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-22T21:09:57.015227+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
|
||||
}
|
||||
],
|
||||
"score": 0.848212095,
|
||||
"score": 2.827859657,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/AcademicCalendar.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_model_classroom_classroom": {
|
||||
"code_fingerprint": "5614d83b8b7e20fd2b76271b28a8ef11ffc137808ec2d7ed7cd23e42e0ab93ec",
|
||||
@@ -422,7 +457,7 @@
|
||||
"q": "Почему в выборе аудитории показаны корпус и этаж?"
|
||||
}
|
||||
],
|
||||
"score": 0.828570992,
|
||||
"score": 0.809436264,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/Classroom.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -438,7 +473,7 @@
|
||||
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
|
||||
}
|
||||
],
|
||||
"score": 0.848212095,
|
||||
"score": 0.828623782,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/Department.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -459,7 +494,7 @@
|
||||
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
|
||||
}
|
||||
],
|
||||
"score": 1.228405956,
|
||||
"score": 1.200037579,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRule.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -475,7 +510,7 @@
|
||||
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
|
||||
}
|
||||
],
|
||||
"score": 0.67364438,
|
||||
"score": 0.658087473,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/ScheduleRuleSlot.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -496,7 +531,7 @@
|
||||
"q": "в дополнительные фильры просмотра расписаний нужно добавить выбор семестра"
|
||||
}
|
||||
],
|
||||
"score": 1.676018021,
|
||||
"score": 1.637312649,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/Semester.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -512,11 +547,32 @@
|
||||
"q": "хотим поменять название сервиса, придумай что-то короткое одним словом подходящее по смыслу"
|
||||
}
|
||||
],
|
||||
"score": 0.848212095,
|
||||
"score": 0.828623782,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/model/StudentGroup.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_repository_academiccalendardayrepository_academiccalendardayrepository": {
|
||||
"code_fingerprint": "",
|
||||
"label": "AcademicCalendarDayRepository",
|
||||
"last": "2026-07-31T00:15:22.545259+00:00",
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-31T00:15:22.545259+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "давай сделаем \"Один непрерывный период с одинаковой активностью становится одной строкой\". пиши всё в v1 не создавая новую миграцию. внеси изменения о которых сказал, не беспокоясь о проде, потому что проект на этапе разработки и ничего не сломается."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30T23:49:30.491131+00:00",
|
||||
"outcome": "useful",
|
||||
"q": "как ты думаешь, нужно ли оптимизировать таблицу academic_calendar_days? а то расписаний будет очень много, минимум 1000 и со временем будет расти, а там уже такой большой объём данных, даже когда расписаний мало. Можем подумать как переделать логику чтобы оптимизировать это дело"
|
||||
}
|
||||
],
|
||||
"score": 1.999235874,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/repository/AcademicCalendarDayRepository.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
},
|
||||
"backend_src_main_java_com_magistr_app_repository_semesterrepository_semesterrepository": {
|
||||
"code_fingerprint": "2b869d2e676e50879b467e36cdc45982b68b323942c51de567c347fe9bf1fd10",
|
||||
"label": "SemesterRepository",
|
||||
@@ -528,7 +584,7 @@
|
||||
"q": "Какие связи затрагивает полная валидация правил расписания в исправлении №7?"
|
||||
}
|
||||
],
|
||||
"score": 0.67364438,
|
||||
"score": 0.658087473,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/repository/SemesterRepository.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -549,7 +605,7 @@
|
||||
"q": "Как безопасно валидировать и сохранять точечные изменения расписания?"
|
||||
}
|
||||
],
|
||||
"score": 1.496963464,
|
||||
"score": 1.462393114,
|
||||
"source_file": "backend/src/main/java/com/magistr/app/service/ScheduleQueryService.java",
|
||||
"status": "preferred",
|
||||
"uses": 2
|
||||
@@ -565,7 +621,7 @@
|
||||
"q": "Корректны ли inferred-связи вокруг ScheduleRule?"
|
||||
}
|
||||
],
|
||||
"score": 0.554761576,
|
||||
"score": 0.541950106,
|
||||
"source_file": "backend/src/test/java/com/magistr/app/model/ScheduleRuleTest.java",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -581,7 +637,7 @@
|
||||
"q": "Какие подсистемы связывают исправления аутентификации, tenant-конфигурации, расписания и инфраструктуры?"
|
||||
}
|
||||
],
|
||||
"score": 0.658614913,
|
||||
"score": 0.643405092,
|
||||
"source_file": "compose.yaml",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -597,13 +653,13 @@
|
||||
"q": "новая схема на проде на удалённом сервере создана через CREATE SCHEMA public. я проверил, давай дальше"
|
||||
}
|
||||
],
|
||||
"score": 0.995980694,
|
||||
"score": 0.972979865,
|
||||
"source_file": "compose.yaml",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"docs_database_flyway_migrations": {
|
||||
"code_fingerprint": "2f5d4ce61ba0f5d8a9141f4ac33375dfc26a68e3bdccdc4269cf2a4f45a50176",
|
||||
"code_fingerprint": "2b50799351b008ee1b60bba24a352e8cb6c0faf710e5af8ed780a5c2d3a1fc87",
|
||||
"label": "Flyway-миграции",
|
||||
"last": "2026-07-29T19:26:44.188240+00:00",
|
||||
"provenance": [
|
||||
@@ -613,13 +669,13 @@
|
||||
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
|
||||
}
|
||||
],
|
||||
"score": 0.995464026,
|
||||
"score": 0.972475129,
|
||||
"source_file": "docs/DATABASE.md",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
},
|
||||
"docs_infrastructure_gitea_actions_cicd": {
|
||||
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2",
|
||||
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
|
||||
"label": "CI/CD Gitea Actions в инфраструктуре",
|
||||
"last": "2026-07-29T22:22:22.155742+00:00",
|
||||
"provenance": [
|
||||
@@ -644,13 +700,13 @@
|
||||
"q": "The goal you specified requires a project to execute but there is no POM in this directory (/workspace)."
|
||||
}
|
||||
],
|
||||
"score": 3.990045183,
|
||||
"score": 3.897900479,
|
||||
"source_file": "docs/INFRASTRUCTURE.md",
|
||||
"status": "preferred",
|
||||
"uses": 4
|
||||
},
|
||||
"docs_infrastructure_jwt_secret_policy": {
|
||||
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2",
|
||||
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
|
||||
"label": "Политика JWT-секрета",
|
||||
"last": "2026-07-30T00:01:24.718235+00:00",
|
||||
"provenance": [
|
||||
@@ -670,16 +726,22 @@
|
||||
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
|
||||
}
|
||||
],
|
||||
"score": 2.99152254,
|
||||
"score": 2.922437367,
|
||||
"source_file": "docs/INFRASTRUCTURE.md",
|
||||
"status": "preferred",
|
||||
"uses": 3
|
||||
},
|
||||
"docs_infrastructure_kubernetes_production": {
|
||||
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2",
|
||||
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
|
||||
"label": "Production-инфраструктура Kubernetes",
|
||||
"last": "2026-07-30T00:01:24.718235+00:00",
|
||||
"last": "2026-07-30T00:15:03.724071+00:00",
|
||||
"neg": 1,
|
||||
"provenance": [
|
||||
{
|
||||
"date": "2026-07-30T00:15:03.724071+00:00",
|
||||
"outcome": "corrected",
|
||||
"q": "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30T00:01:24.718235+00:00",
|
||||
"outcome": "useful",
|
||||
@@ -701,13 +763,14 @@
|
||||
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
|
||||
}
|
||||
],
|
||||
"score": 3.987503234,
|
||||
"score": 2.918432812,
|
||||
"source_file": "docs/INFRASTRUCTURE.md",
|
||||
"status": "preferred",
|
||||
"uses": 4
|
||||
"status": "contested",
|
||||
"uses": 4,
|
||||
"verdict": "useful"
|
||||
},
|
||||
"docs_infrastructure_tenants_configmap": {
|
||||
"code_fingerprint": "7d9952767f14c83ef2e2c1708d9b5f0f8fccde3a214b2d2d9d91446437fdfdd2",
|
||||
"code_fingerprint": "f0d580f018f58ee321bcca59df5787ebad772071c433fb340da53fa885fa7ebc",
|
||||
"label": "Tenants ConfigMap",
|
||||
"last": "2026-07-29T19:26:44.188240+00:00",
|
||||
"provenance": [
|
||||
@@ -717,7 +780,7 @@
|
||||
"q": "теперь напиши мне инструкцию как это всё выыкатить на прод. у мня уже поднят на проде этот сервис со старой базой данных, но я могу её затереть. ci/cd тоже настроен, по сути я прямо сейчас могу залить это в ветку мейн и оно должно раскататься. каие есть подводные камни после изменений безопасности которые мы внесли?"
|
||||
}
|
||||
],
|
||||
"score": 0.995464026,
|
||||
"score": 0.972475129,
|
||||
"source_file": "docs/INFRASTRUCTURE.md",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
@@ -733,7 +796,7 @@
|
||||
"q": "Проверить опубликованные образы: backend Trivy HIGH/CRITICAL scan завершился exitcode 1"
|
||||
}
|
||||
],
|
||||
"score": 0.997393869,
|
||||
"score": 0.974360405,
|
||||
"source_file": "",
|
||||
"status": "tentative",
|
||||
"uses": 1
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 инфраструктура
|
||||
@@ -1,24 +1,23 @@
|
||||
# Lessons
|
||||
|
||||
_Auto-generated by `graphify reflect` from 20 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
|
||||
_Auto-generated by `graphify reflect` from 23 session memories in graphify-out/memory/. Deterministic; no LLM. Use for orientation — verify before relying, and revisit dead ends if the code has changed since._
|
||||
|
||||
## Summary
|
||||
|
||||
- 19 useful · 1 dead ends · 0 corrected · 0 unmarked
|
||||
- 21 useful · 1 dead ends · 1 corrected · 0 unmarked
|
||||
|
||||
## Lessons
|
||||
|
||||
**Preferred sources** — corroborated by ≥2 useful results; start here.
|
||||
|
||||
- `Workflow сборки и публикации Docker-образов` (8× useful)
|
||||
- `ScheduleGeneratorService` (5× useful)
|
||||
- `CI/CD Gitea Actions в инфраструктуре` (4× useful)
|
||||
- `Production-инфраструктура Kubernetes` (4× useful)
|
||||
- `Pipeline frontend-образа` (3× useful)
|
||||
- `Pipeline backend-образа` (3× useful)
|
||||
- `Политика JWT-секрета` (3× useful)
|
||||
- `ScheduleGeneratorService` (3× useful)
|
||||
- `AcademicCalendar` (3× useful)
|
||||
- `AcademicCalendarDayRepository` (2× useful)
|
||||
- `TenantRoutingDataSource` (3× useful)
|
||||
- `Job деплоя в Kubernetes` (2× useful)
|
||||
- `TenantConfigWatcher` (3× useful)
|
||||
- `AuthorizationInterceptor` (3× useful)
|
||||
- `Semester` (2× useful)
|
||||
@@ -30,11 +29,12 @@ _Auto-generated by `graphify reflect` from 20 session memories in graphify-out/m
|
||||
|
||||
**Tentative** — useful in fewer than 2 results; verify before relying.
|
||||
|
||||
- `AcademicCalendarController` (1× useful)
|
||||
- `AcademicDateService` (1× useful)
|
||||
- `SpringBootApplication` (1× useful)
|
||||
- `PostgreSQL-сервис Docker Compose` (1× useful)
|
||||
- `Flyway-миграции` (1× useful)
|
||||
- `Tenants ConfigMap` (1× useful)
|
||||
- `AcademicCalendar` (1× useful)
|
||||
- `Department` (1× useful)
|
||||
- `StudentGroup` (1× useful)
|
||||
- `Classroom` (1× useful)
|
||||
@@ -52,6 +52,16 @@ _Auto-generated by `graphify reflect` from 20 session memories in graphify-out/m
|
||||
- `ScheduleRuleTest` (1× useful)
|
||||
- `RequireRoles` (1× useful)
|
||||
|
||||
**Contested** — mixed signals; recency decides.
|
||||
|
||||
- `Workflow сборки и публикации Docker-образов` — 8× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30)
|
||||
- `Production-инфраструктура Kubernetes` — 4× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30)
|
||||
- `Job деплоя в Kubernetes` — 2× useful, 1× dead end/corrected → recency leans **useful** (latest 2026-07-30)
|
||||
|
||||
**Known dead ends** — led nowhere; don't re-derive.
|
||||
|
||||
- "Как ScheduleOverrideService должен выявлять конфликты эффективного расписания?" — `Override`
|
||||
|
||||
**Corrections** — do these differently.
|
||||
|
||||
- "Ты неправильно меня понял. Мне не нужно, чтобы K8S находился в репозитории. Мне нужно просто, чтобы ты вернул назад, как было, чтобы cicd срабатывала без ручного вмешательства. И всё." → Kubernetes manifests остаются вне репозитория; CI автоматически выполняет только image digest rollout после push в main.
|
||||
|
||||
Reference in New Issue
Block a user