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

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 миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
только в `V1__init.sql`**.
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
- Подробнее — см. [`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`
- `docs/README.md:45-54`
- `backend/src/main/resources/db/migration/V2__test_data.sql`
- `docs/README.md`
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
**Как исправить:**
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
dev/test-профилем.
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
- Разнести demo/dev seed и обязательную production-схему.
- Ротировать пароли уже созданных tenant-БД.
## Высокий приоритет

View File

@@ -62,6 +62,20 @@ public class AcademicCalendarController {
.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")
public List<AcademicCalendarDto> searchCalendarOptions(
@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.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
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}")
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long 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 jakarta.persistence.LockModeType;
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.Query;
import org.springframework.data.repository.query.Param;
@@ -13,6 +15,23 @@ import java.util.Collection;
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("""
select distinct r
from ScheduleRule r
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
where r.id = :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;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext;
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.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
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)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return getAll(semesterId, groupId, null);

View File

@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
archive_reason TEXT
);
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
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 (
id BIGSERIAL PRIMARY KEY,
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)
);
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
);
-- Админ по умолчанию: 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 (
id BIGSERIAL PRIMARY KEY,
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)
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 (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
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
);
-- Тестовая базовая группа для работы
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")
-- ==========================================
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
FOR EACH ROW
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
'Глобально уникальное название дисциплины без учёта регистра';
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
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 (
id BIGSERIAL PRIMARY KEY,
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
archive_reason TEXT
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
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)
CREATE TABLE IF NOT EXISTS classroom_equipments (
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)
);
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_specialties_status ON specialties(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)
WHERE apply_mode = 'WEEKDAY';
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
@@ -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);
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 (
id BIGSERIAL PRIMARY KEY,
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)
);
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 (
id BIGSERIAL PRIMARY KEY,
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)
);
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 TABLE IF NOT EXISTS academic_calendar_activity_types (
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
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 (
id BIGSERIAL PRIMARY KEY,
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
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 (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -754,24 +575,6 @@ AS $$
) + 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 (
id BIGSERIAL PRIMARY KEY,
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
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 (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
FOR EACH ROW
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
-- ==========================================
@@ -1371,7 +1044,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
cancel_violations,
move_violations,
replace_violations,
@@ -1470,7 +1143,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
lecture_violations,
laboratory_violations,
practice_violations
@@ -1511,7 +1184,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = format(
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
duplicate_slot_groups
);
END IF;
@@ -1577,7 +1250,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
duration_violations,
overlap_violations,
scope_violations
@@ -1733,7 +1406,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
academic_year_overlap_violations,
semester_bounds_violations,
semester_overlap_violations
@@ -1864,7 +1537,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
assignment_violations,
calendar_period_violations,
calendar_subject_violations
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
CREATE INDEX idx_schedule_versions_semester_history
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
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 COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
CREATE INDEX idx_schedule_version_history_version
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 COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
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 {
@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
void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);

View File

@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
.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
void returnsBadRequestWithRussianValidationMessage() throws Exception {
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;
@Testcontainers
@DisplayName("Миграция начальных периодов календарного графика")
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
@@ -33,6 +33,7 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
prepareCustomCalendarPrerequisites(connection);
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
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) {
var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
@@ -75,10 +121,12 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
study_form_id,
course_count
)
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
FROM academic_calendars
ORDER BY id
LIMIT 1
SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
FROM academic_years academic_year
JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
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
""")) {
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 {
try (PreparedStatement statement = connection.prepareStatement("""
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,
int minCourse,
int maxCourse,

View File

@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
flyway.migrate();
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("")) {
long groupId = queryLong(
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
"23514",
"сохранённые назначения календарных графиков станут несовместимыми"
);
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
assertSqlState(
() -> updateInt(
connection,
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
return Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.target(MigrationVersion.fromVersion("2"))
.cleanDisabled(false)
.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 {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, value);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
flyway.migrate();
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("")) {
long manualScopeId = insertManualScope(connection, "migration_checks");
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
}
private Flyway baselineFlyway() {
return flyway("1");
return flyway("2");
}
private Flyway flyway(String targetVersion) {

View File

@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
properties = {
"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"
}
)
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
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
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
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 | Назначение |
|-------|-----|------------|
| `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/{id}` | Один график |
| `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`,
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
выполняется по составному названию, учебному году, коду/названию специальности, профилю и
@@ -718,11 +726,18 @@ CRUD доступен по:
| Метод | URL | Назначение |
|-------|-----|------------|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/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`). Субботняя и ручные сетки не выбираются в правиле напрямую.
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`

View File

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

View File

@@ -953,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
Пара `semester_id + version_number` уникальна. Частичный индекс
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
`Основное расписание` и две записи истории: создание и публикацию.
@@ -1177,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
### Правила работы
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
триггеры и ограничения изменяются только в `V1__init.sql`.
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
tenant-БД. Сброс выполняется только по прямой команде владельца.
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
пустой tenant-схеме
@@ -1188,19 +1193,46 @@ CHECK фиксирует допустимую форму каждого типа
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет |
| `V1__init.sql` | Полная schema-only baseline: таблицы, связи, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия, пожелания и заявки преподавателей, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии. Постоянных доменных данных нет |
| `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
### Этап разработки
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются
отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`.
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
непосредственно в V1.
Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы
baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема.
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый 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. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения
1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
постоянный DDL в ней запрещён
3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
5. Не сбрасывайте tenant-БД без прямой команды владельца
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено
содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам,
пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только
отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
`V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
всем tenant-БД.
### Применение
@@ -270,6 +270,10 @@ public class AbsenceController {
# Локально — сброс и повтор всех миграций
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
```

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` и календарного учебного графика группы.
## База данных
`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` | Администратор |
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
Эти и остальные прежние демонстрационные записи загружаются миграцией
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
### Полезные команды
```bash

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { renderPagination } from '../pagination.js';
import { debounce } from '../url-state.js';
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { confirmAction } from '../dialog.js';
@@ -52,6 +54,29 @@ export async function initUniversityStructure() {
let specialties = [];
let currentProfiles = [];
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 = [
@@ -105,11 +130,12 @@ export async function initUniversityStructure() {
}
function renderDepartments() {
if (!departments || !departments.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
if (!visible.length) {
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
deptTbody.innerHTML = departments.map(d => `
deptTbody.innerHTML = visible.map(d => `
<tr>
<td>${d.id}</td>
<td>${escapeHtml(d.departmentName || d.name)}</td>
@@ -123,11 +149,12 @@ export async function initUniversityStructure() {
}
function renderSpecialties() {
if (!specialties || !specialties.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
if (!visible.length) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
return;
}
specTbody.innerHTML = specialties.map(s => `
specTbody.innerHTML = visible.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.specialityName || s.name)}</td>
@@ -428,11 +455,12 @@ export async function initUniversityStructure() {
}
function renderAllProfiles() {
if (!allProfiles || !allProfiles.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
if (!visible.length) {
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
return;
}
tabProfileTbody.innerHTML = allProfiles.map(p => `
tabProfileTbody.innerHTML = visible.map(p => `
<tr>
<td>${p.id}</td>
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>

View File

@@ -76,6 +76,16 @@
<h2>Календарные графики</h2>
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</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">
<table>
<thead>
@@ -95,6 +105,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="academic-calendars-pagination"></div>
</div>
<div class="card create-card academic-year-builder-card">
@@ -181,7 +192,7 @@
<div class="form-row">
<div class="form-group">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar">
<select id="calendar-editor-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
@@ -226,7 +237,7 @@
<div class="form-row">
<div class="form-group">
<label for="calendar-subject-calendar">График</label>
<select id="calendar-subject-calendar">
<select id="calendar-subject-calendar" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>

View File

@@ -221,6 +221,10 @@
<h2>Правила динамического расписания</h2>
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</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">
<table id="schedule-table">
<colgroup>
@@ -250,6 +254,7 @@
</tbody>
</table>
</div>
<div class="pagination" id="schedule-rules-pagination"></div>
</div>
<button type="button"

View File

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

View File

@@ -52,3 +52,16 @@ test('метрики и вкладка анализа подключены к SP
assert.match(script, /magistr\.schedule\.openRuleId/);
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