Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign

This commit is contained in:
dipatrik10
2026-06-02 00:02:06 +03:00
17 changed files with 893 additions and 19 deletions

View File

@@ -9,8 +9,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/academic-calendars")
@@ -24,6 +28,8 @@ public class AcademicCalendarController {
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
@@ -33,6 +39,8 @@ public class AcademicCalendarController {
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
@@ -41,6 +49,8 @@ public class AcademicCalendarController {
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -136,6 +146,34 @@ public class AcademicCalendarController {
}
}
@GetMapping("/{id}/subjects")
public ResponseEntity<?> getSubjects(@PathVariable Long id) {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
}
@PutMapping("/{id}/subjects")
@Transactional
public ResponseEntity<?> saveSubjects(@PathVariable Long id,
@RequestBody List<AcademicCalendarSubjectDto> rows) {
AcademicCalendar calendar = calendarRepository.findByIdWithDetails(id).orElse(null);
if (calendar == null) {
return ResponseEntity.notFound().build();
}
try {
replaceCalendarSubjects(calendar, rows == null ? List.of() : rows);
return ResponseEntity.ok(calendarSubjectRepository.findByCalendarIdWithSubject(id).stream()
.map(this::toCalendarSubjectDto)
.toList());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
@@ -146,6 +184,9 @@ public class AcademicCalendarController {
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
@@ -209,6 +250,52 @@ public class AcademicCalendarController {
throw new IllegalArgumentException("Код активности обязателен");
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
}
int maxSemesterNumber = Math.max(1, calendar.getCourseCount() == null ? 0 : calendar.getCourseCount() * 2);
Set<Long> subjectIds = new HashSet<>();
Set<String> uniqueRows = new HashSet<>();
for (AcademicCalendarSubjectDto row : rows) {
if (row == null || row.semesterNumber() == null || row.subjectId() == null) {
throw new IllegalArgumentException("Семестр и дисциплина обязательны");
}
if (row.semesterNumber() < 1 || row.semesterNumber() > maxSemesterNumber) {
throw new IllegalArgumentException("Номер семестра выходит за пределы графика");
}
if (!uniqueRows.add(row.semesterNumber() + ":" + row.subjectId())) {
throw new IllegalArgumentException("Дисциплина уже привязана к этому семестру");
}
subjectIds.add(row.subjectId());
}
Map<Long, Subject> subjectsById = subjectRepository.findAllById(subjectIds).stream()
.collect(Collectors.toMap(Subject::getId, subject -> subject, (left, right) -> left, HashMap::new));
for (Long subjectId : subjectIds) {
Subject subject = subjectsById.get(subjectId);
if (subject == null) {
throw new IllegalArgumentException("Дисциплина не найдена");
}
if (subject.isArchivedRecord()) {
throw new IllegalArgumentException("Архивную дисциплину нельзя привязать к графику");
}
}
calendarSubjectRepository.deleteByAcademicCalendar_Id(calendar.getId());
List<AcademicCalendarSubject> entities = rows.stream()
.map(row -> {
AcademicCalendarSubject calendarSubject = new AcademicCalendarSubject();
calendarSubject.setAcademicCalendar(calendar);
calendarSubject.setSemesterNumber(row.semesterNumber());
calendarSubject.setSubject(subjectsById.get(row.subjectId()));
return calendarSubject;
})
.toList();
calendarSubjectRepository.saveAll(entities);
}
private AcademicCalendarDto toCalendarDto(AcademicCalendar calendar) {
AcademicYear year = calendar.getAcademicYear();
Speciality speciality = calendar.getSpeciality();
@@ -247,4 +334,17 @@ public class AcademicCalendarController {
activityType.getAllowSchedule()
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -1,15 +1,18 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.Subject;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.model.StudentGroupStudyState;
@@ -24,9 +27,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/groups")
@@ -41,6 +47,7 @@ public class GroupController {
private final SpecialtyProfileRepository specialtyProfileRepository;
private final AcademicYearRepository academicYearRepository;
private final AcademicCalendarRepository academicCalendarRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final StudentGroupLifecycleService groupLifecycleService;
@@ -51,6 +58,7 @@ public class GroupController {
SpecialtyProfileRepository specialtyProfileRepository,
AcademicYearRepository academicYearRepository,
AcademicCalendarRepository academicCalendarRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
ScheduleGeneratorService scheduleGeneratorService,
StudentGroupLifecycleService groupLifecycleService) {
@@ -60,6 +68,7 @@ public class GroupController {
this.specialtyProfileRepository = specialtyProfileRepository;
this.academicYearRepository = academicYearRepository;
this.academicCalendarRepository = academicCalendarRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupLifecycleService = groupLifecycleService;
@@ -303,8 +312,10 @@ public class GroupController {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(assignmentRepository.findByGroupIdWithDetails(id).stream()
.map(this::toAssignmentDto)
List<StudentGroupCalendarAssignment> assignments = assignmentRepository.findByGroupIdWithDetails(id);
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId = loadCalendarSubjectsByCalendar(assignments);
return ResponseEntity.ok(assignments.stream()
.map(assignment -> toAssignmentDto(assignment, subjectsByCalendarId))
.toList());
}
@@ -345,7 +356,13 @@ public class GroupController {
assignment.setAcademicYear(academicYear);
assignment.setAcademicCalendar(calendar);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toAssignmentDto(assignmentRepository.save(assignment)));
StudentGroupCalendarAssignment saved = assignmentRepository.save(assignment);
List<AcademicCalendarSubjectDto> subjects = calendarSubjectRepository
.findByCalendarIdWithSubject(calendar.getId())
.stream()
.map(this::toCalendarSubjectDto)
.toList();
return ResponseEntity.ok(toAssignmentDto(saved, subjects));
}
@DeleteMapping("/{id}/calendar-assignments/{assignmentId}")
@@ -360,7 +377,31 @@ public class GroupController {
return ResponseEntity.ok(Map.of("message", "Назначение календарного графика удалено"));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment) {
private Map<Long, List<AcademicCalendarSubjectDto>> loadCalendarSubjectsByCalendar(
List<StudentGroupCalendarAssignment> assignments) {
Set<Long> calendarIds = assignments.stream()
.map(assignment -> assignment.getAcademicCalendar().getId())
.collect(Collectors.toSet());
if (calendarIds.isEmpty()) {
return Collections.emptyMap();
}
return calendarSubjectRepository.findByCalendarIdInWithSubject(calendarIds).stream()
.collect(Collectors.groupingBy(
calendarSubject -> calendarSubject.getAcademicCalendar().getId(),
Collectors.mapping(this::toCalendarSubjectDto, Collectors.toList())
));
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
Map<Long, List<AcademicCalendarSubjectDto>> subjectsByCalendarId) {
return toAssignmentDto(
assignment,
subjectsByCalendarId.getOrDefault(assignment.getAcademicCalendar().getId(), List.of())
);
}
private GroupCalendarAssignmentDto toAssignmentDto(StudentGroupCalendarAssignment assignment,
List<AcademicCalendarSubjectDto> subjects) {
return new GroupCalendarAssignmentDto(
assignment.getId(),
assignment.getStudentGroup().getId(),
@@ -368,7 +409,21 @@ public class GroupController {
assignment.getAcademicYear().getId(),
assignment.getAcademicYear().getTitle(),
assignment.getAcademicCalendar().getId(),
assignment.getAcademicCalendar().getTitle()
assignment.getAcademicCalendar().getTitle(),
subjects
);
}
private AcademicCalendarSubjectDto toCalendarSubjectDto(AcademicCalendarSubject calendarSubject) {
Subject subject = calendarSubject.getSubject();
return new AcademicCalendarSubjectDto(
calendarSubject.getId(),
calendarSubject.getAcademicCalendar().getId(),
calendarSubject.getSemesterNumber(),
subject.getId(),
subject.getName(),
subject.getCode(),
subject.getDepartmentId()
);
}
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record AcademicCalendarSubjectDto(
Long id,
Long calendarId,
Integer semesterNumber,
Long subjectId,
String subjectName,
String subjectCode,
Long departmentId
) {
}

View File

@@ -1,5 +1,7 @@
package com.magistr.app.dto;
import java.util.List;
public record GroupCalendarAssignmentDto(
Long id,
Long groupId,
@@ -7,6 +9,7 @@ public record GroupCalendarAssignmentDto(
Long academicYearId,
String academicYearTitle,
Long calendarId,
String calendarTitle
String calendarTitle,
List<AcademicCalendarSubjectDto> subjects
) {
}

View File

@@ -0,0 +1,65 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(
name = "academic_calendar_subjects",
uniqueConstraints = @UniqueConstraint(
name = "uq_academic_calendar_subject",
columnNames = {"calendar_id", "semester_number", "subject_id"}
),
indexes = {
@Index(name = "idx_academic_calendar_subjects_calendar_semester", columnList = "calendar_id, semester_number"),
@Index(name = "idx_academic_calendar_subjects_subject", columnList = "subject_id")
}
)
public class AcademicCalendarSubject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "calendar_id", nullable = false)
private AcademicCalendar academicCalendar;
@Column(name = "semester_number", nullable = false)
private Integer semesterNumber;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subject_id", nullable = false)
private Subject subject;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public AcademicCalendar getAcademicCalendar() {
return academicCalendar;
}
public void setAcademicCalendar(AcademicCalendar academicCalendar) {
this.academicCalendar = academicCalendar;
}
public Integer getSemesterNumber() {
return semesterNumber;
}
public void setSemesterNumber(Integer semesterNumber) {
this.semesterNumber = semesterNumber;
}
public Subject getSubject() {
return subject;
}
public void setSubject(Subject subject) {
this.subject = subject;
}
}

View File

@@ -0,0 +1,34 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendarSubject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface AcademicCalendarSubjectRepository extends JpaRepository<AcademicCalendarSubject, Long> {
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id = :calendarId
order by calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdWithSubject(@Param("calendarId") Long calendarId);
@Query("""
select calendarSubject
from AcademicCalendarSubject calendarSubject
join fetch calendarSubject.academicCalendar calendar
join fetch calendarSubject.subject subject
where calendar.id in :calendarIds
order by calendar.id asc, calendarSubject.semesterNumber asc, lower(subject.name) asc
""")
List<AcademicCalendarSubject> findByCalendarIdInWithSubject(@Param("calendarIds") Collection<Long> calendarIds);
void deleteByAcademicCalendar_Id(Long calendarId);
}

View File

@@ -563,7 +563,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
course_count INT NOT NULL DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count > 0),
CONSTRAINT chk_academic_calendars_course_count CHECK (course_count BETWEEN 1 AND 8),
CONSTRAINT uq_academic_calendar_title UNIQUE (academic_year_id, specialty_profile_id, study_form_id, title)
);
@@ -617,6 +617,22 @@ CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
ON CONFLICT (calendar_id, course_number, date) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
semester_number INT NOT NULL,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_academic_calendar_subjects_semester_positive CHECK (semester_number > 0),
CONSTRAINT uq_academic_calendar_subject UNIQUE (calendar_id, semester_number, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_calendar_semester
ON academic_calendar_subjects(calendar_id, semester_number);
CREATE INDEX IF NOT EXISTS idx_academic_calendar_subjects_subject
ON academic_calendar_subjects(subject_id);
CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES student_groups(id) ON DELETE CASCADE,
@@ -964,6 +980,7 @@ 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_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
COMMENT ON TABLE schedule_rule_groups IS 'Привязка правил расписания к учебным группам';
@@ -1101,6 +1118,11 @@ 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_subjects.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_subjects.semester_number IS 'Номер учебного семестра внутри графика: 1, 2, 3 ...';
COMMENT ON COLUMN academic_calendar_subjects.subject_id IS 'ID дисциплины из справочника subjects';
COMMENT ON COLUMN academic_calendar_subjects.created_at IS 'Дата и время создания привязки';
COMMENT ON COLUMN student_group_calendar_assignments.group_id IS 'ID учебной группы';
COMMENT ON COLUMN student_group_calendar_assignments.academic_year_id IS 'ID учебного года';
COMMENT ON COLUMN student_group_calendar_assignments.calendar_id IS 'ID назначенного календарного графика';