From e3b67977133f0e58c8f67e79daace4416172c2c6 Mon Sep 17 00:00:00 2001 From: Zuev Date: Sun, 6 Sep 2026 19:17:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 +- BUG_REPORT.md | 14 +- .../AcademicCalendarController.java | 14 + .../ScheduleRuleAdminController.java | 10 + .../repository/ScheduleRuleRepository.java | 38 + .../app/service/ScheduleRuleService.java | 17 + .../main/resources/db/migration/V1__init.sql | 371 +---- .../V2__backfill_default_theory_periods.sql | 24 - .../resources/db/migration/V2__test_data.sql | 1410 +++++++++++++++++ .../AcademicCalendarControllerTest.java | 24 + .../ScheduleRuleAdminControllerTest.java | 14 + ...tTheoryPeriodMigrationIntegrationTest.java | 100 +- ...emicStructureInvariantIntegrationTest.java | 19 +- ...eduleOverrideMigrationIntegrationTest.java | 6 +- ...duleRuleHoursMigrationIntegrationTest.java | 6 +- ...jectOwnershipInvariantIntegrationTest.java | 2 +- ...herDepartmentInvariantIntegrationTest.java | 2 +- ...erPreferencesMigrationIntegrationTest.java | 4 +- ...SlotInvariantMigrationIntegrationTest.java | 4 +- ...RuleServiceConcurrencyIntegrationTest.java | 43 + docs/ACADEMIC_CALENDAR.md | 8 +- docs/API.md | 15 + docs/BUSINESS_LOGIC.md | 6 +- docs/DATABASE.md | 64 +- docs/DEVELOPMENT.md | 26 +- docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 8 +- docs/FRONTEND.md | 6 +- docs/README.md | 5 + docs/SCHEDULE_PROPOSAL.md | 3 +- docs/SCHEDULE_TASKS.md | 3 +- frontend/admin/js/views/academic-calendar.js | 150 +- frontend/admin/js/views/schedule-quality.js | 2 +- frontend/admin/js/views/schedule.js | 96 +- .../admin/js/views/university-structure.js | 46 +- frontend/admin/views/academic-calendar.html | 15 +- frontend/admin/views/schedule.html | 5 + .../admin/views/university-structure.html | 15 + frontend/tests/schedule-quality.test.mjs | 13 + ...ализована_загрузка_конструктора_правил__кале.md | 24 + 39 files changed, 2121 insertions(+), 521 deletions(-) delete mode 100644 backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql create mode 100644 backend/src/main/resources/db/migration/V2__test_data.sql create mode 100644 graphify-out/memory/query_20260906_161317_где_реализована_загрузка_конструктора_правил__кале.md diff --git a/AGENTS.md b/AGENTS.md index ba3b8a7..bdb188e 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) ### Языковые требования diff --git a/BUG_REPORT.md b/BUG_REPORT.md index d2d13a3..a30f12a 100644 --- a/BUG_REPORT.md +++ b/BUG_REPORT.md @@ -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-БД. ## Высокий приоритет diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 8e7d9a8..89ff483 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -62,6 +62,20 @@ public class AcademicCalendarController { .toList(); } + @GetMapping("/page") + @Transactional(readOnly = true) + public PageResponse 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 searchCalendarOptions( @RequestParam(defaultValue = "") String query, diff --git a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java index fed6191..27b5c23 100644 --- a/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java +++ b/backend/src/main/java/com/magistr/app/controller/ScheduleRuleAdminController.java @@ -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 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 getById(@PathVariable("id") Long id) { return ResponseEntity.ok(scheduleRuleService.getById(id)); diff --git a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java index 09543ee..41baa82 100644 --- a/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/ScheduleRuleRepository.java @@ -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 { + // Сначала ограничиваем 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 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 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 findPageWithDetails(@Param("ids") Collection ids); } diff --git a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java index 2e51525..28d5f8f 100644 --- a/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java +++ b/backend/src/main/java/com/magistr/app/service/ScheduleRuleService.java @@ -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 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 getAll(Long semesterId, Long groupId) { return getAll(semesterId, groupId, null); diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 4654cd3..344c229 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -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 'Версия, из которой был скопирован черновик'; diff --git a/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql b/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql deleted file mode 100644 index e908a7c..0000000 --- a/backend/src/main/resources/db/migration/V2__backfill_default_theory_periods.sql +++ /dev/null @@ -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 -); diff --git a/backend/src/main/resources/db/migration/V2__test_data.sql b/backend/src/main/resources/db/migration/V2__test_data.sql new file mode 100644 index 0000000..5dc58a6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__test_data.sql @@ -0,0 +1,1410 @@ +-- ============================================================================ +-- Большой демонстрационный набор данных +-- ============================================================================ +-- V2 содержит только тестовые данные. Постоянная схема, ограничения, функции и +-- триггеры определяются исключительно в V1__init.sql. Временные таблицы ниже +-- используются только как детерминированные staging-наборы на время миграции. +-- +-- Названия направлений, кафедр и дисциплин основаны на публичных страницах ЮЗГУ: +-- https://ee.swsu.ru/specs_2026.php +-- https://swsu.ru/structura/up/ +-- https://swsu.ru/structura/up/fivt/kvt/bakmag.php +-- https://swsu.ru/structura/up/uf/tgip/distsipliny/ +-- https://swsu.ru/structura/up/fiu/tiet/untitled.php +-- +-- ФИО массовых преподавателей синтетические и явно помечены как тестовые. +-- Их пароль генерируется случайно и не публикуется; для ручного входа остаются +-- только исторические демонстрационные учётные записи из начала этого файла. + +CREATE TEMP TABLE tmp_seed_programs ( + ordinal INT PRIMARY KEY, + group_prefix VARCHAR(10) NOT NULL, + specialty_code VARCHAR(20) NOT NULL, + specialty_name VARCHAR(255) NOT NULL, + department_code BIGINT NOT NULL, + education_form_name VARCHAR(100) NOT NULL, + course_count INT NOT NULL +); + +INSERT INTO tmp_seed_programs ( + ordinal, + group_prefix, + specialty_code, + specialty_name, + department_code, + education_form_name, + course_count +) VALUES + (1, 'МИ', '02.03.03', 'Математическое обеспечение и администрирование информационных систем', 101, 'Бакалавриат', 4), + (2, 'ХО', '04.03.01', 'Химия', 104, 'Бакалавриат', 4), + (3, 'АР', '07.03.01', 'Архитектура', 122, 'Бакалавриат', 4), + (4, 'ПС', '08.03.01', 'Строительство', 123, 'Бакалавриат', 4), + (5, 'СУ', '08.05.01', 'Строительство уникальных зданий и сооружений', 124, 'Специалитет', 6), + (6, 'ВМ', '09.03.01', 'Информатика и вычислительная техника', 106, 'Бакалавриат', 4), + (7, 'ПИ', '09.03.03', 'Прикладная информатика', 106, 'Бакалавриат', 4), + (8, 'ПО', '09.03.04', 'Программная инженерия', 108, 'Бакалавриат', 4), + (9, 'ИБ', '10.03.01', 'Информационная безопасность', 107, 'Бакалавриат', 4), + (10, 'БТ', '10.05.02', 'Информационная безопасность телекоммуникационных систем', 107, 'Специалитет', 6), + (11, 'ИТ', '11.03.02', 'Инфокоммуникационные технологии и системы связи', 106, 'Бакалавриат', 4), + (12, 'КС', '11.03.03', 'Конструирование и технология электронных средств', 103, 'Бакалавриат', 4), + (13, 'БМ', '12.03.04', 'Биотехнические системы и технологии', 105, 'Бакалавриат', 4), + (14, 'ТЕ', '13.03.01', 'Теплоэнергетика и теплотехника', 110, 'Бакалавриат', 4), + (15, 'ЭС', '13.03.02', 'Электроэнергетика и электротехника', 109, 'Бакалавриат', 4), + (16, 'СП', '15.03.01', 'Машиностроение', 118, 'Бакалавриат', 4), + (17, 'МТ', '15.03.06', 'Мехатроника и робототехника', 102, 'Бакалавриат', 4), + (18, 'ХТ', '18.03.01', 'Химическая технология', 104, 'Бакалавриат', 4), + (19, 'ПП', '19.03.02', 'Продукты питания из растительного сырья', 121, 'Бакалавриат', 4), + (20, 'ПЖ', '19.03.03', 'Продукты питания животного происхождения', 121, 'Бакалавриат', 4), + (21, 'ТБ', '20.03.01', 'Техносферная безопасность', 119, 'Бакалавриат', 4), + (22, 'КД', '21.03.02', 'Землеустройство и кадастры', 125, 'Бакалавриат', 4), + (23, 'АХ', '23.03.03', 'Эксплуатация транспортно-технологических машин и комплексов', 117, 'Бакалавриат', 4), + (24, 'НС', '23.05.01', 'Наземные транспортно-технологические средства', 117, 'Специалитет', 6), + (25, 'НМ', '28.03.01', 'Нанотехнологии и микросистемная техника', 103, 'Бакалавриат', 4), + (26, 'КИ', '29.03.05', 'Конструирование изделий лёгкой промышленности', 120, 'Бакалавриат', 4), + (27, 'МК', '30.05.03', 'Медицинская кибернетика', 105, 'Специалитет', 6), + (28, 'КФ', '37.03.02', 'Конфликтология', 113, 'Бакалавриат', 4), + (29, 'ЭК', '38.03.01', 'Экономика', 129, 'Бакалавриат', 4), + (30, 'МД', '38.03.02', 'Менеджмент', 126, 'Бакалавриат', 4), + (31, 'ГУ', '38.03.04', 'Государственное и муниципальное управление', 130, 'Бакалавриат', 4), + (32, 'ЭБ', '38.05.01', 'Экономическая безопасность', 130, 'Специалитет', 6), + (33, 'ТД', '38.05.02', 'Таможенное дело', 131, 'Специалитет', 6), + (34, 'СЛ', '39.03.01', 'Социология', 127, 'Бакалавриат', 4), + (35, 'ЮР', '40.03.01', 'Юриспруденция', 133, 'Бакалавриат', 4), + (36, 'НБ', '40.05.01', 'Правовое обеспечение национальной безопасности', 134, 'Специалитет', 6), + (37, 'ПД', '40.05.02', 'Правоохранительная деятельность', 137, 'Специалитет', 6), + (38, 'МО', '41.03.05', 'Международные отношения', 114, 'Бакалавриат', 4), + (39, 'ЖР', '42.03.02', 'Журналистика', 112, 'Бакалавриат', 4), + (40, 'ГО', '43.03.03', 'Гостиничное дело', 112, 'Бакалавриат', 4), + (41, 'ФЛ', '45.03.03', 'Фундаментальная и прикладная лингвистика', 116, 'Бакалавриат', 4), + (42, 'ВИ', '53.03.03', 'Вокальное искусство', 111, 'Бакалавриат', 4); + +-- Сохраняем три исторические кафедры и добавляем действующие названия ЮЗГУ. +INSERT INTO departments (name, code) VALUES + ('Кафедра ИБ', 1), + ('Кафедра ВТ', 2), + ('Кафедра КТ', 3), + ('Кафедра высшей математики', 101), + ('Кафедра механики, мехатроники и робототехники', 102), + ('Кафедра нанотехнологий, микроэлектроники, общей и прикладной физики', 103), + ('Кафедра фундаментальной химии и химической технологии', 104), + ('Кафедра биомедицинской инженерии', 105), + ('Кафедра вычислительной техники', 106), + ('Кафедра информационной безопасности', 107), + ('Кафедра программной инженерии', 108), + ('Кафедра электроэнергетики и электротехники', 109), + ('Кафедра инфраструктурных теплоэнергетических систем', 110), + ('Кафедра вокального искусства', 111), + ('Кафедра истории и социально-культурного сервиса', 112), + ('Кафедра коммуникологии и психологии', 113), + ('Кафедра международных отношений и лингвистики', 114), + ('Кафедра физического воспитания', 115), + ('Кафедра иностранных языков', 116), + ('Кафедра технологии материалов и транспорта', 117), + ('Кафедра машиностроительных технологий и оборудования', 118), + ('Кафедра охраны труда и окружающей среды', 119), + ('Кафедра дизайна и индустрии моды', 120), + ('Кафедра товароведения, технологии и экспертизы товаров', 121), + ('Кафедра архитектуры, градостроительства и графики', 122), + ('Кафедра промышленного и гражданского строительства', 123), + ('Кафедра уникальных зданий и сооружений', 124), + ('Кафедра экспертизы и управления недвижимостью, горного дела', 125), + ('Кафедра региональной экономики и менеджмента', 126), + ('Кафедра философии и социологии', 127), + ('Кафедра финансов и кредита', 128), + ('Кафедра экономики, управления и аудита', 129), + ('Кафедра экономической безопасности и государственного управления', 130), + ('Кафедра таможенного дела и мировой экономики', 131), + ('Кафедра административного и финансового права', 132), + ('Кафедра гражданского права', 133), + ('Кафедра конституционного права', 134), + ('Кафедра теории и истории государства и права', 135), + ('Кафедра уголовного права', 136), + ('Кафедра уголовного процесса и криминалистики', 137), + ('Кафедра русского языка и общеобразовательных дисциплин для иностранных граждан', 138) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO specialties (name, specialty_code) +SELECT specialty_name, specialty_code +FROM tmp_seed_programs +ON CONFLICT (specialty_code) DO UPDATE +SET name = EXCLUDED.name; + +INSERT INTO education_forms (name) VALUES + ('Бакалавриат'), + ('Магистратура'), + ('Специалитет') +ON CONFLICT (name) DO NOTHING; + +INSERT INTO specialty_profiles (specialty_id, name) +SELECT id, 'Без профиля' +FROM specialties +ON CONFLICT (specialty_id, name) DO NOTHING; + +CREATE TEMP TABLE tmp_seed_credentials AS +SELECT + crypt('admin', gen_salt('bf', 10)) AS admin_password, + crypt('1234567890', gen_salt('bf', 10)) AS demo_password, + crypt(encode(gen_random_bytes(32), 'hex'), gen_salt('bf', 10)) AS blocked_bulk_password; + +-- Исторические демонстрационные учётные записи перенесены из V1 без удаления. +INSERT INTO users (username, password, role, full_name, job_title, department_id) +SELECT + data.username, + CASE WHEN data.username = 'admin' THEN credentials.admin_password ELSE credentials.demo_password END, + data.role, + data.full_name, + data.job_title, + department.id +FROM (VALUES + ('admin', 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1::BIGINT), + ('Тестовый преподаватель', 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2::BIGINT), + ('учебный_отдел', 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1::BIGINT), + ('кафедра_иб', 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1::BIGINT), + ('просмотр_расписаний', 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1::BIGINT) +) AS data(username, role, full_name, job_title, department_code) +JOIN departments department ON department.code = data.department_code +CROSS JOIN tmp_seed_credentials credentials +ON CONFLICT (username) DO NOTHING; + +CREATE TEMP TABLE tmp_seed_teachers AS +WITH ordered_departments AS ( + SELECT + code, + ROW_NUMBER() OVER (ORDER BY code) AS department_number, + COUNT(*) OVER () AS department_count + FROM departments +), generated AS ( + SELECT + number, + 1 + MOD(number - 1, (SELECT MAX(department_count) FROM ordered_departments)) AS department_number, + (ARRAY[ + 'Алексеев', 'Белов', 'Васильев', 'Волков', 'Громов', 'Данилов', + 'Егоров', 'Жуков', 'Зайцев', 'Ильин', 'Ковалёв', 'Козлов', + 'Лебедев', 'Макаров', 'Морозов', 'Никитин', 'Орлов', 'Павлов', + 'Романов', 'Семёнов', 'Соколов', 'Титов', 'Фёдоров', 'Фролов', + 'Чернов', 'Широков', 'Щербаков', 'Юдин', 'Яковлев', 'Быков' + ])[1 + MOD(number - 1, 30)] AS surname, + (ARRAY['Алексей', 'Борис', 'Виктор', 'Дмитрий', 'Евгений'])[ + 1 + MOD((number - 1) / 30, 5) + ] AS first_name, + (ARRAY['Андреевич', 'Борисович', 'Викторович', 'Дмитриевич', 'Евгеньевич'])[ + 1 + MOD(number - 1, 5) + ] AS patronymic, + (ARRAY['Ассистент', 'Преподаватель', 'Старший преподаватель', 'Доцент', 'Профессор'])[ + 1 + MOD(number - 1, 5) + ] AS job_title + FROM GENERATE_SERIES(1, 150) AS series(number) +) +SELECT + generated.number, + 'преподаватель_' || LPAD(generated.number::TEXT, 3, '0') AS username, + FORMAT( + '%s %s %s (тестовый №%s)', + generated.surname, + generated.first_name, + generated.patronymic, + LPAD(generated.number::TEXT, 3, '0') + ) AS full_name, + generated.job_title, + department.code AS department_code +FROM generated +JOIN ordered_departments department + ON department.department_number = generated.department_number; + +INSERT INTO users (username, password, role, full_name, job_title, department_id) +SELECT + teacher.username, + credentials.blocked_bulk_password, + 'TEACHER', + teacher.full_name, + teacher.job_title, + department.id +FROM tmp_seed_teachers teacher +JOIN departments department ON department.code = teacher.department_code +CROSS JOIN tmp_seed_credentials credentials +ON CONFLICT (username) DO NOTHING; + +INSERT INTO teacher_department_assignments ( + teacher_id, + department_id, + valid_from, + is_primary, + comment +) +SELECT + user_account.id, + user_account.department_id, + DATE '2024-09-01', + TRUE, + 'Начальная кафедра из тестового набора V2' +FROM users user_account +WHERE user_account.role = 'TEACHER' + AND NOT EXISTS ( + SELECT 1 + FROM teacher_department_assignments existing + WHERE existing.teacher_id = user_account.id + ); + +-- Две исторические группы сохраняются; дополнительно создаются ровно 200 групп. +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, + education_form.id, + department.id, + specialty.id, + profile.id, + data.year_start_study +FROM (VALUES + ('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 1::BIGINT, '09.03.01', 2025::BIGINT), + ('ИБ-41м', 15::BIGINT, 'Магистратура', 1::BIGINT, '10.03.01', 2024::BIGINT) +) AS data(name, group_size, education_form_name, department_code, specialty_code, year_start_study) +JOIN education_forms education_form ON education_form.name = data.education_form_name +JOIN departments department ON department.code = data.department_code +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 +); + +CREATE TEMP TABLE tmp_seed_generated_groups AS +WITH group_parameters AS ( + SELECT + program.ordinal AS program_ordinal, + program.group_prefix, + program.specialty_code, + program.department_code, + program.education_form_name, + series.series_number, + CASE + WHEN program.education_form_name = 'Бакалавриат' + THEN 2022 + MOD(series.series_number - 1, 4) + ELSE 2020 + series.series_number + END AS year_start_study, + CASE + WHEN program.education_form_name = 'Бакалавриат' + THEN 1 + (series.series_number - 1) / 4 + ELSE 1 + END AS stream_number + FROM tmp_seed_programs program + CROSS JOIN GENERATE_SERIES(1, 5) AS series(series_number) + WHERE program.ordinal <= 40 +) +SELECT + program_ordinal, + specialty_code, + department_code, + education_form_name, + year_start_study, + group_prefix + || '-' + || RIGHT(year_start_study::TEXT, 1) + || stream_number + || CASE WHEN education_form_name = 'Бакалавриат' THEN 'б' ELSE '' END AS group_name, + 18 + MOD(program_ordinal * 7 + series_number * 3, 18) AS group_size +FROM group_parameters; + +INSERT INTO student_groups ( + name, + group_size, + education_form_id, + department_id, + specialty_id, + specialty_profile_id, + year_start_study +) +SELECT + generated_group.group_name, + generated_group.group_size, + education_form.id, + department.id, + specialty.id, + profile.id, + generated_group.year_start_study +FROM tmp_seed_generated_groups generated_group +JOIN education_forms education_form + ON education_form.name = generated_group.education_form_name +JOIN departments department + ON department.code = generated_group.department_code +JOIN specialties specialty + ON specialty.specialty_code = generated_group.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 = generated_group.group_name +); + +INSERT INTO subgroups (group_id, name, student_capacity) +SELECT + student_group.id, + subgroup.name, + CASE + WHEN subgroup.part_number = 1 + THEN CEIL(student_group.group_size::NUMERIC / 2)::INT + ELSE FLOOR(student_group.group_size::NUMERIC / 2)::INT + END +FROM student_groups student_group +CROSS JOIN (VALUES + ('Подгруппа 1', 1), + ('Подгруппа 2', 2) +) AS subgroup(name, part_number) +ON CONFLICT DO NOTHING; + +-- -------------------------------------------------------------------------- +-- Дисциплины, преподавательские компетенции и справочники занятий +-- -------------------------------------------------------------------------- +CREATE TEMP TABLE tmp_seed_subjects ( + name VARCHAR(200) NOT NULL, + department_code BIGINT NOT NULL +); + +INSERT INTO tmp_seed_subjects (name, department_code) VALUES + -- Исторические дисциплины из V1. + ('Высшая математика', 1), + ('Философия', 1), + ('Информатика', 1), + ('Базы данных', 1), + ('Английский язык', 1), + -- Дисциплины кафедр вычислительной техники, ИБ и программной инженерии. + ('IT-стандарты', 2), + ('Web-программирование', 108), + ('Администрирование информационных систем', 106), + ('Администрирование операционных систем', 106), + ('Алгоритмы и структуры данных в системах искусственного интеллекта', 106), + ('Аппаратно-программное обеспечение инфраструктуры систем искусственного интеллекта', 106), + ('Архитектура систем обработки, анализа и интерпретации данных', 106), + ('Базы данных и знаний', 106), + ('Безопасность систем искусственного интеллекта', 107), + ('Вычислительные системы', 2), + ('Защита информации', 107), + ('Инженерная и компьютерная графика', 106), + ('Интеллектуальные и экспертные системы в цифровой экономике', 106), + ('Интеллектуальные системы и технологии', 106), + ('Интеллектуальные системы поддержки принятия решений', 106), + ('Интеллектуальные системы управления', 106), + ('Информационно-коммуникационные технологии', 106), + ('Информационные технологии', 106), + ('Исследование операций в экономике', 106), + ('Киберфизические системы и технологии', 106), + ('Математическая логика и теория алгоритмов', 101), + ('Машинное обучение и анализ данных в цифровой экономике', 106), + ('Машинное обучение и нейросетевые модели', 106), + ('Методы оптимизации', 101), + ('Микропроцессорные системы', 103), + ('Мировые информационные ресурсы', 106), + ('Облачные вычислительные системы', 106), + ('Объектно-ориентированное программирование', 108), + ('Операционные системы', 106), + ('Организация ЭВМ и систем', 2), + ('Основы системной инженерии', 106), + ('Основы теории распознавания образов', 106), + ('Параллельное программирование', 108), + ('Программирование', 108), + ('Проектирование информационных систем', 108), + ('Разработка мобильных приложений', 108), + ('Сети и телекоммуникации', 106), + ('Системное администрирование и DevOps', 106), + ('Системное программное обеспечение', 106), + ('Системы искусственного интеллекта', 106), + ('Системы обработки больших данных', 106), + ('Технология разработки программного обеспечения', 108), + ('Цифровая обработка и анализ изображений в информационных системах', 106), + ('Цифровые платформы', 106), + ('Электронный бизнес', 106), + -- Правовые дисциплины. + ('Антикоррупционная политика', 135), + ('Антитеррористическая политика', 135), + ('История государства и права зарубежных стран', 135), + ('История государства и права России', 135), + ('История международных отношений', 114), + ('Источники права', 135), + ('Медицинское право', 133), + ('Навыки публичных выступлений и ведения переговоров юриста', 133), + ('Основы российской государственности', 135), + ('Основы теории национальной безопасности', 134), + ('Право Европейского Союза', 134), + ('Право национальной безопасности', 134), + ('Правовое обеспечение государственного и муниципального управления', 132), + ('Правовое регулирование кадастровой деятельности', 132), + ('Правовое регулирование строительства', 132), + ('Правовые основы журналистики', 132), + ('Правоохранительные органы', 137), + ('Правоприменительные акты', 135), + ('Правосознание и правовая культура', 135), + ('Разработка и экспертиза нормативно-правовых актов', 135), + ('Сравнительное правоведение', 135), + ('Теория государства и права', 135), + ('Цифровое право', 133), + ('Юридическая конфликтология', 133), + ('Юридическая техника', 135), + ('Юридическая экспертиза', 135), + -- Общие, естественно-научные, экономические и товароведческие дисциплины. + ('Иностранный язык', 116), + ('Физическая культура', 115), + ('Отечественная история', 112), + ('Русский язык и культура речи', 138), + ('Социология', 127), + ('Психология и педагогика', 113), + ('Культурология', 112), + ('Политология', 127), + ('Правоведение', 135), + ('Деловое общение', 113), + ('Этика деловых отношений', 113), + ('Прикладная математика', 101), + ('Физика', 103), + ('Химия', 104), + ('Экология', 119), + ('Методы физической и химической идентификации веществ', 104), + ('Статистика', 129), + ('Теоретические основы товароведения и экспертизы', 121), + ('Стандартизация, метрология и сертификация', 121), + ('Основы микробиологии', 121), + ('Логистика', 131), + ('Рекламная деятельность', 126), + ('Безопасность жизнедеятельности', 119), + ('Менеджмент', 126), + ('Маркетинг', 126), + ('Бухгалтерский учёт и анализ хозяйственной деятельности', 129), + ('Управление качеством', 121), + ('Методы и средства исследования качества товаров', 121), + ('Квалиметрия', 121), + ('Управление персоналом', 126); + +INSERT INTO subjects (name, code, department_id) +SELECT + seed_subject.name, + CASE + WHEN seed_subject.name IN ( + 'Высшая математика', + 'Философия', + 'Информатика', + 'Базы данных', + 'Английский язык' + ) THEN NULL + ELSE 'ЮЗГУ-' || LPAD(ROW_NUMBER() OVER (ORDER BY seed_subject.name)::TEXT, 3, '0') + END, + department.id +FROM tmp_seed_subjects seed_subject +JOIN departments department ON department.code = seed_subject.department_code +ON CONFLICT DO NOTHING; + +-- Каждая кафедра получает ещё три явно синтетических дисциплины для нагрузочных сценариев. +INSERT INTO subjects (name, code, department_id) +SELECT + generated_subject.name, + generated_subject.code, + generated_subject.department_id +FROM ( + SELECT + 'Проектный практикум: ' || department.name AS name, + 'ПР-' || department.code AS code, + department.id AS department_id + FROM departments department + UNION ALL + SELECT + 'Научно-исследовательский семинар: ' || department.name, + 'НИС-' || department.code, + department.id + FROM departments department + UNION ALL + SELECT + 'Современные проблемы направления: ' || department.name, + 'СПН-' || department.code, + department.id + FROM departments department +) generated_subject +ON CONFLICT DO NOTHING; + +INSERT INTO lesson_types (name, color_code) VALUES + ('Лекция', '#FF6B6B'), + ('Практика', '#4ECDC4'), + ('Лабораторная работа', '#45B7D1') +ON CONFLICT (name) DO NOTHING; + +INSERT INTO teacher_subjects (user_id, subject_id, qualification_level, experience_years) +SELECT + teacher.id, + subject.id, + CASE + WHEN MOD(teacher.id, 3) = 0 THEN 'Экспертный' + WHEN MOD(teacher.id, 3) = 1 THEN 'Продвинутый' + ELSE 'Базовый' + END, + 1 + MOD(teacher.id + subject.id, 25)::INT +FROM users teacher +JOIN subjects subject ON subject.department_id = teacher.department_id +WHERE teacher.role = 'TEACHER' +ON CONFLICT DO NOTHING; + +INSERT INTO teacher_lesson_types (user_id, subject_id, lesson_type_id) +SELECT teacher_subject.user_id, teacher_subject.subject_id, lesson_type.id +FROM teacher_subjects teacher_subject +CROSS JOIN lesson_types lesson_type +ON CONFLICT DO NOTHING; + +INSERT INTO subject_comments (subject_id, author_id, comment) +SELECT + subject.id, + administrator.id, + 'Тестовый комментарий к дисциплине для проверки рабочего кабинета кафедры' +FROM subjects subject +CROSS JOIN LATERAL ( + SELECT id + FROM users + WHERE username = 'admin' +) administrator +ORDER BY subject.id +LIMIT 40; + +-- -------------------------------------------------------------------------- +-- Аудитории и оборудование +-- -------------------------------------------------------------------------- +INSERT INTO equipments (name) VALUES + ('Проектор'), + ('ПК'), + ('Лаборатория'), + ('Интерактивная доска'), + ('Документ-камера'), + ('Аудиосистема') +ON CONFLICT (name) DO NOTHING; + +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; + +INSERT INTO classrooms (name, capacity, building, floor) +SELECT + 'А-' || LPAD(number::TEXT, 3, '0'), + 20 + MOD(number, 5) * 10, + (ARRAY[ + 'Главный корпус', + 'Корпус цифровых технологий', + 'Лабораторный корпус', + 'Корпус экономики', + 'Юридический корпус', + 'Корпус энергетики', + 'Корпус строительства' + ])[1 + MOD(number - 1, 7)], + 1 + MOD(number - 1, 5) +FROM GENERATE_SERIES(1, 77) AS series(number) +ON CONFLICT (name) DO NOTHING; + +INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity) +SELECT + classroom.id, + equipment.id, + CASE + WHEN equipment.name = 'ПК' AND classroom.name = '202 IT Lab' THEN 15 + WHEN equipment.name = 'ПК' THEN 1 + ELSE 1 + END +FROM classrooms classroom +CROSS JOIN equipments equipment +WHERE + (classroom.name = '101 Ленинская' AND equipment.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема')) + OR (classroom.name = '202 IT Lab' AND equipment.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска')) + OR (classroom.name = '303 Обычная' AND equipment.name = 'Проектор') +ON CONFLICT (classroom_id, equipment_id) DO NOTHING; + +-- -------------------------------------------------------------------------- +-- Временные сетки, учебные периоды и календарные графики +-- -------------------------------------------------------------------------- +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; + +INSERT INTO time_slots ( + time_slot_scope_id, + order_number, + start_time, + end_time, + duration_minutes +) +SELECT + scope.id, + slot.order_number, + slot.start_time::TIME, + slot.end_time::TIME, + slot.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 slot(order_number, start_time, end_time, duration_minutes) +CROSS JOIN time_slot_scopes scope +WHERE scope.code IN ('default', 'saturday') +ON CONFLICT DO NOTHING; + +INSERT INTO academic_years (title, start_date, end_date) VALUES + ('2024-2025', DATE '2024-09-01', DATE '2025-06-30'), + ('2025-2026', DATE '2025-09-01', DATE '2026-06-30'), + ('2026-2027', DATE '2026-09-01', DATE '2027-06-30'), + ('2027-2028', DATE '2027-09-01', DATE '2028-06-30') +ON CONFLICT (title) DO NOTHING; + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT + academic_year.id, + 'autumn', + academic_year.start_date, + MAKE_DATE(SPLIT_PART(academic_year.title, '-', 2)::INT, 1, 31) +FROM academic_years academic_year +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date) +SELECT + academic_year.id, + 'spring', + MAKE_DATE(SPLIT_PART(academic_year.title, '-', 2)::INT, 2, 1), + academic_year.end_date +FROM academic_years academic_year +ON CONFLICT (academic_year_id, semester_type) DO NOTHING; + +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; + +-- Исходные направления для трёх учебных лет. +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 || ' ' || academic_year.title, + academic_year.id, + specialty.id, + profile.id, + study_form.id, + 4 +FROM academic_years academic_year +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 academic_year.title IN ('2025-2026', '2026-2027', '2027-2028') +ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; + +-- По одному подходящему графику для каждого направления с массовыми группами. +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 || ' ' || academic_year.title, + academic_year.id, + specialty.id, + profile.id, + study_form.id, + program.course_count +FROM tmp_seed_programs program +JOIN specialties specialty + ON specialty.specialty_code = program.specialty_code +JOIN specialty_profiles profile + ON profile.specialty_id = specialty.id + AND profile.name = 'Без профиля' +JOIN education_forms study_form + ON study_form.name = program.education_form_name +JOIN academic_years academic_year + ON academic_year.title IN ('2025-2026', '2026-2027', '2027-2028') +WHERE program.ordinal <= 40 +ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING; + +-- Семантика прежней V2 сохранена: пустой график получает период Т на весь год +-- для каждого курса, а частично настроенный график не изменяется. +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 +); + +WITH ordered_subjects AS ( + SELECT + subject.id, + ROW_NUMBER() OVER (ORDER BY subject.id) AS subject_number + FROM subjects subject +), subject_totals AS ( + SELECT COUNT(*)::BIGINT AS subject_count + FROM ordered_subjects +) +INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id) +SELECT + calendar.id, + semester_number.number, + subject.id +FROM academic_calendars calendar +CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count * 2) AS semester_number(number) +CROSS JOIN GENERATE_SERIES(1, 3) AS subject_offset(number) +CROSS JOIN subject_totals totals +JOIN ordered_subjects subject + ON subject.subject_number = 1 + MOD( + calendar.id * 31 + semester_number.number * 7 + subject_offset.number, + totals.subject_count + ) +ON CONFLICT (calendar_id, semester_number, subject_id) DO NOTHING; + +INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id) +SELECT + student_group.id, + academic_year.id, + calendar.id +FROM student_groups student_group +JOIN academic_years academic_year + ON academic_year.title = '2025-2026' +JOIN specialties specialty + ON specialty.id = student_group.specialty_id +JOIN specialty_profiles profile + ON profile.id = student_group.specialty_profile_id +JOIN education_forms study_form + ON study_form.id = student_group.education_form_id +JOIN academic_calendars calendar + ON calendar.academic_year_id = academic_year.id + AND calendar.specialty_id = student_group.specialty_id + AND calendar.specialty_profile_id = student_group.specialty_profile_id + AND calendar.study_form_id = student_group.education_form_id + AND calendar.title = specialty.specialty_code + || ' ' || profile.name + || ' ' || study_form.name + || ' ' || academic_year.title +WHERE student_group.name IN ('ИВТ-21-1', 'ИБ-41м') + OR EXISTS ( + SELECT 1 + FROM tmp_seed_generated_groups generated_group + WHERE generated_group.group_name = student_group.name + ) +ON CONFLICT (group_id, academic_year_id) DO NOTHING; + +INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity) +SELECT + classroom.id, + equipment.id, + CASE + WHEN equipment.name = 'ПК' THEN GREATEST(classroom.capacity - 2, 1) + ELSE 1 + END +FROM classrooms classroom +CROSS JOIN equipments equipment +WHERE classroom.name LIKE 'А-%' + AND ( + equipment.name = 'Проектор' + OR (equipment.name = 'ПК' AND MOD(SUBSTRING(classroom.name FROM 3)::INT, 2) = 0) + OR (equipment.name = 'Интерактивная доска' AND MOD(SUBSTRING(classroom.name FROM 3)::INT, 3) = 0) + OR (equipment.name = 'Аудиосистема' AND MOD(SUBSTRING(classroom.name FROM 3)::INT, 5) = 0) + OR (equipment.name = 'Документ-камера' AND MOD(SUBSTRING(classroom.name FROM 3)::INT, 7) = 0) + ) +ON CONFLICT (classroom_id, equipment_id) DO NOTHING; + +-- -------------------------------------------------------------------------- +-- Версии и большой набор правил динамического расписания +-- -------------------------------------------------------------------------- +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 +ON CONFLICT (semester_id, version_number) DO NOTHING; + +INSERT INTO schedule_version_history (version_id, action, reason, created_at) +SELECT version.id, 'CREATED', 'Начальная версия расписания', version.created_at +FROM schedule_versions version +WHERE NOT EXISTS ( + SELECT 1 + FROM schedule_version_history history + WHERE history.version_id = version.id + AND history.action = 'CREATED' +); + +INSERT INTO schedule_version_history (version_id, action, reason, created_at) +SELECT version.id, 'PUBLISHED', 'Начальная публикация расписания', version.published_at +FROM schedule_versions version +WHERE version.status = 'PUBLISHED' + AND NOT EXISTS ( + SELECT 1 + FROM schedule_version_history history + WHERE history.version_id = version.id + AND history.action = 'PUBLISHED' + ); + +-- Четыре исторических правила из прежней V1. +CREATE TEMP TABLE tmp_seed_legacy_rules AS +SELECT + NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, + prepared.rule_key, + prepared.subject_id, + prepared.semester_id, + prepared.schedule_version_id, + prepared.lecture_academic_hours, + prepared.laboratory_academic_hours, + prepared.practice_academic_hours, + prepared.lecture_start_week, + prepared.laboratory_start_week, + prepared.practice_start_week +FROM ( + SELECT + rule_data.rule_key, + subject.id AS subject_id, + semester.id AS semester_id, + version.id AS schedule_version_id, + rule_data.lecture_academic_hours, + rule_data.laboratory_academic_hours, + rule_data.practice_academic_hours, + rule_data.lecture_start_week, + rule_data.laboratory_start_week, + rule_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 rule_data( + rule_key, + subject_name, + academic_year_title, + semester_type, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week + ) + JOIN subjects subject + ON subject.name = rule_data.subject_name + JOIN academic_years academic_year + ON academic_year.title = rule_data.academic_year_title + JOIN semesters semester + ON semester.academic_year_id = academic_year.id + AND semester.semester_type = rule_data.semester_type + JOIN schedule_versions version + ON version.semester_id = semester.id + AND version.status = 'PUBLISHED' +) prepared; + +INSERT INTO schedule_rules ( + id, + subject_id, + semester_id, + schedule_version_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week, + version_group_id +) +SELECT + legacy_rule.schedule_rule_id, + legacy_rule.subject_id, + legacy_rule.semester_id, + legacy_rule.schedule_version_id, + legacy_rule.lecture_academic_hours, + legacy_rule.laboratory_academic_hours, + legacy_rule.practice_academic_hours, + legacy_rule.lecture_start_week, + legacy_rule.laboratory_start_week, + legacy_rule.practice_start_week, + legacy_rule.schedule_rule_id +FROM tmp_seed_legacy_rules legacy_rule; + +INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) +SELECT legacy_rule.schedule_rule_id, student_group.id +FROM (VALUES + ('stream_math', 'ИВТ-21-1'), + ('stream_math', 'ИБ-41м'), + ('ivt_informatics_lab', 'ИВТ-21-1'), + ('ivt_databases_practice', 'ИВТ-21-1'), + ('ib_philosophy', 'ИБ-41м') +) AS rule_group(rule_key, group_name) +JOIN tmp_seed_legacy_rules legacy_rule + ON legacy_rule.rule_key = rule_group.rule_key +JOIN student_groups student_group + ON student_group.name = rule_group.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 + legacy_rule.schedule_rule_id, + slot_data.day_of_week, + slot_data.parity, + time_slot.id, + teacher.id, + classroom.id, + lesson_type.id, + slot_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 slot_data( + rule_key, + day_of_week, + parity, + time_slot_order, + teacher_username, + classroom_name, + lesson_type_name, + lesson_format +) +JOIN tmp_seed_legacy_rules legacy_rule + ON legacy_rule.rule_key = slot_data.rule_key +JOIN time_slot_scopes scope + ON scope.code = 'default' +JOIN time_slots time_slot + ON time_slot.time_slot_scope_id = scope.id + AND time_slot.order_number = slot_data.time_slot_order +JOIN users teacher + ON teacher.username = slot_data.teacher_username +JOIN classrooms classroom + ON classroom.name = slot_data.classroom_name +JOIN lesson_types lesson_type + ON lesson_type.name = slot_data.lesson_type_name; + +-- По три дополнительных правила для каждой из 202 групп: лекция, лабораторная и практика. +CREATE TEMP TABLE tmp_seed_bulk_rules AS +WITH ordered_subjects AS ( + SELECT + subject.id, + ROW_NUMBER() OVER (ORDER BY subject.id) AS subject_number + FROM subjects subject +), subject_totals AS ( + SELECT COUNT(*)::BIGINT AS subject_count + FROM ordered_subjects +) +SELECT + NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id, + student_group.id AS group_id, + rule_number.number AS rule_number, + subject.id AS subject_id, + semester.id AS semester_id, + version.id AS schedule_version_id +FROM student_groups student_group +CROSS JOIN GENERATE_SERIES(1, 3) AS rule_number(number) +CROSS JOIN subject_totals totals +JOIN ordered_subjects subject + ON subject.subject_number = 1 + MOD( + student_group.id * 5 + rule_number.number, + totals.subject_count + ) +JOIN academic_years academic_year + ON academic_year.title = '2025-2026' +JOIN semesters semester + ON semester.academic_year_id = academic_year.id + AND semester.semester_type = 'spring' +JOIN schedule_versions version + ON version.semester_id = semester.id + AND version.status = 'PUBLISHED' +WHERE student_group.name IN ('ИВТ-21-1', 'ИБ-41м') + OR EXISTS ( + SELECT 1 + FROM tmp_seed_generated_groups generated_group + WHERE generated_group.group_name = student_group.name + ); + +INSERT INTO schedule_rules ( + id, + subject_id, + semester_id, + schedule_version_id, + lecture_academic_hours, + laboratory_academic_hours, + practice_academic_hours, + lecture_start_week, + laboratory_start_week, + practice_start_week, + version_group_id, + change_reason +) +SELECT + bulk_rule.schedule_rule_id, + bulk_rule.subject_id, + bulk_rule.semester_id, + bulk_rule.schedule_version_id, + CASE WHEN bulk_rule.rule_number = 1 THEN 36 ELSE 0 END, + CASE WHEN bulk_rule.rule_number = 2 THEN 36 ELSE 0 END, + CASE WHEN bulk_rule.rule_number = 3 THEN 36 ELSE 0 END, + 1, + 1, + 1, + bulk_rule.schedule_rule_id, + 'Автоматически создано тестовым набором V2' +FROM tmp_seed_bulk_rules bulk_rule; + +INSERT INTO schedule_rule_groups (schedule_rule_id, group_id) +SELECT bulk_rule.schedule_rule_id, bulk_rule.group_id +FROM tmp_seed_bulk_rules bulk_rule +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 + bulk_rule.schedule_rule_id, + 1 + MOD(bulk_rule.group_id * 2 + bulk_rule.rule_number, 5)::INT, + (ARRAY['BOTH', 'EVEN', 'ODD'])[1 + MOD(bulk_rule.group_id + bulk_rule.rule_number, 3)::INT], + time_slot.id, + teacher.user_id, + classroom.id, + lesson_type.id, + CASE WHEN MOD(bulk_rule.group_id + bulk_rule.rule_number, 11) = 0 THEN 'Онлайн' ELSE 'Очно' END +FROM tmp_seed_bulk_rules bulk_rule +JOIN time_slot_scopes scope + ON scope.code = 'default' +JOIN time_slots time_slot + ON time_slot.time_slot_scope_id = scope.id + AND time_slot.order_number = 1 + MOD(bulk_rule.group_id + bulk_rule.rule_number, 7)::INT +JOIN lesson_types lesson_type + ON lesson_type.name = CASE bulk_rule.rule_number + WHEN 1 THEN 'Лекция' + WHEN 2 THEN 'Лабораторная работа' + ELSE 'Практика' + END +JOIN LATERAL ( + SELECT teacher_subject.user_id + FROM teacher_subjects teacher_subject + WHERE teacher_subject.subject_id = bulk_rule.subject_id + ORDER BY teacher_subject.user_id + LIMIT 1 +) teacher ON TRUE +JOIN LATERAL ( + SELECT candidate.id + FROM classrooms candidate + ORDER BY candidate.id + OFFSET MOD( + bulk_rule.group_id * 7 + bulk_rule.rule_number, + (SELECT COUNT(*) FROM classrooms) + )::INT + LIMIT 1 +) classroom ON TRUE; + +INSERT INTO schedule_rule_slot_subgroups (schedule_rule_slot_id, subgroup_id) +SELECT + rule_slot.id, + subgroup.id +FROM tmp_seed_bulk_rules bulk_rule +JOIN schedule_rule_slots rule_slot + ON rule_slot.schedule_rule_id = bulk_rule.schedule_rule_id +JOIN LATERAL ( + SELECT candidate.id + FROM subgroups candidate + WHERE candidate.group_id = bulk_rule.group_id + AND candidate.status <> 'ARCHIVED' + ORDER BY candidate.name + LIMIT 1 +) subgroup ON TRUE +WHERE bulk_rule.rule_number = 2 +ON CONFLICT DO NOTHING; + +-- -------------------------------------------------------------------------- +-- Дополнительные workflow-данные +-- -------------------------------------------------------------------------- +WITH selected_teachers AS ( + SELECT + teacher.id, + ROW_NUMBER() OVER (ORDER BY teacher.id) AS teacher_number + FROM users teacher + WHERE teacher.role = 'TEACHER' + ORDER BY teacher.id + LIMIT 60 +) +INSERT INTO teacher_preferences ( + teacher_id, + semester_id, + preference_type, + day_of_week, + time_slot_id, + comment, + status, + requested_by +) +SELECT + teacher.id, + semester.id, + 'SOFT_PREFERRED', + 1 + MOD(teacher.teacher_number - 1, 5)::INT, + time_slot.id, + 'Тестовое пожелание преподавателя из V2', + CASE WHEN MOD(teacher.teacher_number, 4) = 0 THEN 'PENDING' ELSE 'APPROVED' END, + teacher.id +FROM selected_teachers teacher +JOIN academic_years academic_year + ON academic_year.title = '2025-2026' +JOIN semesters semester + ON semester.academic_year_id = academic_year.id + AND semester.semester_type = 'spring' +JOIN time_slot_scopes scope + ON scope.code = 'default' +JOIN time_slots time_slot + ON time_slot.time_slot_scope_id = scope.id + AND time_slot.order_number = 1 + MOD(teacher.teacher_number - 1, 7)::INT; + +WITH selected_teachers AS ( + SELECT + teacher.id, + ROW_NUMBER() OVER (ORDER BY teacher.id) AS teacher_number + FROM users teacher + WHERE teacher.role = 'TEACHER' + ORDER BY teacher.id + LIMIT 20 +) +INSERT INTO teacher_absences ( + teacher_id, + start_date, + end_date, + reason, + status, + requested_by +) +SELECT + teacher.id, + DATE '2026-03-01' + (teacher.teacher_number::INT * 2), + DATE '2026-03-01' + (teacher.teacher_number::INT * 2), + 'Тестовое отсутствие для проверки мастера замен', + CASE WHEN MOD(teacher.teacher_number, 3) = 0 THEN 'APPROVED' ELSE 'PENDING' END, + teacher.id +FROM selected_teachers teacher; + +-- -------------------------------------------------------------------------- +-- Самопроверка полноты тестового набора +-- -------------------------------------------------------------------------- +DO $$ +DECLARE + generated_group_count BIGINT; + generated_teacher_count BIGINT; + seeded_subgroup_count BIGINT; + legacy_rule_count BIGINT; + bulk_rule_count BIGINT; + bulk_slot_count BIGINT; + missing_assignment_count BIGINT; + incomplete_calendar_count BIGINT; +BEGIN + SELECT COUNT(DISTINCT student_group.name) + INTO generated_group_count + FROM student_groups student_group + JOIN tmp_seed_generated_groups generated_group + ON generated_group.group_name = student_group.name; + + SELECT COUNT(*) + INTO generated_teacher_count + FROM users teacher + JOIN tmp_seed_teachers seed_teacher + ON seed_teacher.username = teacher.username + WHERE teacher.role = 'TEACHER'; + + SELECT COUNT(*) + INTO seeded_subgroup_count + FROM subgroups subgroup + JOIN student_groups student_group + ON student_group.id = subgroup.group_id + WHERE student_group.name IN ('ИВТ-21-1', 'ИБ-41м') + OR EXISTS ( + SELECT 1 + FROM tmp_seed_generated_groups generated_group + WHERE generated_group.group_name = student_group.name + ); + + SELECT COUNT(*) + INTO legacy_rule_count + FROM schedule_rules rule + JOIN tmp_seed_legacy_rules seed_rule + ON seed_rule.schedule_rule_id = rule.id; + + SELECT COUNT(*) + INTO bulk_rule_count + FROM schedule_rules rule + JOIN tmp_seed_bulk_rules seed_rule + ON seed_rule.schedule_rule_id = rule.id; + + SELECT COUNT(*) + INTO bulk_slot_count + FROM schedule_rule_slots slot + JOIN tmp_seed_bulk_rules seed_rule + ON seed_rule.schedule_rule_id = slot.schedule_rule_id; + + SELECT COUNT(*) + INTO missing_assignment_count + FROM student_groups student_group + WHERE ( + student_group.name IN ('ИВТ-21-1', 'ИБ-41м') + OR EXISTS ( + SELECT 1 + FROM tmp_seed_generated_groups generated_group + WHERE generated_group.group_name = student_group.name + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM student_group_calendar_assignments assignment + JOIN academic_years academic_year + ON academic_year.id = assignment.academic_year_id + WHERE assignment.group_id = student_group.id + AND academic_year.title = '2025-2026' + ); + + SELECT COUNT(*) + INTO incomplete_calendar_count + FROM academic_calendars calendar + JOIN academic_years academic_year + ON academic_year.id = calendar.academic_year_id + JOIN specialties specialty + ON specialty.id = calendar.specialty_id + JOIN specialty_profiles profile + ON profile.id = calendar.specialty_profile_id + JOIN education_forms study_form + ON study_form.id = calendar.study_form_id + WHERE academic_year.title = '2025-2026' + AND calendar.title = specialty.specialty_code + || ' ' || profile.name + || ' ' || study_form.name + || ' ' || academic_year.title + AND ( + specialty.specialty_code IN ('09.03.01', '10.03.01') + OR EXISTS ( + SELECT 1 + FROM tmp_seed_programs program + WHERE program.ordinal <= 40 + AND program.specialty_code = specialty.specialty_code + AND program.education_form_name = study_form.name + ) + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM academic_calendar_periods period + WHERE period.calendar_id = calendar.id + ) + OR ( + SELECT COUNT(DISTINCT period.course_number) + FROM academic_calendar_periods period + WHERE period.calendar_id = calendar.id + ) <> calendar.course_count + ); + + IF generated_group_count <> 200 + OR generated_teacher_count <> 150 + OR seeded_subgroup_count <> 404 + OR legacy_rule_count <> 4 + OR bulk_rule_count <> 606 + OR bulk_slot_count <> 606 + OR missing_assignment_count <> 0 + OR incomplete_calendar_count <> 0 THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = FORMAT( + 'Тестовый набор V2 неполон: группы=%s/200, преподаватели=%s/150, подгруппы=%s/404, исходные правила=%s/4, массовые правила=%s/606, массовые слоты=%s/606, группы без графика=%s, неполные графики=%s', + generated_group_count, + generated_teacher_count, + seeded_subgroup_count, + legacy_rule_count, + bulk_rule_count, + bulk_slot_count, + missing_assignment_count, + incomplete_calendar_count + ); + END IF; +END $$; + +DROP TABLE tmp_seed_bulk_rules; +DROP TABLE tmp_seed_legacy_rules; +DROP TABLE tmp_seed_subjects; +DROP TABLE tmp_seed_generated_groups; +DROP TABLE tmp_seed_teachers; +DROP TABLE tmp_seed_credentials; +DROP TABLE tmp_seed_programs; diff --git a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java index 9ec68ac..4aa92f9 100644 --- a/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/AcademicCalendarControllerTest.java @@ -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); diff --git a/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java index d7ee900..c20b98c 100644 --- a/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java +++ b/backend/src/test/java/com/magistr/app/controller/ScheduleRuleAdminControllerTest.java @@ -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( diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java index 62c3f56..898cb32 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest.java @@ -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, diff --git a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java index 3627b12..10ef49f 100644 --- a/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/AcademicStructureInvariantIntegrationTest.java @@ -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); diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java index af3067e..392d9b0 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleOverrideMigrationIntegrationTest.java @@ -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(); } diff --git a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java index a17beba..92a7578 100644 --- a/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/ScheduleRuleHoursMigrationIntegrationTest.java @@ -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(); } diff --git a/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java index 578d2b4..07122c7 100644 --- a/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/SubjectOwnershipInvariantIntegrationTest.java @@ -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(); diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java index b2f023d..3bb8485 100644 --- a/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TeacherDepartmentInvariantIntegrationTest.java @@ -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(); } diff --git a/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java index 42f7d1a..c858110 100644 --- a/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TeacherPreferencesMigrationIntegrationTest.java @@ -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(*) diff --git a/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java index 7f462e4..7b13e8c 100644 --- a/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/migration/TimeSlotInvariantMigrationIntegrationTest.java @@ -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) { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java index 65554a3..265016e 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java @@ -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(); diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index 439091b..1c75a02 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -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` установлено только для `Т`. ## Правило генерации расписания diff --git a/docs/API.md b/docs/API.md index 4fd045f..e51f356 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index c692561..b7f5039 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом, дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения: сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего -графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет -такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или -полностью настроенные сетки она не изменяет. +графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции +`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам +без единого периода; частично или полностью настроенные сетки не изменяются. Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d0098ff..a2126f4 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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-аккаунты. ### Полный сброс БД (локально) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 849a334..0231e19 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 ``` diff --git a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index d165d8d..fa695a1 100644 --- a/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/docs/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -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` заполняет их +связанным тестовым набором: | Таблица | Назначение | |---------|------------| diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index f5e0439..a64d6b1 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -191,7 +191,7 @@ frontend/ | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели, привязка преподавателей, заявки на новых преподавателей и нагрузка | `/api/department/*`, `/api/department/teacher-requests`, `/api/workload/teachers` | | `schedule-view` | Просмотр опубликованного расписания: учебный год и семестр выбираются каскадом в дополнительных фильтрах, поисковые списки сущностей при открытии остаются свёрнутыми и используют размытый непрозрачный слой меню, найденные расписания переключаются без общего контекста страницы, а `ADMIN` и `EDUCATION_OFFICE` редактируют конкретное занятие в боковой панели без изменения правила | `/api/schedule/semesters`, `/api/schedule/search`, `/api/edu-office/schedule/overrides`, `/api/admin/time-slots/effective` | | `teacher-absences` | Запросы преподавателей: отсутствия и мастер замены, согласование семестровых пожеланий, заявки на перенос, аудиторию или отмену; преподаватель ищется асинхронно по всем кафедрам для `ADMIN`/`EDUCATION_OFFICE` и только по своей кафедре для `DEPARTMENT`; поисковое меню размывает фон, а фильтры согласования оформлены для светлой и тёмной тем | `/api/teacher-absences`, `/api/teacher-preferences`, `/api/teacher-change-requests`, `/api/users/teachers/options` | -| `schedule` | Конструктор правил: локальный каскад `Учебный год → Семестр → Версия` открывает опубликованное расписание или черновик; первичная загрузка скрывает нестабильную компоновку до подготовки полей, а таблица правил использует фиксированные колонки; группы, преподаватели и аудитории ищутся на сервере, подгруппы загружаются только для выбранных групп; предупреждение о несохранённых изменениях появляется только после действия пользователя | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/groups/options`, `/api/subgroups?groupId=…`, `/api/users/teachers/options`, `/api/classrooms/options` | +| `schedule` | Конструктор правил: локальный каскад `Учебный год → Семестр → Версия` открывает опубликованное расписание или черновик; первичная загрузка скрывает нестабильную компоновку до подготовки полей, а таблица правил использует фиксированные колонки и серверную пагинацию; группы, преподаватели и аудитории ищутся на сервере, подгруппы загружаются только для выбранных групп; предупреждение о несохранённых изменениях появляется только после действия пользователя | `/api/admin/schedule-rules`, `/api/edu-office/schedule/versions`, `/api/teacher-preferences`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/groups/options`, `/api/subgroups?groupId=…`, `/api/users/teachers/options`, `/api/classrooms/options` | | `schedule-versions` | Контур публикации с локальным выбором учебного года и семестра: текущая версия, черновики, diff, архив, восстановление и журнал | `/api/edu-office/schedule/versions` | | `schedule-quality` | Диагностика выбранной версии через каскад `Учебный год → Семестр → Версия`: оценка, метрики и проблемные правила с переходом к их редактированию | `/api/edu-office/schedule/quality`, `/api/admin/schedule-rules` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков, Excel-подобный редактор дневной сетки и привязка дисциплин к семестрам графика; редко используемая форма создания учебного года расположена внизу вкладки `Графики` | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/academic-calendars/{id}/subjects`, `/api/admin/calendar/activity-types`, `/api/specialties`, `/api/specialties/{id}/profiles`, `/api/education-forms`, `/api/subjects` | @@ -246,6 +246,10 @@ frontend/ - Режим `Изменения за период` загружает overrides, у которых в двухнедельный диапазон попала исходная или целевая дата. Поэтому в реестре остаются отменённые занятия и входящие/исходящие переносы, которых нет на исходном месте в таблице. После сохранения, отмены или возврата расписание и реестр обновляются без смены выбранного периода. Для `DEPARTMENT` и `SCHEDULE_VIEWER` вкладка остаётся полностью read-only: кнопка, панель и запросы управляющего API не создаются. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `university-structure` содержит внутренние разделы `Кафедры`, `Специальности` и `Профили`, оформленные тем же визуальным паттерном вкладок, что и `academic-calendar`: администратор создаёт профили как из общего списка, так и через кнопку `Профили` у конкретной специальности. +- Таблицы правил конструктора и календарных графиков загружают страницы с сервера: по умолчанию 25 записей, доступны 50 и 100. Поиск правил работает по дисциплине, группе и ID; поиск графиков — по названию, направлению, профилю, форме обучения и году, дополнительно доступен фильтр учебного года. Поиск сбрасывает страницу, устаревшие ответы не заменяют актуальную выдачу. Скрытая визуальная матрица конструктора загружается и строится только при открытии; переход из анализа качества получает нужное правило по ID независимо от страницы таблицы. Программное обновление селектов периода обновляет обёртки без повторного вызова обработчиков смены года и семестра. +- В разделе календарного графика списки графиков в «Сетках» и «Дисциплинах» используют `AsyncCombobox` и `/api/admin/academic-calendars/options`: поиск на сервере с задержкой 320 мс, до 20 результатов плюс выбранная запись. Полный справочник графиков не загружается в DOM; выбор редактора не зависит от страницы таблицы. Отмена перехода с несохранёнными дисциплинами восстанавливает выбранный график, устаревшие ответы загрузки игнорируются, а сохранение сетки требует предварительной загрузки именно выбранного графика. +- В «Структуре вуза» кафедры, специальности и профили имеют отдельный поиск и клиентскую пагинацию по 25/50/100 строк. Поиск учитывает названия, коды и ID, а у профилей также специальность и описание. +- В «Качестве расписания» правила после фильтрации упорядочиваются по суммарному штрафу по убыванию; при равном штрафе сохраняется исходный порядок. - Вкладка `schedule` имеет заголовок `Конструктор правил` и не обращается к старым `lessons` API. До завершения первичной загрузки справочников и правил вкладка показывает единое состояние загрузки, после чего открывает уже подготовленную форму; таблица правил использует `table-layout: fixed`, явный `colgroup` и отключённую анимацию строк, поэтому заголовки колонок не смещаются при замене состояния загрузки данными. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Для выбранных преподавателя и семестра frontend загружает согласованные `/api/teacher-preferences` и выводит под строкой слота компактные маркеры строгой недоступности, предпочтительного или нежелательного времени и пожеланий «пары подряд»/«без окон»; количество недоступных дат показывается отдельным маркером. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Единственный слот имеет действие `Очистить`, которое сбрасывает его поля; при наличии нескольких слотов у каждой строки показывается действие `Удалить`. Оба действия используют размер формы, минимальную ширину `125px` и высоту `44px`, поэтому совпадают по масштабу с соседними селектами. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары открывает контекстное меню возле нажатой кнопки, с автоматическим разворотом от границ viewport: можно открыть полное правило в форме, изменить только день и базовую пару выбранного слота через компактную модалку или удалить правило целиком. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий и внутри себя разделена на три вкладки: `Графики` для учебных годов, семестров и карточек календарных графиков, `Сетки` для редактора дневной сетки, `Дисциплины` для ручной привязки дисциплин из `/api/subjects` к номерам учебных семестров графика. Форма редкого ежегодного действия — создания учебного года вместе с семестрами — расположена после реестров учебных годов и календарных графиков, в нижней части вкладки `Графики`. Пользователь вводит только четырёхзначный год начала, например `2026`; интерфейс подставляет год окончания `2027`, формирует название `2026-2027` и внутренний период `01.09.2026–30.06.2027`. Границы семестров можно скорректировать до сохранения. У календарного графика нет поля ввода и отдельного столбца названия; отображаемая подпись и сохраняемое backend название автоматически собираются как `код специальности — профиль обучения — форма обучения — учебный год`. Frontend не отправляет `title` календарного графика в payload. Администратор выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. Каждый код активности окрашивает ячейку, подсказку, диалог и итоговый чип своим `colorCode`. REST-контракт сетки остаётся дневным: backend объединяет соседние даты с одинаковой активностью в периоды при записи и разворачивает их при чтении. Шесть полей дат принимают до восьми цифр, автоматически показывают их в формате `ДД.ММ.ГГГГ`, ограничивают год четырьмя цифрами и перед отправкой преобразуют значение в ISO `ГГГГ-ММ-ДД`; два поля учебного года принимают и показывают только четыре цифры. - При сохранении правила во вкладке `schedule` frontend различает `409 Conflict` от остальных ошибок API. Если backend возвращает `conflictRule`, открывается широкое модальное окно разрешения конфликта: пользователь видит новое и ранее созданное правило, чипы причины конфликта из `conflictReasons` и подсветку соответствующих полей (`teacher`, `classroom`, `group`). Пользователь меняет у конфликтующего правила день, чётность, пару, преподавателя или аудиторию, после чего frontend сохраняет конфликтующее правило через `PUT /api/admin/schedule-rules/{id}` и автоматически повторяет исходный `POST` или `PUT`. Если после переноса появляется следующий конфликт, показывается следующее конфликтующее правило без потери исходного черновика. diff --git a/docs/README.md b/docs/README.md index 04be510..77d1d0d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик | `admin` | `admin` | Администратор | | `Тестовый преподаватель` | `1234567890` | Преподаватель | +Эти и остальные прежние демонстрационные записи загружаются миграцией +`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических +преподавателей и связанные дисциплины, аудитории, календари и правила расписания. +Пароли массовых преподавателей генерируются случайно и не предназначены для входа. + ### Полезные команды ```bash diff --git a/docs/SCHEDULE_PROPOSAL.md b/docs/SCHEDULE_PROPOSAL.md index c8c2cc8..4f666e9 100644 --- a/docs/SCHEDULE_PROPOSAL.md +++ b/docs/SCHEDULE_PROPOSAL.md @@ -37,7 +37,8 @@ 2. Для группы находится назначенный календарный учебный график. 3. По году начала обучения вычисляется текущий курс группы. 4. По курсу и дате находится код активности. -5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`. +5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных + `V2__test_data.sql` это `Т`. 6. Для разрешённого дня выбираются активные правила и слоты. 7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе. diff --git a/docs/SCHEDULE_TASKS.md b/docs/SCHEDULE_TASKS.md index ac36a22..59f2a4e 100644 --- a/docs/SCHEDULE_TASKS.md +++ b/docs/SCHEDULE_TASKS.md @@ -27,7 +27,8 @@ ## Проверочный сценарий 1. Полностью сбросить локальную БД. -2. Запустить приложение и применить `V1__init.sql`. +2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор + `V2__test_data.sql`. 3. Создать профиль специальности. 4. Создать календарный график и заполнить дневную сетку. 5. Назначить график группе. diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js index 7010ebe..8a93616 100644 --- a/frontend/admin/js/views/academic-calendar.js +++ b/frontend/admin/js/views/academic-calendar.js @@ -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 = ''; + 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 = '' + academicYears.map(year => + ``).join(''); + calendarYearFilter.value = selectedYear; + refreshSelectVisuals(calendarYearFilter); } catch (error) { academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; @@ -566,13 +608,28 @@ export async function initAcademicCalendar() { } async function loadCalendars() { + const request = ++calendarRequest; + calendarPagination?.replaceChildren(); calendarsTbody.innerHTML = 'Загрузка...'; 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 = `Ошибка загрузки: ${escapeHtml(error.message)}`; throw error; } @@ -580,7 +637,7 @@ export async function initAcademicCalendar() { function renderCalendars() { if (!calendars.length) { - calendarsTbody.innerHTML = 'Календарные графики не созданы'; + calendarsTbody.innerHTML = 'Календарные графики не найдены'; 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 = '
Загрузка...
'; 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 = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; 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 = '' + - calendars.map(calendar => - `` - ).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 - ? '' + calendars.map(calendar => - `` - ).join('') - : ''; - 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 + ? `` + : ''; + 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(); } diff --git a/frontend/admin/js/views/schedule-quality.js b/frontend/admin/js/views/schedule-quality.js index 45567df..99eb8cf 100644 --- a/frontend/admin/js/views/schedule-quality.js +++ b/frontend/admin/js/views/schedule-quality.js @@ -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 = '') { diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index 3fcb236..17601b2 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -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 = 'Загрузка...'; 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 = `Ошибка загрузки: ${escapeHtml(error.message)}`; 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 = 'Правила расписания не созданы'; + rulesTbody.innerHTML = 'Правила не найдены'; 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 = `
@@ -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; diff --git a/frontend/admin/js/views/university-structure.js b/frontend/admin/js/views/university-structure.js index 34ad5a1..175dc76 100644 --- a/frontend/admin/js/views/university-structure.js +++ b/frontend/admin/js/views/university-structure.js @@ -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 = ''; + const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]); + if (!visible.length) { + deptTbody.innerHTML = ''; return; } - deptTbody.innerHTML = departments.map(d => ` + deptTbody.innerHTML = visible.map(d => ` @@ -123,11 +149,12 @@ export async function initUniversityStructure() { } function renderSpecialties() { - if (!specialties || !specialties.length) { - specTbody.innerHTML = ''; + const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]); + if (!visible.length) { + specTbody.innerHTML = ''; return; } - specTbody.innerHTML = specialties.map(s => ` + specTbody.innerHTML = visible.map(s => ` @@ -428,11 +455,12 @@ export async function initUniversityStructure() { } function renderAllProfiles() { - if (!allProfiles || !allProfiles.length) { - tabProfileTbody.innerHTML = ''; + const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]); + if (!visible.length) { + tabProfileTbody.innerHTML = ''; return; } - tabProfileTbody.innerHTML = allProfiles.map(p => ` + tabProfileTbody.innerHTML = visible.map(p => ` diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html index 279c471..7224368 100644 --- a/frontend/admin/views/academic-calendar.html +++ b/frontend/admin/views/academic-calendar.html @@ -76,6 +76,16 @@

Календарные графики

+
+
+ + +
+
+ + +
+
Кафедры не созданы
Ничего не найдено
${d.id} ${escapeHtml(d.departmentName || d.name)}
Специальности не созданы
Ничего не найдено
${s.id} ${escapeHtml(s.specialityName || s.name)}
Профили не созданы
Ничего не найдено
${p.id} ${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}
@@ -95,6 +105,7 @@
+
@@ -181,7 +192,7 @@
-
@@ -226,7 +237,7 @@
-
diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index 001984c..05878a4 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -221,6 +221,10 @@

Правила динамического расписания

+
+ + +
@@ -250,6 +254,7 @@
+
+
+ + +
@@ -47,6 +51,7 @@
+ @@ -74,6 +79,10 @@

Специальности

+
+ + +
@@ -91,6 +100,7 @@
+ @@ -128,6 +138,10 @@

Профили обучения

+
+ + +
@@ -146,6 +160,7 @@
+ diff --git a/frontend/tests/schedule-quality.test.mjs b/frontend/tests/schedule-quality.test.mjs index 0fb2155..b8e82bb 100644 --- a/frontend/tests/schedule-quality.test.mjs +++ b/frontend/tests/schedule-quality.test.mjs @@ -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, 'малый'); +}); diff --git a/graphify-out/memory/query_20260906_161317_где_реализована_загрузка_конструктора_правил__кале.md b/graphify-out/memory/query_20260906_161317_где_реализована_загрузка_конструктора_правил__кале.md new file mode 100644 index 0000000..2d91504 --- /dev/null +++ b/graphify-out/memory/query_20260906_161317_где_реализована_загрузка_конструктора_правил__кале.md @@ -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 \ No newline at end of file