сделал настройку временных слотов

This commit is contained in:
Zuev
2026-05-01 20:55:49 +03:00
parent bf02efb8b8
commit 3cb311f469
32 changed files with 2130 additions and 383 deletions

View File

@@ -47,6 +47,8 @@ public class ClassroomController {
Classroom classroom = new Classroom();
classroom.setName(request.getName().trim());
classroom.setCapacity(request.getCapacity());
classroom.setBuilding(cleanText(request.getBuilding()));
classroom.setFloor(request.getFloor());
classroom.setIsAvailable(request.getIsAvailable() != null ? request.getIsAvailable() : true);
if (request.getEquipmentIds() != null && !request.getEquipmentIds().isEmpty()) {
@@ -80,6 +82,14 @@ public class ClassroomController {
classroom.setCapacity(request.getCapacity());
}
if (request.getBuilding() != null) {
classroom.setBuilding(cleanText(request.getBuilding()));
}
if (request.getFloor() != null) {
classroom.setFloor(request.getFloor());
}
if (request.getIsAvailable() != null) {
classroom.setIsAvailable(request.getIsAvailable());
}
@@ -103,7 +113,11 @@ public class ClassroomController {
}
private ClassroomResponse mapToResponse(Classroom c) {
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getIsAvailable(),
return new ClassroomResponse(c.getId(), c.getName(), c.getCapacity(), c.getBuilding(), c.getFloor(), c.getIsAvailable(),
new java.util.ArrayList<>(c.getEquipments()));
}
private String cleanText(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@@ -16,6 +16,8 @@ import java.util.*;
@RequestMapping("/api/admin/schedule-rules")
public class ScheduleRuleAdminController {
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private final ScheduleRuleRepository scheduleRuleRepository;
private final SubjectRepository subjectRepository;
private final SemesterRepository semesterRepository;
@@ -169,6 +171,10 @@ public class ScheduleRuleAdminController {
}
TimeSlot timeSlot = timeSlotRepository.findById(slotDto.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (timeSlot.getTimeSlotScope() == null
|| !APPLY_MODE_DEFAULT.equals(timeSlot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("В правилах расписания можно выбирать только базовые временные слоты");
}
User teacher = userRepository.findById(slotDto.teacherId())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
Classroom classroom = classroomRepository.findById(slotDto.classroomId())

View File

@@ -1,40 +1,159 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TimeSlotDateAssignmentDto;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.dto.TimeSlotScopeDto;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.TimeSlotDateAssignment;
import com.magistr.app.model.TimeSlotScope;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/time-slots")
public class TimeSlotAdminController {
private final TimeSlotRepository timeSlotRepository;
private static final String APPLY_MODE_DEFAULT = "DEFAULT";
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private static final String APPLY_MODE_MANUAL = "MANUAL";
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository) {
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
public TimeSlotAdminController(TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
}
@GetMapping
public List<TimeSlotDto> getAll() {
return timeSlotRepository.findAllByOrderByOrderNumberAsc().stream()
return timeSlotRepository.findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc().stream()
.map(this::toDto)
.toList();
}
@GetMapping("/effective")
public ResponseEntity<?> getEffective(@RequestParam LocalDate date) {
return ResponseEntity.ok(effectiveSlots(date).stream().map(this::toDto).toList());
}
@GetMapping("/scopes")
public List<TimeSlotScopeDto> getScopes() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(this::toScopeDto)
.toList();
}
@PostMapping("/scopes")
public ResponseEntity<?> createScope(@RequestBody TimeSlotScopeDto request) {
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
TimeSlotScope scope = new TimeSlotScope();
scope.setCode("manual_" + UUID.randomUUID().toString().replace("-", ""));
scope.setName(request.name().trim());
scope.setApplyMode(APPLY_MODE_MANUAL);
scope.setDayOfWeek(null);
scope.setSystemScope(false);
scope.setDisplayOrder(nextManualDisplayOrder());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@PutMapping("/scopes/{id}")
public ResponseEntity<?> updateScope(@PathVariable Long id, @RequestBody TimeSlotScopeDto request) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя изменять"));
}
if (request.name() == null || request.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название сетки обязательно"));
}
scope.setName(request.name().trim());
return ResponseEntity.ok(toScopeDto(timeSlotScopeRepository.save(scope)));
}
@DeleteMapping("/scopes/{id}")
public ResponseEntity<?> deleteScope(@PathVariable Long id) {
TimeSlotScope scope = timeSlotScopeRepository.findById(id).orElse(null);
if (scope == null) {
return ResponseEntity.notFound().build();
}
if (Boolean.TRUE.equals(scope.getSystemScope())) {
return ResponseEntity.badRequest().body(Map.of("message", "Системную сетку нельзя удалить"));
}
timeSlotScopeRepository.delete(scope);
return ResponseEntity.ok(Map.of("message", "Сетка времени удалена"));
}
@GetMapping("/date-assignments")
public List<TimeSlotDateAssignmentDto> getDateAssignments(@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate) {
List<TimeSlotDateAssignment> assignments = startDate != null && endDate != null
? dateAssignmentRepository.findByDateBetweenOrderByDateAsc(startDate, endDate)
: dateAssignmentRepository.findAllByOrderByDateAsc();
return assignments.stream().map(this::toAssignmentDto).toList();
}
@PostMapping("/date-assignments")
public ResponseEntity<?> saveDateAssignment(@RequestBody TimeSlotDateAssignmentDto request) {
if (request.date() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Дата обязательна"));
}
if (request.scopeId() == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени обязательна"));
}
TimeSlotScope scope = timeSlotScopeRepository.findById(request.scopeId()).orElse(null);
if (scope == null) {
return ResponseEntity.badRequest().body(Map.of("message", "Сетка времени не найдена"));
}
if (!APPLY_MODE_MANUAL.equals(scope.getApplyMode())) {
return ResponseEntity.badRequest().body(Map.of("message", "Вручную можно применять только пользовательские сетки"));
}
TimeSlotDateAssignment assignment = dateAssignmentRepository.findByDate(request.date())
.orElseGet(TimeSlotDateAssignment::new);
assignment.setDate(request.date());
assignment.setTimeSlotScope(scope);
return ResponseEntity.ok(toAssignmentDto(dateAssignmentRepository.save(assignment)));
}
@DeleteMapping("/date-assignments/{id}")
public ResponseEntity<?> deleteDateAssignment(@PathVariable Long id) {
if (!dateAssignmentRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
dateAssignmentRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Ручное назначение удалено"));
}
@PostMapping
public ResponseEntity<?> create(@RequestBody TimeSlotDto request) {
String validationError = validate(request);
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())) {
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
if (hasDuplicate(request, null)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
TimeSlot timeSlot = new TimeSlot();
@@ -53,9 +172,8 @@ public class TimeSlotAdminController {
if (validationError != null) {
return ResponseEntity.badRequest().body(Map.of("message", validationError));
}
if (timeSlotRepository.existsByOrderNumber(request.orderNumber())
&& !timeSlot.getOrderNumber().equals(request.orderNumber())) {
return ResponseEntity.badRequest().body(Map.of("message", "Слот с таким номером пары уже существует"));
if (hasDuplicate(request, id)) {
return ResponseEntity.badRequest().body(Map.of("message", "В выбранной сетке уже есть слот с таким номером пары"));
}
apply(timeSlot, request);
@@ -67,14 +185,21 @@ public class TimeSlotAdminController {
if (!timeSlotRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
try {
timeSlotRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Временной слот удалён"));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.badRequest().body(Map.of("message", "Нельзя удалить слот, который используется в правилах расписания"));
}
}
private String validate(TimeSlotDto request) {
if (request.orderNumber() == null || request.orderNumber() <= 0) {
return "Номер пары должен быть больше нуля";
}
if (request.scopeId() == null || timeSlotScopeRepository.findById(request.scopeId()).isEmpty()) {
return "Выберите сетку времени";
}
if (request.startTime() == null || request.endTime() == null) {
return "Время начала и окончания обязательно";
}
@@ -86,6 +211,8 @@ public class TimeSlotAdminController {
private void apply(TimeSlot timeSlot, TimeSlotDto request) {
timeSlot.setOrderNumber(request.orderNumber());
timeSlot.setTimeSlotScope(timeSlotScopeRepository.findById(request.scopeId())
.orElseThrow(() -> new IllegalArgumentException("Сетка времени не найдена")));
timeSlot.setStartTime(request.startTime());
timeSlot.setEndTime(request.endTime());
int duration = request.durationMinutes() != null && request.durationMinutes() > 0
@@ -94,13 +221,83 @@ public class TimeSlotAdminController {
timeSlot.setDurationMinutes(duration);
}
private boolean hasDuplicate(TimeSlotDto request, Long currentId) {
return timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(request.scopeId(), request.orderNumber())
.filter(existing -> currentId == null || !existing.getId().equals(currentId))
.isPresent();
}
private List<TimeSlot> effectiveSlots(LocalDate date) {
TimeSlotScope defaultScope = defaultScope();
TimeSlotScope effectiveScope = effectiveScope(date);
List<TimeSlot> defaultSlots = timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(defaultScope.getId());
if (effectiveScope.getId().equals(defaultScope.getId())) {
return defaultSlots;
}
Map<Integer, TimeSlot> slotsByOrder = new LinkedHashMap<>();
defaultSlots.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
timeSlotRepository.findAllByTimeSlotScopeIdOrderByOrderNumberAsc(effectiveScope.getId())
.forEach(slot -> slotsByOrder.put(slot.getOrderNumber(), slot));
return new ArrayList<>(slotsByOrder.values());
}
private TimeSlotScope effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()))
.orElseGet(this::defaultScope);
}
private TimeSlotScope defaultScope() {
return timeSlotScopeRepository.findFirstByApplyModeOrderByDisplayOrderAsc(APPLY_MODE_DEFAULT)
.orElseThrow(() -> new IllegalStateException("Базовая сетка времени не настроена"));
}
private int nextManualDisplayOrder() {
return timeSlotScopeRepository.findAllByOrderByDisplayOrderAscNameAsc().stream()
.map(TimeSlotScope::getDisplayOrder)
.filter(value -> value != null)
.max(Integer::compareTo)
.orElse(100) + 10;
}
private TimeSlotDto toDto(TimeSlot timeSlot) {
TimeSlotScope scope = timeSlot.getTimeSlotScope();
return new TimeSlotDto(
timeSlot.getId(),
timeSlot.getOrderNumber(),
scope == null ? null : scope.getId(),
scope == null ? null : scope.getName(),
scope == null ? null : scope.getApplyMode(),
scope == null ? null : scope.getDayOfWeek(),
timeSlot.getStartTime(),
timeSlot.getEndTime(),
timeSlot.getDurationMinutes()
);
}
private TimeSlotScopeDto toScopeDto(TimeSlotScope scope) {
return new TimeSlotScopeDto(
scope.getId(),
scope.getCode(),
scope.getName(),
scope.getApplyMode(),
scope.getDayOfWeek(),
scope.getSystemScope(),
scope.getDisplayOrder()
);
}
private TimeSlotDateAssignmentDto toAssignmentDto(TimeSlotDateAssignment assignment) {
TimeSlotScope scope = assignment.getTimeSlotScope();
return new TimeSlotDateAssignmentDto(
assignment.getId(),
assignment.getDate(),
scope.getId(),
scope.getName()
);
}
}

View File

@@ -5,6 +5,8 @@ import java.util.List;
public class ClassroomRequest {
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private List<Long> equipmentIds;
@@ -24,6 +26,22 @@ public class ClassroomRequest {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -7,16 +7,21 @@ public class ClassroomResponse {
private Long id;
private String name;
private Integer capacity;
private String building;
private Integer floor;
private Boolean isAvailable;
private List<Equipment> equipments;
public ClassroomResponse() {
}
public ClassroomResponse(Long id, String name, Integer capacity, Boolean isAvailable, List<Equipment> equipments) {
public ClassroomResponse(Long id, String name, Integer capacity, String building, Integer floor,
Boolean isAvailable, List<Equipment> equipments) {
this.id = id;
this.name = name;
this.capacity = capacity;
this.building = building;
this.floor = floor;
this.isAvailable = isAvailable;
this.equipments = equipments;
}
@@ -45,6 +50,22 @@ public class ClassroomResponse {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TimeSlotDateAssignmentDto(
Long id,
LocalDate date,
Long scopeId,
String scopeName
) {
}

View File

@@ -5,6 +5,10 @@ import java.time.LocalTime;
public record TimeSlotDto(
Long id,
Integer orderNumber,
Long scopeId,
String scopeName,
String scopeApplyMode,
Integer scopeDayOfWeek,
LocalTime startTime,
LocalTime endTime,
Integer durationMinutes

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
public record TimeSlotScopeDto(
Long id,
String code,
String name,
String applyMode,
Integer dayOfWeek,
Boolean systemScope,
Integer displayOrder
) {
}

View File

@@ -18,6 +18,11 @@ public class Classroom {
@Column(nullable = false)
private Integer capacity;
@Column(length = 50)
private String building;
private Integer floor;
@Column(name = "is_available", nullable = false)
private Boolean isAvailable = true;
@@ -52,6 +57,22 @@ public class Classroom {
this.capacity = capacity;
}
public String getBuilding() {
return building;
}
public void setBuilding(String building) {
this.building = building;
}
public Integer getFloor() {
return floor;
}
public void setFloor(Integer floor) {
this.floor = floor;
}
public Boolean getIsAvailable() {
return isAvailable;
}

View File

@@ -15,6 +15,10 @@ public class TimeSlot {
@Column(name = "order_number", nullable = false)
private Integer orderNumber;
@ManyToOne(optional = false)
@JoinColumn(name = "time_slot_scope_id", nullable = false)
private TimeSlotScope timeSlotScope;
@Column(name = "start_time", nullable = false)
private LocalTime startTime;
@@ -40,6 +44,14 @@ public class TimeSlot {
this.orderNumber = orderNumber;
}
public TimeSlotScope getTimeSlotScope() {
return timeSlotScope;
}
public void setTimeSlotScope(TimeSlotScope timeSlotScope) {
this.timeSlotScope = timeSlotScope;
}
public LocalTime getStartTime() {
return startTime;
}

View File

@@ -0,0 +1,45 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "time_slot_date_assignments")
public class TimeSlotDateAssignment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "assignment_date", nullable = false, unique = true)
private LocalDate date;
@ManyToOne(optional = false)
@JoinColumn(name = "time_slot_scope_id", nullable = false)
private TimeSlotScope timeSlotScope;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public TimeSlotScope getTimeSlotScope() {
return timeSlotScope;
}
public void setTimeSlotScope(TimeSlotScope timeSlotScope) {
this.timeSlotScope = timeSlotScope;
}
}

View File

@@ -0,0 +1,86 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "time_slot_scopes")
public class TimeSlotScope {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String code;
@Column(nullable = false, length = 120)
private String name;
@Column(name = "apply_mode", nullable = false, length = 20)
private String applyMode;
@Column(name = "day_of_week")
private Integer dayOfWeek;
@Column(name = "system_scope", nullable = false)
private Boolean systemScope = false;
@Column(name = "display_order", nullable = false)
private Integer displayOrder = 100;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getApplyMode() {
return applyMode;
}
public void setApplyMode(String applyMode) {
this.applyMode = applyMode;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public Boolean getSystemScope() {
return systemScope;
}
public void setSystemScope(Boolean systemScope) {
this.systemScope = systemScope;
}
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
}

View File

@@ -0,0 +1,19 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotDateAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface TimeSlotDateAssignmentRepository extends JpaRepository<TimeSlotDateAssignment, Long> {
Optional<TimeSlotDateAssignment> findByDate(LocalDate date);
List<TimeSlotDateAssignment> findAllByOrderByDateAsc();
List<TimeSlotDateAssignment> findByDateBetweenOrderByDateAsc(LocalDate startDate, LocalDate endDate);
boolean existsByTimeSlotScopeId(Long scopeId);
}

View File

@@ -4,10 +4,15 @@ import com.magistr.app.model.TimeSlot;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
List<TimeSlot> findAllByOrderByOrderNumberAsc();
List<TimeSlot> findAllByOrderByTimeSlotScopeDisplayOrderAscOrderNumberAscStartTimeAsc();
boolean existsByOrderNumber(Integer orderNumber);
List<TimeSlot> findAllByTimeSlotScopeIdOrderByOrderNumberAsc(Long scopeId);
Optional<TimeSlot> findByTimeSlotScopeIdAndOrderNumber(Long scopeId, Integer orderNumber);
boolean existsByTimeSlotScopeId(Long scopeId);
}

View File

@@ -0,0 +1,18 @@
package com.magistr.app.repository;
import com.magistr.app.model.TimeSlotScope;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface TimeSlotScopeRepository extends JpaRepository<TimeSlotScope, Long> {
List<TimeSlotScope> findAllByOrderByDisplayOrderAscNameAsc();
Optional<TimeSlotScope> findByCode(String code);
Optional<TimeSlotScope> findFirstByApplyModeOrderByDisplayOrderAsc(String applyMode);
Optional<TimeSlotScope> findByApplyModeAndDayOfWeek(String applyMode, Integer dayOfWeek);
}

View File

@@ -4,6 +4,9 @@ import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.*;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.TimeSlotDateAssignmentRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.TimeSlotScopeRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,21 +25,31 @@ public class ScheduleGeneratorService {
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final long MAX_RANGE_DAYS = 120;
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private final ScheduleRuleRepository scheduleRuleRepository;
private final GroupRepository groupRepository;
private final UserRepository userRepository;
private final AcademicDateService academicDateService;
private final TimeSlotRepository timeSlotRepository;
private final TimeSlotScopeRepository timeSlotScopeRepository;
private final TimeSlotDateAssignmentRepository dateAssignmentRepository;
private final Map<String, Integer> consumedHoursCache = new ConcurrentHashMap<>();
public ScheduleGeneratorService(ScheduleRuleRepository scheduleRuleRepository,
GroupRepository groupRepository,
UserRepository userRepository,
AcademicDateService academicDateService) {
AcademicDateService academicDateService,
TimeSlotRepository timeSlotRepository,
TimeSlotScopeRepository timeSlotScopeRepository,
TimeSlotDateAssignmentRepository dateAssignmentRepository) {
this.scheduleRuleRepository = scheduleRuleRepository;
this.groupRepository = groupRepository;
this.userRepository = userRepository;
this.academicDateService = academicDateService;
this.timeSlotRepository = timeSlotRepository;
this.timeSlotScopeRepository = timeSlotScopeRepository;
this.dateAssignmentRepository = dateAssignmentRepository;
}
public List<RenderedLessonDto> buildScheduleForGroup(Long groupId, LocalDate startDate, LocalDate endDate) {
@@ -247,7 +260,7 @@ public class ScheduleGeneratorService {
List<StudentGroup> groups,
int consumedBeforeLesson,
int remainingAfterLesson) {
TimeSlot timeSlot = slot.getTimeSlot();
TimeSlot timeSlot = resolveEffectiveTimeSlot(slot.getTimeSlot(), date);
List<StudentGroup> sortedGroups = groups.stream()
.sorted(Comparator.comparing(StudentGroup::getName))
.toList();
@@ -283,6 +296,25 @@ public class ScheduleGeneratorService {
);
}
private TimeSlot resolveEffectiveTimeSlot(TimeSlot baseSlot, LocalDate date) {
if (baseSlot == null || baseSlot.getOrderNumber() == null) {
return baseSlot;
}
return effectiveScope(date)
.flatMap(scope -> timeSlotRepository.findByTimeSlotScopeIdAndOrderNumber(
scope.getId(),
baseSlot.getOrderNumber()))
.orElse(baseSlot);
}
private Optional<TimeSlotScope> effectiveScope(LocalDate date) {
return dateAssignmentRepository.findByDate(date)
.map(TimeSlotDateAssignment::getTimeSlotScope)
.or(() -> timeSlotScopeRepository.findByApplyModeAndDayOfWeek(
APPLY_MODE_WEEKDAY,
date.getDayOfWeek().getValue()));
}
public static String dayName(DayOfWeek dayOfWeek) {
String value = dayOfWeek.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru-RU"));
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru-RU")) + value.substring(1);

View File

@@ -236,20 +236,55 @@ CREATE TABLE IF NOT EXISTS teacher_lesson_types (
-- Динамическое расписание: календарь, слоты и правила
-- ==========================================
CREATE TABLE IF NOT EXISTS time_slot_scopes (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
apply_mode VARCHAR(20) NOT NULL,
day_of_week INT NULL,
system_scope BOOLEAN NOT NULL DEFAULT FALSE,
display_order INT NOT NULL DEFAULT 100,
CONSTRAINT chk_time_slot_scopes_mode CHECK (apply_mode IN ('DEFAULT', 'WEEKDAY', 'MANUAL')),
CONSTRAINT chk_time_slot_scopes_day CHECK (day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7),
CONSTRAINT chk_time_slot_scopes_weekday CHECK (
(apply_mode = 'WEEKDAY' AND day_of_week IS NOT NULL)
OR (apply_mode <> 'WEEKDAY' AND day_of_week IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_default
ON time_slot_scopes(apply_mode)
WHERE apply_mode = 'DEFAULT';
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
ON time_slot_scopes(day_of_week)
WHERE apply_mode = 'WEEKDAY';
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
order_number INT NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
duration_minutes INT NOT NULL,
CONSTRAINT chk_time_slots_order_positive CHECK (order_number > 0),
CONSTRAINT chk_time_slots_time_range CHECK (start_time < end_time),
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0),
CONSTRAINT uq_time_slots_order UNIQUE (order_number),
CONSTRAINT uq_time_slots_range UNIQUE (start_time, end_time)
CONSTRAINT chk_time_slots_duration_positive CHECK (duration_minutes > 0)
);
INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VALUES
CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
ON time_slots(time_slot_scope_id, order_number);
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
FROM (VALUES
(1, '08:00', '09:30', 90),
(2, '09:40', '11:10', 90),
(3, '11:40', '13:10', 90),
@@ -257,7 +292,19 @@ INSERT INTO time_slots (order_number, start_time, end_time, duration_minutes) VA
(5, '15:00', '16:30', 90),
(6, '16:50', '18:20', 90),
(7, '18:30', '20:00', 90)
ON CONFLICT (start_time, end_time) DO NOTHING;
) AS data(order_number, start_time, end_time, duration_minutes)
CROSS JOIN time_slot_scopes scope
WHERE scope.code IN ('default', 'saturday')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
id BIGSERIAL PRIMARY KEY,
assignment_date DATE NOT NULL UNIQUE,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_time_slot_date_assignments_scope
ON time_slot_date_assignments(time_slot_scope_id);
CREATE TABLE IF NOT EXISTS academic_years (
id BIGSERIAL PRIMARY KEY,
@@ -522,7 +569,8 @@ FROM (VALUES
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN time_slots ts ON ts.order_number = data.time_slot_order
JOIN time_slot_scopes scope ON scope.code = 'default'
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
JOIN users teacher ON teacher.username = data.teacher_username
JOIN classrooms classroom ON classroom.name = data.classroom_name
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
@@ -555,7 +603,9 @@ COMMENT ON TABLE users IS 'Пользователи системы (студен
COMMENT ON TABLE departments IS 'Кафедры';
COMMENT ON TABLE specialties IS 'Специальности';
COMMENT ON TABLE specialty_profiles IS 'Профили обучения внутри специальности';
COMMENT ON TABLE time_slot_scopes IS 'Именованные сетки времени: базовая, автоматическая субботняя и ручные сетки';
COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий';
COMMENT ON TABLE time_slot_date_assignments IS 'Ручное назначение сетки времени на конкретную дату';
COMMENT ON TABLE academic_years IS 'Учебные годы';
COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';