много всего и тестовые данные

This commit is contained in:
Zuev
2026-09-06 19:17:06 +03:00
parent a1c4ee4298
commit e3b6797713
39 changed files with 2121 additions and 521 deletions

View File

@@ -62,8 +62,14 @@ docker compose logs -f backend
## Критические правила для агентов ## Критические правила для агентов
### Flyway миграции ### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу) - До отдельного решения владельца проект работает в режиме пересобираемого baseline:
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` **все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
только в `V1__init.sql`**.
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) - Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
### Языковые требования ### Языковые требования

View File

@@ -63,18 +63,22 @@
**Файлы:** **Файлы:**
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265` - `backend/src/main/resources/db/migration/V2__test_data.sql`
- `docs/README.md:45-54` - `docs/README.md`
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные. **Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями. **Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
**Как исправить:** **Как исправить:**
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены. - Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
dev/test-профилем.
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом. - Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
- Разнести demo/dev seed и обязательную production-схему.
- Ротировать пароли уже созданных tenant-БД. - Ротировать пароли уже созданных tenant-БД.
## Высокий приоритет ## Высокий приоритет

View File

@@ -62,6 +62,20 @@ public class AcademicCalendarController {
.toList(); .toList();
} }
@GetMapping("/page")
@Transactional(readOnly = true)
public PageResponse<AcademicCalendarDto> getCalendarsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long academicYearId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return PageResponse.from(calendarRepository.searchOptions(
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
.map(this::toCalendarDto));
}
@GetMapping("/options") @GetMapping("/options")
public List<AcademicCalendarDto> searchCalendarOptions( public List<AcademicCalendarDto> searchCalendarOptions(
@RequestParam(defaultValue = "") String query, @RequestParam(defaultValue = "") String query,

View File

@@ -2,6 +2,7 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles; import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto; import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Role; import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException; import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService; import com.magistr.app.service.ScheduleRuleService;
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
return scheduleRuleService.getAll(semesterId, groupId, versionId); return scheduleRuleService.getAll(semesterId, groupId, versionId);
} }
@GetMapping("/page")
public PageResponse<ScheduleRuleDto> getPage(
@RequestParam Long versionId,
@RequestParam(defaultValue = "") String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return scheduleRuleService.getPage(versionId, query, page, size);
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) { public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
return ResponseEntity.ok(scheduleRuleService.getById(id)); return ResponseEntity.ok(scheduleRuleService.getById(id));

View File

@@ -3,6 +3,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule; import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -13,6 +15,23 @@ import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> { public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
@Query("""
select r.id from ScheduleRule r
join r.scheduleVersion version
join r.subject subject
where r.status <> 'ARCHIVED'
and version.id = :versionId
and (:query = '' or str(r.id) = :query
or lower(subject.name) like concat('%', :query, '%')
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
order by r.id desc
""")
Page<Long> searchPageIds(
@Param("versionId") Long versionId, @Param("query") String query,
Pageable pageable);
@Query(""" @Query("""
select distinct r select distinct r
from ScheduleRule r from ScheduleRule r
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
where r.id = :id where r.id = :id
""") """)
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id); Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
@Query("""
select distinct r
from ScheduleRule r
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.scheduleVersion
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where r.id in :ids
""")
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
} }

View File

@@ -1,6 +1,7 @@
package com.magistr.app.service; package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto; import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.dto.ScheduleRuleSlotDto; import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext; import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.Classroom; import com.magistr.app.model.Classroom;
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository; import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository; import com.magistr.app.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Objects; import java.util.Objects;
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository; this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
} }
@Transactional(readOnly = true)
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
var ids = scheduleRuleRepository.searchPageIds(versionId,
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
if (ids.isEmpty()) {
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
ids.getTotalPages(), ids.isFirst(), ids.isLast());
}
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
return PageResponse.from(ids.map(details::get));
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) { public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null); return getAll(semesterId, groupId, null);

View File

@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties ( CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO specialties (name, specialty_code) VALUES
('Информационная безопасность', '10.03.01'),
('Информатика и вычислительная техника', '09.03.01'),
('Программная инженерия', '09.03.04')
ON CONFLICT (specialty_code) DO NOTHING;
CREATE TABLE IF NOT EXISTS specialty_profiles ( CREATE TABLE IF NOT EXISTS specialty_profiles (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE, specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
@@ -57,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name) CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
); );
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Без профиля'
FROM specialties
ON CONFLICT (specialty_id, name) DO NOTHING;
-- ========================================== -- ==========================================
-- Пользователи и роли -- Пользователи и роли
-- ========================================== -- ==========================================
@@ -83,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
archive_reason TEXT archive_reason TEXT
); );
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -189,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id) ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL; WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
WHERE u.role = 'TEACHER'
AND NOT EXISTS (
SELECT 1
FROM teacher_department_assignments existing
WHERE existing.teacher_id = u.id
);
CREATE TABLE IF NOT EXISTS teacher_creation_requests ( CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id), department_id BIGINT NOT NULL REFERENCES departments(id),
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO education_forms (name) VALUES
('Бакалавриат'),
('Магистратура'),
('Специалитет')
ON CONFLICT (name) DO NOTHING;
-- ========================================== -- ==========================================
-- Учебные группы -- Учебные группы
-- ========================================== -- ==========================================
@@ -272,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
archive_reason TEXT archive_reason TEXT
); );
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
FROM (VALUES
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
JOIN education_forms ef ON ef.name = data.education_form_name
JOIN departments dept ON dept.name = data.department_name
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
-- ========================================== -- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1") -- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ========================================== -- ==========================================
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_subgroup_student_capacity(); EXECUTE FUNCTION validate_subgroup_student_capacity();
INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
sg.id,
data.name,
CASE
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
END
FROM student_groups sg
CROSS JOIN (VALUES
('Подгруппа 1', 1),
('Подгруппа 2', 2)
) AS data(name, part_number)
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, name) DO NOTHING;
-- ========================================== -- ==========================================
-- Справочники -- Справочники
-- ========================================== -- ==========================================
@@ -414,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
COMMENT ON INDEX uq_subjects_name_ci IS COMMENT ON INDEX uq_subjects_name_ci IS
'Глобально уникальное название дисциплины без учёта регистра'; 'Глобально уникальное название дисциплины без учёта регистра';
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS subject_comments ( CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id), subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
duration_minutes INT DEFAULT 90 duration_minutes INT DEFAULT 90
); );
INSERT INTO lesson_types (name, color_code) VALUES
('Лекция', '#FF6B6B'),
('Практика', '#4ECDC4'),
('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;
-- Оборудование -- Оборудование
CREATE TABLE IF NOT EXISTS equipments ( CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории -- Аудитории
CREATE TABLE IF NOT EXISTS classrooms ( CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
archive_reason TEXT archive_reason TEXT
); );
INSERT INTO classrooms (name, capacity, building, floor) VALUES
('101 Ленинская', 120, 'Главный корпус', 1),
('202 IT Lab', 20, 'Корпус IT', 2),
('303 Обычная', 30, 'Главный корпус', 3)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many) -- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments ( CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
@@ -503,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
PRIMARY KEY (classroom_id, equipment_id) PRIMARY KEY (classroom_id, equipment_id)
); );
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
WHEN e.name = 'ПК' THEN 1
ELSE 1
END
FROM classrooms c, equipments e
WHERE
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status); CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status); CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status); CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
@@ -575,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
ON time_slot_scopes(day_of_week) ON time_slot_scopes(day_of_week)
WHERE apply_mode = 'WEEKDAY'; 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 ( CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE, time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
@@ -597,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
CREATE INDEX IF NOT EXISTS idx_time_slots_scope 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),
(4, '13:20', '14:50', 90),
(5, '15:00', '16:30', 90),
(6, '16:50', '18:20', 90),
(7, '18:30', '20:00', 90)
) 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 ( CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
assignment_date DATE NOT NULL UNIQUE, assignment_date DATE NOT NULL UNIQUE,
@@ -629,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date) CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
); );
INSERT INTO academic_years (title, start_date, end_date) VALUES
('2024-2025', '2024-09-01', '2025-06-30'),
('2025-2026', '2025-09-01', '2026-06-30')
ON CONFLICT (title) DO NOTHING;
CREATE TABLE IF NOT EXISTS semesters ( CREATE TABLE IF NOT EXISTS semesters (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
@@ -645,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type) CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
); );
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
description TEXT description TEXT
); );
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendars ( CREATE TABLE IF NOT EXISTS academic_calendars (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
@@ -700,21 +536,6 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id); ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
SELECT
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
ay.id,
specialty.id,
profile.id,
study_form.id,
4
FROM academic_years ay
CROSS JOIN education_forms study_form
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
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_periods ( CREATE TABLE IF NOT EXISTS academic_calendar_periods (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -754,24 +575,6 @@ AS $$
) + 1 ) + 1
$$; $$;
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
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);
CREATE TABLE IF NOT EXISTS academic_calendar_subjects ( CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE, calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -800,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
ON student_group_calendar_assignments(group_id, academic_year_id); ON student_group_calendar_assignments(group_id, academic_year_id);
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
SELECT
sg.id,
ay.id,
calendar.id
FROM student_groups sg
JOIN academic_years ay ON ay.title = '2025-2026'
JOIN academic_calendars calendar
ON calendar.academic_year_id = ay.id
AND calendar.specialty_id = sg.specialty_id
AND calendar.specialty_profile_id = sg.specialty_profile_id
AND calendar.study_form_id = sg.education_form_id
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
CREATE TABLE IF NOT EXISTS schedule_rules ( CREATE TABLE IF NOT EXISTS schedule_rules (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id), subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups(); EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
CREATE TEMP TABLE tmp_default_rules AS
SELECT
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
rule_key,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM (
SELECT
data.rule_key,
subj.id AS subject_id,
sem.id AS semester_id,
data.lecture_academic_hours,
data.laboratory_academic_hours,
data.practice_academic_hours,
data.lecture_start_week,
data.laboratory_start_week,
data.practice_start_week
FROM (VALUES
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
) AS data(
rule_key,
subject_name,
year_title,
semester_type,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
JOIN subjects subj ON subj.name = data.subject_name
JOIN academic_years ay ON ay.title = data.year_title
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
) prepared_rules;
INSERT INTO schedule_rules (
id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
SELECT
schedule_rule_id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM tmp_default_rules;
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
SELECT
rules.schedule_rule_id,
sg.id
FROM (VALUES
('stream_math', 'ИВТ-21-1'),
('stream_math', 'ИБ-41м'),
('ivt_informatics_lab', 'ИВТ-21-1'),
('ivt_databases_practice', 'ИВТ-21-1'),
('ib_philosophy', 'ИБ-41м')
) AS data(rule_key, group_name)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN student_groups sg ON sg.name = data.group_name
ON CONFLICT DO NOTHING;
INSERT INTO schedule_rule_slots (
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
)
SELECT
rules.schedule_rule_id,
data.day_of_week,
data.parity,
ts.id,
teacher.id,
classroom.id,
lt.id,
data.lesson_format
FROM (VALUES
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
('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_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;
-- ========================================== -- ==========================================
-- Функция обновления timestamp -- Функция обновления timestamp
-- ========================================== -- ==========================================
@@ -1371,7 +1044,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
cancel_violations, cancel_violations,
move_violations, move_violations,
replace_violations, replace_violations,
@@ -1470,7 +1143,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
lecture_violations, lecture_violations,
laboratory_violations, laboratory_violations,
practice_violations practice_violations
@@ -1511,7 +1184,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23505', ERRCODE = '23505',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
duplicate_slot_groups duplicate_slot_groups
); );
END IF; END IF;
@@ -1577,7 +1250,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
duration_violations, duration_violations,
overlap_violations, overlap_violations,
scope_violations scope_violations
@@ -1733,7 +1406,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
academic_year_overlap_violations, academic_year_overlap_violations,
semester_bounds_violations, semester_bounds_violations,
semester_overlap_violations semester_overlap_violations
@@ -1864,7 +1537,7 @@ BEGIN
RAISE EXCEPTION USING RAISE EXCEPTION USING
ERRCODE = '23514', ERRCODE = '23514',
MESSAGE = format( MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.', 'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
assignment_violations, assignment_violations,
calendar_period_violations, calendar_period_violations,
calendar_subject_violations calendar_subject_violations
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
CREATE INDEX idx_schedule_versions_semester_history CREATE INDEX idx_schedule_versions_semester_history
ON schedule_versions(semester_id, version_number DESC); ON schedule_versions(semester_id, version_number DESC);
INSERT INTO schedule_versions (
semester_id,
version_number,
name,
status,
created_at,
published_at
)
SELECT
semester.id,
1,
'Опубликованная версия 1',
'PUBLISHED',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM semesters semester;
ALTER TABLE schedule_rules ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT; ADD COLUMN schedule_version_id BIGINT;
UPDATE schedule_rules rule
SET schedule_version_id = version.id,
version_group_id = COALESCE(rule.version_group_id, rule.id)
FROM schedule_versions version
WHERE version.semester_id = rule.semester_id
AND version.status = 'PUBLISHED';
ALTER TABLE schedule_rules ALTER TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL, ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version ADD CONSTRAINT fk_schedule_rules_version
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
CREATE INDEX idx_schedule_version_history_version CREATE INDEX idx_schedule_version_history_version
ON schedule_version_history(version_id, created_at DESC, id DESC); ON schedule_version_history(version_id, created_at DESC, id DESC);
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
FROM schedule_versions;
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
FROM schedule_versions;
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания'; COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED'; COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик'; COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';

View File

@@ -1,24 +0,0 @@
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
academic_year.start_date,
academic_year.end_date,
theory_activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year
ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types theory_activity
ON theory_activity.code = 'Т'
CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
WHERE NOT EXISTS (
SELECT 1
FROM academic_calendar_periods existing_period
WHERE existing_period.calendar_id = calendar.id
);

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,30 @@ import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest { class AcademicCalendarControllerTest {
@Test
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
void returnsBoundedCalendarPage() throws Exception {
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
.and(org.springframework.data.domain.Sort.by("title", "id")));
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
var controller = new AcademicCalendarController(repository,
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.get("/api/admin/academic-calendars/page")
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
.param("page", "-1").param("size", "1000"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
}
@Test @Test
void createCalendarReturnsCalendarCreatedByTransactionalService() { void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class); AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);

View File

@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
.build(); .build();
} }
@Test
@org.junit.jupiter.api.DisplayName("Страница правил принимает фильтры и возвращает метаданные пагинации")
void returnsRulesPage() throws Exception {
when(service.getPage(7L, "Математика", 2, 25)).thenReturn(
new com.magistr.app.dto.PageResponse<>(List.of(), 2, 25, 50, 2, false, true));
mockMvc.perform(get("/api/admin/schedule-rules/page")
.param("versionId", "7").param("query", "Математика").param("page", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.page").value(2))
.andExpect(jsonPath("$.size").value(25))
.andExpect(jsonPath("$.totalItems").value(50));
}
@Test @Test
void returnsBadRequestWithRussianValidationMessage() throws Exception { void returnsBadRequestWithRussianValidationMessage() throws Exception {
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException( when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(

View File

@@ -16,7 +16,7 @@ import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers @Testcontainers
@DisplayName("Миграция начальных периодов календарного графика") @DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest { class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container @Container
@@ -33,6 +33,7 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
long emptyCalendarId; long emptyCalendarId;
long configuredCalendarId; long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
prepareCustomCalendarPrerequisites(connection);
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3); emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2); configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId); insertConfiguredPeriod(connection, configuredCalendarId);
@@ -52,6 +53,51 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
} }
} }
@Test
@DisplayName("V1 создаёт только схему, а V2 загружает полный тестовый набор")
void migrationSeparatesSchemaAndTestData() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT
(SELECT COUNT(*) FROM departments)
+ (SELECT COUNT(*) FROM users)
+ (SELECT COUNT(*) FROM student_groups)
+ (SELECT COUNT(*) FROM subjects)
+ (SELECT COUNT(*) FROM academic_years)
+ (SELECT COUNT(*) FROM schedule_rules)
"""))
.as("После V1 доменные таблицы должны быть пустыми")
.isZero();
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, "SELECT COUNT(*) FROM departments")).isEqualTo(41);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM specialties")).isEqualTo(42);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM users WHERE role = 'TEACHER'"))
.isEqualTo(151);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_groups")).isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subgroups")).isEqualTo(404);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subjects")).isEqualTo(229);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM classrooms")).isEqualTo(80);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendars")).isEqualTo(132);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendar_periods")).isEqualTo(576);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_group_calendar_assignments"))
.isEqualTo(202);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rules")).isEqualTo(610);
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rule_slots")).isEqualTo(610);
}
}
private Flyway flyway(String targetVersion) { private Flyway flyway(String targetVersion) {
var configuration = Flyway.configure() var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
@@ -75,10 +121,12 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
study_form_id, study_form_id,
course_count course_count
) )
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ? SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
FROM academic_calendars FROM academic_years academic_year
ORDER BY id JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
LIMIT 1 JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
JOIN education_forms study_form ON study_form.name = 'Тестовая форма'
WHERE academic_year.title = '2030-2031'
RETURNING id RETURNING id
""")) { """)) {
statement.setString(1, title); statement.setString(1, title);
@@ -90,6 +138,40 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
} }
} }
private void prepareCustomCalendarPrerequisites(Connection connection) throws SQLException {
try (var statement = connection.createStatement()) {
statement.executeUpdate("""
INSERT INTO departments (name, code)
VALUES ('Тестовая кафедра для V2', 9999)
""");
statement.executeUpdate("""
INSERT INTO specialties (name, specialty_code)
VALUES ('Тестовое направление для V2', '99.99.99')
""");
statement.executeUpdate("""
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Тестовый профиль для V2'
FROM specialties
WHERE specialty_code = '99.99.99'
""");
statement.executeUpdate("""
INSERT INTO education_forms (name)
VALUES ('Тестовая форма')
""");
statement.executeUpdate("""
INSERT INTO academic_years (title, start_date, end_date)
VALUES ('2030-2031', DATE '2030-09-01', DATE '2031-06-30')
""");
statement.executeUpdate("""
INSERT INTO academic_calendar_activity_types (
code, name, allow_schedule, color_code, display_order
) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10),
('К', 'Каникулы', FALSE, '#38bdf8', 30)
""");
}
}
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException { private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods ( INSERT INTO academic_calendar_periods (
@@ -140,6 +222,14 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
} }
} }
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
private record PeriodSummary(int count, private record PeriodSummary(int count,
int minCourse, int minCourse,
int maxCourse, int maxCourse,

View File

@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
flyway.migrate(); flyway.migrate();
assertThat(flyway.info().current()).isNotNull(); assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
long groupId = queryLong( long groupId = queryLong(
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
"23514", "23514",
"сохранённые назначения календарных графиков станут несовместимыми" "сохранённые назначения календарных графиков станут несовместимыми"
); );
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
assertSqlState( assertSqlState(
() -> updateInt( () -> updateInt(
connection, connection,
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
return Flyway.configure() return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
} }
@@ -541,6 +542,20 @@ class AcademicStructureInvariantIntegrationTest {
"""); """);
} }
private void deleteOtherCalendarAssignments(Connection connection,
long calendarId,
long preservedAssignmentId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
DELETE FROM student_group_calendar_assignments
WHERE calendar_id = ?
AND id <> ?
""")) {
statement.setLong(1, calendarId);
statement.setLong(2, preservedAssignmentId);
statement.executeUpdate();
}
}
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException { private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) { try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value); statement.setLong(1, value);

View File

@@ -37,8 +37,8 @@ class ScheduleOverrideMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия") .as("После полной миграции должна существовать текущая версия")
.isNotNull(); .isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()) assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V1") .as("Тестовые данные должны быть загружены миграцией V2")
.isEqualTo("1"); .isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection); SeedIds seedIds = loadSeedIds(connection);
@@ -149,7 +149,7 @@ class ScheduleOverrideMigrationIntegrationTest {
return Flyway.configure() return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
} }

View File

@@ -38,8 +38,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.as("После полной миграции должна существовать текущая версия") .as("После полной миграции должна существовать текущая версия")
.isNotNull(); .isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()) assertThat(flyway.info().current().getVersion().getVersion())
.as("Последней успешно применённой миграцией должна быть V1") .as("Тестовые данные должны быть загружены миграцией V2")
.isEqualTo("1"); .isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection); SeedIds seedIds = loadSeedIds(connection);
@@ -82,7 +82,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
return Flyway.configure() return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
} }

View File

@@ -93,7 +93,7 @@ class SubjectOwnershipInvariantIntegrationTest {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
flyway.clean(); flyway.clean();

View File

@@ -180,7 +180,7 @@ class TeacherDepartmentInvariantIntegrationTest {
return Flyway.configure() return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
} }

View File

@@ -30,13 +30,13 @@ class TeacherPreferencesMigrationIntegrationTest {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration") .locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1")) .target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false) .cleanDisabled(false)
.load(); .load();
flyway.clean(); flyway.clean();
flyway.migrate(); flyway.migrate();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """ assertThat(queryLong(connection, """
SELECT count(*) SELECT count(*)

View File

@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
flyway.migrate(); flyway.migrate();
assertThat(flyway.info().current()).isNotNull(); assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1"); assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) { try (Connection connection = POSTGRES.createConnection("")) {
long manualScopeId = insertManualScope(connection, "migration_checks"); long manualScopeId = insertManualScope(connection, "migration_checks");
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
} }
private Flyway baselineFlyway() { private Flyway baselineFlyway() {
return flyway("1"); return flyway("2");
} }
private Flyway flyway(String targetVersion) { private Flyway flyway(String targetVersion) {

View File

@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class, classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
properties = { properties = {
"spring.jpa.open-in-view=false", "spring.jpa.open-in-view=false",
"spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true",
"management.endpoint.health.validate-group-membership=false" "management.endpoint.health.validate-group-membership=false"
} }
) )
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
} }
@Test
@org.junit.jupiter.api.DisplayName("Страницы правил не пересекаются, поиск работает вне первой страницы")
void pagesRulesAndSearchesAcrossWholeVersion() {
Long versionId = jdbcTemplate.queryForObject("""
SELECT schedule_version_id FROM schedule_rules
WHERE status <> 'ARCHIVED' GROUP BY schedule_version_id
ORDER BY COUNT(*) DESC LIMIT 1
""", Long.class);
var first = service.getPage(versionId, "", 0, 25);
var second = service.getPage(versionId, "", 1, 25);
assertThat(first.items()).hasSize(25);
assertThat(second.items()).hasSize(25);
assertThat(first.totalItems()).isGreaterThan(50);
assertThat(first.items()).extracting(ScheduleRuleDto::id)
.doesNotContainAnyElementsOf(second.items().stream().map(ScheduleRuleDto::id).toList());
var rule = second.items().get(0);
assertThat(rule.slots()).isNotEmpty();
assertThat(service.getPage(versionId, rule.id().toString(), 0, 25).items())
.extracting(ScheduleRuleDto::id).contains(rule.id());
assertThat(service.getPage(versionId, rule.subjectName().toUpperCase(java.util.Locale.ROOT), 0, 25).items())
.isNotEmpty().allMatch(item -> item.subjectName().equals(rule.subjectName()));
assertThat(service.getPage(versionId, rule.groupNames().get(0), 0, 25).items())
.isNotEmpty().allMatch(item -> item.groupNames().contains(rule.groupNames().get(0)));
assertThat(service.getPage(versionId, "НетТакойДисциплины", 0, 25).totalItems()).isZero();
assertThat(service.getPage(versionId, "", 100000, 25).items()).isEmpty();
assertThat(service.getPage(versionId, "", -1, 1000).size()).isEqualTo(100);
}
@Autowired
private com.magistr.app.repository.AcademicCalendarRepository calendarRepository;
@Test
@org.junit.jupiter.api.DisplayName("Календарные графики ищутся по году с ограничением страницы в БД")
void pagesCalendarsForFutureYears() {
var page = calendarRepository.searchOptions("2027-2028", null, null, null, null,
org.springframework.data.domain.PageRequest.of(0, 25,
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
.and(org.springframework.data.domain.Sort.by("title", "id"))));
assertThat(page.getTotalElements()).isEqualTo(44);
assertThat(page.getContent()).hasSize(25).allMatch(calendar -> calendar.getAcademicYear().getTitle().equals("2027-2028"));
}
@Test @Test
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception { void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
assertThat(AopUtils.isAopProxy(service)).isTrue(); assertThat(AopUtils.isAopProxy(service)).isTrue();

View File

@@ -25,6 +25,11 @@
Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам.
Список графиков в интерфейсе загружается страницами по 25 записей (также доступны 50 и 100),
с серверным поиском и фильтром учебного года. В редакторах «Сетки» и «Дисциплины» график
выбирается через поисковый комбобокс, который загружает до 20 результатов и выбранную запись.
В V2 предусмотрены по 44 демонстрационных графика на 2025–2026, 2026–2027 и 2027–2028 годы.
## Коды активностей ## Коды активностей
| Код | Значение | Обычные пары | | Код | Значение | Обычные пары |
@@ -43,7 +48,8 @@
| `*` | Праздничный или нерабочий день | Нет | | `*` | Праздничный или нерабочий день | Нет |
| `=` | День вне учебного года или пустая ячейка графика | Нет | | `=` | День вне учебного года или пустая ячейка графика | Нет |
Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`. В seed-данных `true` установлен только для `Т`. Технически разрешение генерации задаётся полем `academic_calendar_activity_types.allow_schedule`.
В тестовых данных `V2__test_data.sql` значение `true` установлено только для `Т`.
## Правило генерации расписания ## Правило генерации расписания

View File

@@ -597,6 +597,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | | `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
| `GET` | `/api/admin/academic-calendars/page` | Страница графиков: `query`, `academicYearId`, `page`, `size` |
| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса | | `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
| `GET` | `/api/admin/academic-calendars/{id}` | Один график | | `GET` | `/api/admin/academic-calendars/{id}` | Один график |
| `POST` | `/api/admin/academic-calendars` | Создать график | | `POST` | `/api/admin/academic-calendars` | Создать график |
@@ -624,6 +625,13 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`. название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
Отдельного пользовательского названия у календарного графика нет. Отдельного пользовательского названия у календарного графика нет.
`GET /api/admin/academic-calendars/page` возвращает `PageResponse` с полями
`items`, `page`, `size`, `totalItems`, `totalPages`, `first`, `last`.
Нумерация страниц начинается с 0, размер по умолчанию 25, сервер ограничивает его диапазоном 1–100.
Поиск `query` без учёта регистра охватывает название графика, учебный год, код и название
специальности, профиль и форму обучения. Сортировка: начало учебного года по убыванию,
затем название и ID по возрастанию.
`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`, `GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск `specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
выполняется по составному названию, учебному году, коду/названию специальности, профилю и выполняется по составному названию, учебному году, коду/названию специальности, профилю и
@@ -718,11 +726,18 @@ CRUD доступен по:
| Метод | URL | Назначение | | Метод | URL | Назначение |
|-------|-----|------------| |-------|-----|------------|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` | | `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило | | `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило | | `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило | | `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило | | `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
`GET /api/admin/schedule-rules/page` возвращает тот же формат `PageResponse`;
`page` по умолчанию 0, `size` — 25 (ограничение 1–100). Правила выбранной версии,
кроме архивных, сортируются по ID по убыванию. `query` ищет по названию дисциплины,
названию группы или точному ID. Сначала БД выбирает страницу ID, затем загружаются
группы и слоты только этих правил; пагинация не выполняется в памяти над полной коллекцией.
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую. `timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED` Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`

View File

@@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом, курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения: дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или `V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
полностью настроенные сетки она не изменяет. без единого периода; частично или полностью настроенные сетки не изменяются.
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не

View File

@@ -953,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
Пара `semester_id + version_number` уникальна. Частичный индекс Пара `semester_id + version_number` уникальна. Частичный индекс
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на `uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после привязывает к ней тестовые правила. Для семестров, создаваемых через API после
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1 запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
`Основное расписание` и две записи истории: создание и публикацию. `Основное расписание` и две записи истории: создание и публикацию.
@@ -1177,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
### Правила работы ### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/` 1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) 2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД. триггеры и ограничения изменяются только в `V1__init.sql`.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
tenant-БД. Сброс выполняется только по прямой команде владельца.
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью `V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
пустой tenant-схеме пустой tenant-схеме
@@ -1188,19 +1193,46 @@ CHECK фиксирует допустимую форму каждого типа
| Файл | Описание | | Файл | Описание |
|------|----------| |------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии | | `V1__init.sql` | Полная schema-only baseline: таблицы, связи, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия, пожелания и заявки преподавателей, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии. Постоянных доменных данных нет |
| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет | | `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
### Этап разработки ### Этап разработки
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`. tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1. `V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема. production. Это сознательное ограничение текущего режима разработки; перед production-релизом
V2 необходимо сделать условной или исключить из production locations.
### Объём тестового набора V2
На чистой схеме V2 создаёт детерминированный связанный набор:
| Сущности | Количество |
|----------|-----------:|
| Кафедры / специальности / профили | 41 / 42 / 42 |
| Пользователи / преподаватели | 155 / 151 |
| Группы / подгруппы | 202 / 404 |
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
| Аудитории / связи с оборудованием | 80 / 174 |
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
| Версии / события истории | 8 / 16 |
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
исторические demo-аккаунты.
### Полный сброс БД (локально) ### Полный сброс БД (локально)

View File

@@ -251,18 +251,18 @@ public class AbsenceController {
### Правила ### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя 1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... постоянный DDL в ней запрещён
4. После добавления — перезапустите backend для применения 3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
5. Не сбрасывайте tenant-БД без прямой команды владельца
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. `V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам, отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только всем tenant-БД.
отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
### Применение ### Применение
@@ -270,6 +270,10 @@ public class AbsenceController {
# Локально — сброс и повтор всех миграций # Локально — сброс и повтор всех миграций
docker compose down -v && docker compose up -d docker compose down -v && docker compose up -d
# Интеграционная проверка V1/V2 на PostgreSQL через Testcontainers
cd backend
mvn -Dtest='com.magistr.app.migration.*IntegrationTest' test
# Продакшн — применить к существующим тенантам # Продакшн — применить к существующим тенантам
kubectl rollout restart deployment backend -n magistr kubectl rollout restart deployment backend -n magistr
``` ```

View File

@@ -4,13 +4,17 @@
## Статус ## Статус
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД. Новая модель является базовой схемой проекта и создаётся в
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы. Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
## База данных ## База данных
`V1__init.sql` создаёт и заполняет: `V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
связанным тестовым набором:
| Таблица | Назначение | | Таблица | Назначение |
|---------|------------| |---------|------------|

File diff suppressed because one or more lines are too long

View File

@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
| `admin` | `admin` | Администратор | | `admin` | `admin` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель | | `Тестовый преподаватель` | `1234567890` | Преподаватель |
Эти и остальные прежние демонстрационные записи загружаются миграцией
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
### Полезные команды ### Полезные команды
```bash ```bash

View File

@@ -37,7 +37,8 @@
2. Для группы находится назначенный календарный учебный график. 2. Для группы находится назначенный календарный учебный график.
3. По году начала обучения вычисляется текущий курс группы. 3. По году начала обучения вычисляется текущий курс группы.
4. По курсу и дате находится код активности. 4. По курсу и дате находится код активности.
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. 5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
`V2__test_data.sql` это `Т`.
6. Для разрешённого дня выбираются активные правила и слоты. 6. Для разрешённого дня выбираются активные правила и слоты.
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. 7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.

View File

@@ -27,7 +27,8 @@
## Проверочный сценарий ## Проверочный сценарий
1. Полностью сбросить локальную БД. 1. Полностью сбросить локальную БД.
2. Запустить приложение и применить `V1__init.sql`. 2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
`V2__test_data.sql`.
3. Создать профиль специальности. 3. Создать профиль специальности.
4. Создать календарный график и заполнить дневную сетку. 4. Создать календарный график и заполнить дневную сетку.
5. Назначить график группе. 5. Назначить график группе.

View File

@@ -1,4 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { AsyncCombobox } from '../async-combobox.js';
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js'; import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
import { academicCalendarWeekNumber } from './academic-calendar-grid.js'; import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
@@ -209,6 +212,14 @@ export async function initAcademicCalendar() {
let studyForms = []; let studyForms = [];
let activityTypes = []; let activityTypes = [];
let calendars = []; let calendars = [];
let calendarPage = 0;
let calendarSize = 25;
let calendarRequest = 0;
const calendarCache = new Map();
const calendarComboboxes = new Map();
const calendarSearch = document.getElementById('academic-calendars-search');
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
const calendarPagination = document.getElementById('academic-calendars-pagination');
let subjects = []; let subjects = [];
let calendarSubjectRows = []; let calendarSubjectRows = [];
let timeSlotScopes = []; let timeSlotScopes = [];
@@ -224,9 +235,27 @@ export async function initAcademicCalendar() {
let calendarDayTooltipPositionAnimation = null; let calendarDayTooltipPositionAnimation = null;
let loadedGridCalendarId = ''; let loadedGridCalendarId = '';
let loadedSubjectCalendarId = ''; let loadedSubjectCalendarId = '';
let subjectLoadRequest = 0;
let gridLoadRequest = 0;
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?'); const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?'); const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
select.innerHTML = '<option value="">Выберите график</option>';
calendarComboboxes.set(select, new AsyncCombobox(select, {
placeholder: 'Поиск по направлению, профилю, форме или году',
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
optionDescription: calendar => calendar.specialtyName || '',
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: 20 });
if (selectedId) params.set('selectedId', selectedId);
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
return items;
}
}));
});
bindEvents(); bindEvents();
try { try {
@@ -261,6 +290,14 @@ export async function initAcademicCalendar() {
}); });
}); });
academicYearsRefreshButton?.addEventListener('click', loadYears); academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarSearch?.addEventListener('input', debounce(() => {
calendarPage = 0;
loadCalendars();
}, 300));
calendarYearFilter?.addEventListener('change', () => {
calendarPage = 0;
loadCalendars();
});
calendarsRefreshButton?.addEventListener('click', loadCalendars); calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm); academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput); academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
@@ -363,6 +400,11 @@ export async function initAcademicCalendar() {
academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
renderAcademicYears(); renderAcademicYears();
populateYearSelects(); populateYearSelects();
const selectedYear = calendarYearFilter.value;
calendarYearFilter.innerHTML = '<option value="">Все учебные годы</option>' + academicYears.map(year =>
`<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
calendarYearFilter.value = selectedYear;
refreshSelectVisuals(calendarYearFilter);
} catch (error) { } catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
@@ -566,13 +608,28 @@ export async function initAcademicCalendar() {
} }
async function loadCalendars() { async function loadCalendars() {
const request = ++calendarRequest;
calendarPagination?.replaceChildren();
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>'; calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
try { try {
calendars = await api.get('/api/admin/academic-calendars'); const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
if (request !== calendarRequest) return;
if (calendarPage > 0 && calendarPage >= page.totalPages) {
calendarPage = Math.max(0, page.totalPages - 1);
return loadCalendars();
}
calendars = page.items;
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
renderCalendars(); renderCalendars();
populateEditorCalendarSelect(); renderPagination(calendarPagination, page, {
populateSubjectCalendarSelect(); label: 'графиков',
onPage: value => { calendarPage = value; loadCalendars(); },
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
});
} catch (error) { } catch (error) {
if (request !== calendarRequest) return;
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error; throw error;
} }
@@ -580,7 +637,7 @@ export async function initAcademicCalendar() {
function renderCalendars() { function renderCalendars() {
if (!calendars.length) { if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>'; calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не найдены</td></tr>';
return; return;
} }
calendarsTbody.innerHTML = calendars.map(calendar => ` calendarsTbody.innerHTML = calendars.map(calendar => `
@@ -620,6 +677,10 @@ export async function initAcademicCalendar() {
const saved = id const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload) ? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload); : await api.post('/api/admin/academic-calendars', payload);
calendarCache.set(String(saved.id), saved);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
});
const savedTitle = buildAcademicCalendarTitle(saved); const savedTitle = buildAcademicCalendarTitle(saved);
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success'); showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
resetCalendarForm(); resetCalendarForm();
@@ -654,7 +715,7 @@ export async function initAcademicCalendar() {
if (gridButton) { if (gridButton) {
activateCalendarTab('grid'); activateCalendarTab('grid');
editorCalendarSelect.value = gridButton.dataset.id; setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
refreshSelectVisuals(editorCalendarSelect); refreshSelectVisuals(editorCalendarSelect);
await loadCalendarGrid(); await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -663,7 +724,7 @@ export async function initAcademicCalendar() {
if (subjectsButton) { if (subjectsButton) {
activateCalendarTab('subjects'); activateCalendarTab('subjects');
subjectCalendarSelect.value = subjectsButton.dataset.id; setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
refreshSelectVisuals(subjectCalendarSelect); refreshSelectVisuals(subjectCalendarSelect);
await loadCalendarSubjects(); await loadCalendarSubjects();
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' }); subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -679,7 +740,22 @@ export async function initAcademicCalendar() {
}); });
if (!confirmed) return; if (!confirmed) return;
try { try {
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); const deletedId = deleteButton.dataset.id;
await api.delete('/api/admin/academic-calendars/' + deletedId);
calendarCache.delete(deletedId);
calendarComboboxes.forEach((combobox, select) => {
if (select.value === deletedId) {
setCalendarSelection(select, '');
if (select === editorCalendarSelect) {
loadedGridCalendarId = '';
calendarGrid.replaceChildren();
calendarTotals.replaceChildren();
clearGridDirty();
} else {
loadCalendarSubjects();
}
}
});
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars(); await loadCalendars();
} catch (error) { } catch (error) {
@@ -713,15 +789,18 @@ export async function initAcademicCalendar() {
} }
async function loadCalendarSubjects() { async function loadCalendarSubjects() {
const request = ++subjectLoadRequest;
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
subjectCalendarSelect.value = loadedSubjectCalendarId; setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
refreshSelectVisuals(subjectCalendarSelect); refreshSelectVisuals(subjectCalendarSelect);
return; return;
} }
if (request !== subjectLoadRequest) return;
populateSubjectSemesterSelect(); populateSubjectSemesterSelect();
calendarSubjectRows = []; calendarSubjectRows = [];
loadedSubjectCalendarId = '';
if (!calendar) { if (!calendar) {
loadedSubjectCalendarId = ''; loadedSubjectCalendarId = '';
clearSubjectDirty(); clearSubjectDirty();
@@ -731,11 +810,13 @@ export async function initAcademicCalendar() {
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>'; subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
try { try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
calendarSubjectRows = sortCalendarSubjectRows(rows || []); calendarSubjectRows = sortCalendarSubjectRows(rows || []);
loadedSubjectCalendarId = String(calendar.id); loadedSubjectCalendarId = String(calendar.id);
clearSubjectDirty(); clearSubjectDirty();
renderCalendarSubjects(); renderCalendarSubjects();
} catch (error) { } catch (error) {
if (request !== subjectLoadRequest) return;
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`; subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error'); showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
} }
@@ -744,6 +825,10 @@ export async function initAcademicCalendar() {
function addCalendarSubject() { function addCalendarSubject() {
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
const semesterNumber = Number(subjectSemesterSelect.value); const semesterNumber = Number(subjectSemesterSelect.value);
const subjectId = Number(subjectSelect.value); const subjectId = Number(subjectSelect.value);
if (!calendar || !semesterNumber || !subjectId) { if (!calendar || !semesterNumber || !subjectId) {
@@ -781,6 +866,10 @@ export async function initAcademicCalendar() {
async function saveCalendarSubjects() { async function saveCalendarSubjects() {
hideAlert('calendar-subject-alert'); hideAlert('calendar-subject-alert');
const calendar = selectedSubjectCalendar(); const calendar = selectedSubjectCalendar();
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
return;
}
if (!calendar) { if (!calendar) {
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error'); showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
return; return;
@@ -855,10 +944,11 @@ export async function initAcademicCalendar() {
} }
async function loadCalendarGrid() { async function loadCalendarGrid() {
const request = ++gridLoadRequest;
hideAlert('calendar-editor-alert'); hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar(); const calendar = selectedEditorCalendar();
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) { if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
editorCalendarSelect.value = loadedGridCalendarId; setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
refreshSelectVisuals(editorCalendarSelect); refreshSelectVisuals(editorCalendarSelect);
return; return;
} }
@@ -869,6 +959,7 @@ export async function initAcademicCalendar() {
try { try {
await loadTimeSlotDateAssignments(); await loadTimeSlotDateAssignments();
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
const gridRows = rows.length ? rows : buildDefaultGrid(calendar); const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows); renderCalendarGrid(gridRows);
loadedGridCalendarId = String(calendar.id); loadedGridCalendarId = String(calendar.id);
@@ -876,6 +967,7 @@ export async function initAcademicCalendar() {
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 }); trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) { } catch (error) {
if (request !== gridLoadRequest) return;
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
} }
} }
@@ -887,6 +979,10 @@ export async function initAcademicCalendar() {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return; return;
} }
if (String(calendar.id) !== loadedGridCalendarId) {
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id, calendarId: calendar.id,
courseNumber: Number(day.dataset.course), courseNumber: Number(day.dataset.course),
@@ -1535,31 +1631,12 @@ export async function initAcademicCalendar() {
syncSelects(fillActivitySelect); syncSelects(fillActivitySelect);
} }
function populateEditorCalendarSelect() { function setCalendarSelection(select, id) {
const current = editorCalendarSelect.value; const calendar = calendarCache.get(String(id));
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' + select.innerHTML = calendar
calendars.map(calendar => ? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>` : '<option value="">Выберите график</option>';
).join(''); calendarComboboxes.get(select)?.syncFromSelect();
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
syncSelects(editorCalendarSelect);
}
function populateSubjectCalendarSelect() {
if (!subjectCalendarSelect) return;
const current = subjectCalendarSelect.value;
subjectCalendarSelect.innerHTML = calendars.length
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
).join('')
: '<option value="">Нет графиков</option>';
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
subjectCalendarSelect.value = current;
}
populateSubjectSemesterSelect();
syncSelects(subjectCalendarSelect);
} }
function populateSubjectSemesterSelect() { function populateSubjectSemesterSelect() {
@@ -1603,11 +1680,11 @@ export async function initAcademicCalendar() {
} }
function selectedEditorCalendar() { function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); return calendarCache.get(String(editorCalendarSelect.value));
} }
function selectedSubjectCalendar() { function selectedSubjectCalendar() {
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value)); return calendarCache.get(String(subjectCalendarSelect?.value));
} }
function calendarSemesterCount(calendar) { function calendarSemesterCount(calendar) {
@@ -1795,6 +1872,7 @@ export async function initAcademicCalendar() {
function refreshSelectVisuals(...selects) { function refreshSelectVisuals(...selects) {
selects.filter(Boolean).forEach(select => { selects.filter(Boolean).forEach(select => {
calendarComboboxes.get(select)?.syncFromSelect();
if (select.customSelectInstance) { if (select.customSelectInstance) {
select.customSelectInstance.rebuildMenu(); select.customSelectInstance.rebuildMenu();
} }

View File

@@ -339,7 +339,7 @@ export function filterQualityProblems(problems, severity = '', category = '') {
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type]; const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category); const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
return severityMatches && categoryMatches; return severityMatches && categoryMatches;
}); }).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
} }
function problemCategory(type = '') { function problemCategory(type = '') {

View File

@@ -1,4 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { renderPagination } from '../pagination.js';
import { refreshCustomDropdowns } from '../dropdown.js';
import { import {
academicYearsFromSemesters, academicYearsFromSemesters,
resolveAcademicPeriod, resolveAcademicPeriod,
@@ -188,6 +190,14 @@ export async function initSchedule() {
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time'); const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = []; let rules = [];
let visualRules = [];
let rulesPage = 0;
let rulesSize = 25;
let rulesRequest = 0;
let visualRequest = 0;
let rulesVersionId = '';
const rulesSearch = document.getElementById('schedule-rules-search');
const rulesPagination = document.getElementById('schedule-rules-pagination');
let subjects = []; let subjects = [];
let groups = []; let groups = [];
let lessonTypes = []; let lessonTypes = [];
@@ -237,6 +247,10 @@ export async function initSchedule() {
} }
function bindEvents() { function bindEvents() {
rulesSearch?.addEventListener('input', debounce(() => {
rulesPage = 0;
loadRules();
}, 300));
refreshRulesButton?.addEventListener('click', loadRules); refreshRulesButton?.addEventListener('click', loadRules);
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange); scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange); scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
@@ -522,7 +536,7 @@ export async function initSchedule() {
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || ''); scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
lastVersionYearValue = scheduleVersionYearSelect.value; lastVersionYearValue = scheduleVersionYearSelect.value;
lastVersionSemesterValue = scheduleVersionSemesterSelect.value; lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect); refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
} }
function versionOptionGroup(label, versions) { function versionOptionGroup(label, versions) {
@@ -634,6 +648,11 @@ export async function initSchedule() {
} }
async function loadRules() { async function loadRules() {
const request = ++rulesRequest;
++visualRequest;
visualRules = [];
rules = [];
rulesPagination?.replaceChildren();
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
const version = selectedScheduleVersion(); const version = selectedScheduleVersion();
if (!version) { if (!version) {
@@ -642,26 +661,43 @@ export async function initSchedule() {
setVisualState('Нет выбранного расписания'); setVisualState('Нет выбранного расписания');
return; return;
} }
if (rulesVersionId !== String(version.id)) {
rulesPage = 0;
rulesVersionId = String(version.id);
}
try { try {
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`); const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' });
const page = await api.get(`/api/admin/schedule-rules/page?${params}`);
if (request !== rulesRequest) return;
if (rulesPage > 0 && rulesPage >= page.totalPages) {
rulesPage = Math.max(0, page.totalPages - 1);
return loadRules();
}
rules = page.items;
renderPagination(rulesPagination, page, {
label: 'правил',
onPage: value => { rulesPage = value; loadRules(); },
onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); }
});
if (!visualPeriodTouched) { if (!visualPeriodTouched) {
selectDefaultVisualPeriod(); selectDefaultVisualPeriod();
} }
renderRules(); renderRules();
renderVisualSchedule(); if (visualDrawer?.classList.contains('open')) await loadVisualRules();
openRequestedRule(); await openRequestedRule();
} catch (error) { } catch (error) {
if (request !== rulesRequest) return;
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`; rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания'); setVisualState('Ошибка загрузки правил расписания');
} }
} }
function openRequestedRule() { async function openRequestedRule() {
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId'); const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
if (!requestedRuleId) return; if (!requestedRuleId) return;
localStorage.removeItem('magistr.schedule.openRuleId'); localStorage.removeItem('magistr.schedule.openRuleId');
const rule = rules.find(item => String(item.id) === requestedRuleId); const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
if (!rule) { if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error'); showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
return; return;
} }
@@ -670,7 +706,7 @@ export async function initSchedule() {
function renderRules() { function renderRules() {
if (!rules.length) { if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила не найдены</td></tr>';
renderVisualSchedule(); renderVisualSchedule();
return; return;
} }
@@ -709,6 +745,7 @@ export async function initSchedule() {
visualToggleButton?.setAttribute('aria-expanded', 'true'); visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание'); visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualBackdrop) visualBackdrop.hidden = false; if (visualBackdrop) visualBackdrop.hidden = false;
loadVisualRules();
} }
function closeVisualDrawer() { function closeVisualDrawer() {
@@ -722,34 +759,49 @@ export async function initSchedule() {
if (visualBackdrop) visualBackdrop.hidden = true; if (visualBackdrop) visualBackdrop.hidden = true;
} }
async function loadVisualRules() {
const version = selectedScheduleVersion();
if (!version) return;
const request = ++visualRequest;
setVisualState('Загрузка визуального расписания...');
try {
const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
if (request !== visualRequest) return;
visualRules = loaded;
renderVisualSchedule();
} catch (error) {
if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания');
}
}
function renderVisualSchedule() { function renderVisualSchedule() {
if (!visualStage || !visualSummary) return; if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
if (!selectedVisualSemesterId) { if (!selectedVisualSemesterId) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = 'Выберите учебный год и семестр'; visualSummary.textContent = 'Выберите учебный год и семестр';
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра'); setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
return; return;
} }
if (!rules.length) { if (!visualRules.length) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = 'Нет сохранённых правил'; visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам'); setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return; return;
} }
const visualRules = visualRulesForSelectedPeriod(); const periodRules = visualRulesForSelectedPeriod();
const periodLabel = visualPeriodLabel(); const periodLabel = visualPeriodLabel();
if (!visualRules.length) { if (!periodRules.length) {
syncVisualGroupFilter([]); syncVisualGroupFilter([]);
visualSummary.textContent = `${periodLabel} · правил нет`; visualSummary.textContent = `${periodLabel} · правил нет`;
setVisualState('В выбранном семестре нет сохранённых правил расписания'); setVisualState('В выбранном семестре нет сохранённых правил расписания');
return; return;
} }
const allGroups = collectVisualGroups(visualRules); const allGroups = collectVisualGroups(periodRules);
syncVisualGroupFilter(allGroups); syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id))); const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules); const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules);
if (!allGroups.length || !slots.length) { if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения'; visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий'); setVisualState('У правил должны быть группы и слоты занятий');
@@ -761,7 +813,7 @@ export async function initSchedule() {
return; return;
} }
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules); const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
const rows = buildVisualRows(columns, slots, lessonsByCell); const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) { if (!rows.length) {
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
@@ -769,7 +821,7 @@ export async function initSchedule() {
return; return;
} }
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`; visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = ` visualStage.innerHTML = `
<div class="schedule-visual-table-wrap"> <div class="schedule-visual-table-wrap">
<table class="schedule-visual-table"> <table class="schedule-visual-table">
@@ -1029,7 +1081,7 @@ export async function initSchedule() {
} }
function openVisualContextMenu(button) { function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId)); const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) { if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return; return;
@@ -1074,7 +1126,7 @@ export async function initSchedule() {
if (!actionButton || !visualContextTarget) return; if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action; const action = actionButton.dataset.action;
const target = { ...visualContextTarget }; const target = { ...visualContextTarget };
const rule = rules.find(item => String(item.id) === String(target.ruleId)); const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
if (!rule) { if (!rule) {
closeVisualContextMenu(); closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error'); showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
@@ -1117,7 +1169,7 @@ export async function initSchedule() {
} }
function openMoveSlotModal(target) { function openMoveSlotModal(target) {
const rule = rules.find(item => String(item.id) === String(target.ruleId)); const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target); const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null; const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) { if (!rule || !slot) {
@@ -1160,7 +1212,7 @@ export async function initSchedule() {
event.preventDefault(); event.preventDefault();
if (!pendingSlotMove) return; if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert'); hideAlert('schedule-move-slot-alert');
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId); const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId);
const slot = rule?.slots?.[pendingSlotMove.slotIndex]; const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) { if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error'); showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
@@ -1330,7 +1382,7 @@ export async function initSchedule() {
function visualRulesForSelectedPeriod() { function visualRulesForSelectedPeriod() {
if (!selectedVisualSemesterId) return []; if (!selectedVisualSemesterId) return [];
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId); return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
} }
function firstSemesterWithRules() { function firstSemesterWithRules() {
@@ -1392,7 +1444,7 @@ export async function initSchedule() {
const deleteButton = event.target.closest('.btn-delete-rule'); const deleteButton = event.target.closest('.btn-delete-rule');
if (editButton) { if (editButton) {
const rule = rules.find(item => item.id == editButton.dataset.id) const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id)
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id); || await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
fillRuleForm(rule); fillRuleForm(rule);
return; return;

View File

@@ -1,3 +1,5 @@
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js'; import { confirmAction } from '../dialog.js';
@@ -52,6 +54,29 @@ export async function initUniversityStructure() {
let specialties = []; let specialties = [];
let currentProfiles = []; let currentProfiles = [];
let allProfiles = []; let allProfiles = [];
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
Object.keys(listStates).forEach(key => {
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
listStates[key].page = 0;
listRenderers[key]();
}, 250));
});
function listPage(key, items, fields) {
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
const state = listStates[key];
const totalPages = Math.ceil(filtered.length / state.size);
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
renderPagination(document.getElementById(`${key}-pagination`), {
...state, totalItems: filtered.length, totalPages
}, {
onPage: value => { state.page = value; listRenderers[key](); },
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
});
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
}
// --- Логика переключения табов --- // --- Логика переключения табов ---
const structureTabs = [ const structureTabs = [
@@ -105,11 +130,12 @@ export async function initUniversityStructure() {
} }
function renderDepartments() { function renderDepartments() {
if (!departments || !departments.length) { const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>'; if (!visible.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
deptTbody.innerHTML = departments.map(d => ` deptTbody.innerHTML = visible.map(d => `
<tr> <tr>
<td>${d.id}</td> <td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td> <td>${escapeHtml(d.departmentName || d.name)}</td>
@@ -123,11 +149,12 @@ export async function initUniversityStructure() {
} }
function renderSpecialties() { function renderSpecialties() {
if (!specialties || !specialties.length) { const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>'; if (!visible.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
specTbody.innerHTML = specialties.map(s => ` specTbody.innerHTML = visible.map(s => `
<tr> <tr>
<td>${s.id}</td> <td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td> <td>${escapeHtml(s.specialityName || s.name)}</td>
@@ -428,11 +455,12 @@ export async function initUniversityStructure() {
} }
function renderAllProfiles() { function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) { const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>'; if (!visible.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
return; return;
} }
tabProfileTbody.innerHTML = allProfiles.map(p => ` tabProfileTbody.innerHTML = visible.map(p => `
<tr> <tr>
<td>${p.id}</td> <td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td> <td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>

View File

@@ -76,6 +76,16 @@
<h2>Календарные графики</h2> <h2>Календарные графики</h2>
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</div> </div>
<div class="form-row">
<div class="form-group">
<label for="academic-calendars-search">Поиск графиков</label>
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
</div>
<div class="form-group">
<label for="academic-calendars-year-filter">Учебный год</label>
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
</div>
</div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -95,6 +105,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination" id="academic-calendars-pagination"></div>
</div> </div>
<div class="card create-card academic-year-builder-card"> <div class="card create-card academic-year-builder-card">
@@ -181,7 +192,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="calendar-editor-calendar">График</label> <label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar"> <select id="calendar-editor-calendar" data-native-select="true">
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
@@ -226,7 +237,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="calendar-subject-calendar">График</label> <label for="calendar-subject-calendar">График</label>
<select id="calendar-subject-calendar"> <select id="calendar-subject-calendar" data-native-select="true">
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>

View File

@@ -221,6 +221,10 @@
<h2>Правила динамического расписания</h2> <h2>Правила динамического расписания</h2>
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</div> </div>
<div class="form-group">
<label for="schedule-rules-search">Поиск правил</label>
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
</div>
<div class="table-wrap"> <div class="table-wrap">
<table id="schedule-table"> <table id="schedule-table">
<colgroup> <colgroup>
@@ -250,6 +254,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination" id="schedule-rules-pagination"></div>
</div> </div>
<button type="button" <button type="button"

View File

@@ -30,6 +30,10 @@
<div class="card-header-row"> <div class="card-header-row">
<h2>Кафедры</h2> <h2>Кафедры</h2>
</div> </div>
<div class="form-group">
<label for="departments-search">Поиск по названию или коду кафедры</label>
<input type="search" id="departments-search" placeholder="Введите название или код">
</div>
<div class="table-wrap"> <div class="table-wrap">
<table id="departments-table"> <table id="departments-table">
<thead> <thead>
@@ -47,6 +51,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination" id="departments-pagination"></div>
</div> </div>
</div> </div>
@@ -74,6 +79,10 @@
<div class="card-header-row"> <div class="card-header-row">
<h2>Специальности</h2> <h2>Специальности</h2>
</div> </div>
<div class="form-group">
<label for="specialties-search">Поиск по названию или коду специальности</label>
<input type="search" id="specialties-search" placeholder="Введите название или код">
</div>
<div class="table-wrap"> <div class="table-wrap">
<table id="specialties-table"> <table id="specialties-table">
<thead> <thead>
@@ -91,6 +100,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination" id="specialties-pagination"></div>
</div> </div>
</div> </div>
@@ -128,6 +138,10 @@
<div class="card-header-row"> <div class="card-header-row">
<h2>Профили обучения</h2> <h2>Профили обучения</h2>
</div> </div>
<div class="form-group">
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
<input type="search" id="profiles-search" placeholder="Введите название или код">
</div>
<div class="table-wrap"> <div class="table-wrap">
<table id="profiles-table"> <table id="profiles-table">
<thead> <thead>
@@ -146,6 +160,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination" id="profiles-pagination"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -52,3 +52,16 @@ test('метрики и вкладка анализа подключены к SP
assert.match(script, /magistr\.schedule\.openRuleId/); assert.match(script, /magistr\.schedule\.openRuleId/);
assert.doesNotMatch(script, /quality\/recommendations/); assert.doesNotMatch(script, /quality\/recommendations/);
}); });
test('правила сортируются по суммарному штрафу после фильтрации без изменения исходного анализа', () => {
const problems = [
{ id: 'малый', type: 'GROUP_GAP', severity: 'HIGH', penalty: 2 },
{ id: 'большой', type: 'GROUP_GAP', severity: 'LOW', penalty: 120 },
{ id: 'средний', type: 'GROUP_GAP', severity: 'HIGH', penalty: '30' },
{ id: 'равный', type: 'GROUP_GAP', severity: 'HIGH', penalty: 30 }
];
assert.deepEqual(filterQualityProblems(problems).map(item => item.id), ['большой', 'средний', 'равный', 'малый']);
assert.deepEqual(filterQualityProblems(problems, 'HIGH', 'GAPS').map(item => item.id), ['средний', 'равный', 'малый']);
assert.equal(problems[0].id, 'малый');
});

View File

@@ -0,0 +1,24 @@
---
type: "query"
date: "2026-09-06T16:13:17.921498+00:00"
question: "Где реализована загрузка конструктора правил, календарных графиков и структуры вуза?"
contributor: "graphify"
outcome: "useful"
source_nodes: ["AcademicCalendarController", "ScheduleRuleAdminController", "university-structure.js"]
---
# Q: Где реализована загрузка конструктора правил, календарных графиков и структуры вуза?
## Answer
Термины из словаря графа: schedule rules academic calendar university structure select search. Граф указал AcademicCalendarController, ScheduleRuleAdminController и university-structure.js; исходный код подтвердил полную загрузку списков. Добавлены серверные страницы правил и графиков, серверный поиск графиков в редакторах, клиентские страницы и поиск структуры; устранена рекурсия смены периода конструктора. Проверено на PostgreSQL и в браузере с 10000 записями.
## Outcome
- Signal: useful
## Source Nodes
- AcademicCalendarController
- ScheduleRuleAdminController
- university-structure.js