много всего и тестовые данные
This commit is contained in:
10
AGENTS.md
10
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)
|
||||
|
||||
### Языковые требования
|
||||
|
||||
@@ -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-БД.
|
||||
|
||||
## Высокий приоритет
|
||||
|
||||
@@ -62,6 +62,20 @@ public class AcademicCalendarController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<AcademicCalendarDto> getCalendarsPage(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(required = false) Long academicYearId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return PageResponse.from(calendarRepository.searchOptions(
|
||||
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
|
||||
.map(this::toCalendarDto));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
public List<AcademicCalendarDto> searchCalendarOptions(
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
||||
|
||||
import com.magistr.app.config.auth.RequireRoles;
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||
import com.magistr.app.service.ScheduleRuleService;
|
||||
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
|
||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public PageResponse<ScheduleRuleDto> getPage(
|
||||
@RequestParam Long versionId,
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return scheduleRuleService.getPage(versionId, query, page, size);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
||||
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.magistr.app.repository;
|
||||
import com.magistr.app.model.ScheduleRule;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -13,6 +15,23 @@ import java.util.Collection;
|
||||
|
||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||
|
||||
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
|
||||
@Query("""
|
||||
select r.id from ScheduleRule r
|
||||
join r.scheduleVersion version
|
||||
join r.subject subject
|
||||
where r.status <> 'ARCHIVED'
|
||||
and version.id = :versionId
|
||||
and (:query = '' or str(r.id) = :query
|
||||
or lower(subject.name) like concat('%', :query, '%')
|
||||
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
|
||||
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
|
||||
order by r.id desc
|
||||
""")
|
||||
Page<Long> searchPageIds(
|
||||
@Param("versionId") Long versionId, @Param("query") String query,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
||||
where r.id = :id
|
||||
""")
|
||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select distinct r
|
||||
from ScheduleRule r
|
||||
left join fetch r.subject
|
||||
left join fetch r.semester sem
|
||||
left join fetch sem.academicYear
|
||||
left join fetch r.scheduleVersion
|
||||
left join fetch r.groups groups
|
||||
left join fetch r.slots slots
|
||||
left join fetch slots.timeSlot
|
||||
left join fetch slots.subgroups slotSubgroups
|
||||
left join fetch slotSubgroups.studentGroup
|
||||
left join fetch slots.teacher
|
||||
left join fetch slots.classroom
|
||||
left join fetch slots.lessonType
|
||||
where r.id in :ids
|
||||
""")
|
||||
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.dto.ScheduleRuleDto;
|
||||
import com.magistr.app.dto.PageResponse;
|
||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||
import com.magistr.app.config.auth.AuthContext;
|
||||
import com.magistr.app.model.Classroom;
|
||||
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
|
||||
import com.magistr.app.repository.TimeSlotRepository;
|
||||
import com.magistr.app.repository.UserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
|
||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
|
||||
var ids = scheduleRuleRepository.searchPageIds(versionId,
|
||||
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
|
||||
if (ids.isEmpty()) {
|
||||
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
|
||||
ids.getTotalPages(), ids.isFirst(), ids.isLast());
|
||||
}
|
||||
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
|
||||
return PageResponse.from(ids.map(details::get));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||
return getAll(semesterId, groupId, null);
|
||||
|
||||
@@ -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 'Версия, из которой был скопирован черновик';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
1410
backend/src/main/resources/db/migration/V2__test_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(*)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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` установлено только для `Т`.
|
||||
|
||||
## Правило генерации расписания
|
||||
|
||||
|
||||
15
docs/API.md
15
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`
|
||||
|
||||
@@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
||||
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
||||
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
||||
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет
|
||||
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или
|
||||
полностью настроенные сетки она не изменяет.
|
||||
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
|
||||
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
|
||||
без единого периода; частично или полностью настроенные сетки не изменяются.
|
||||
|
||||
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
||||
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
||||
|
||||
@@ -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-аккаунты.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
|
||||
## Статус
|
||||
|
||||
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
|
||||
Новая модель является базовой схемой проекта и создаётся в
|
||||
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
|
||||
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
|
||||
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
|
||||
|
||||
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
||||
|
||||
## База данных
|
||||
|
||||
`V1__init.sql` создаёт и заполняет:
|
||||
`V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
|
||||
связанным тестовым набором:
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---------|------------|
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
|
||||
| `admin` | `admin` | Администратор |
|
||||
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
||||
|
||||
Эти и остальные прежние демонстрационные записи загружаются миграцией
|
||||
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
|
||||
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
|
||||
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
|
||||
|
||||
### Полезные команды
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
2. Для группы находится назначенный календарный учебный график.
|
||||
3. По году начала обучения вычисляется текущий курс группы.
|
||||
4. По курсу и дате находится код активности.
|
||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
|
||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
|
||||
`V2__test_data.sql` это `Т`.
|
||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
## Проверочный сценарий
|
||||
|
||||
1. Полностью сбросить локальную БД.
|
||||
2. Запустить приложение и применить `V1__init.sql`.
|
||||
2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
|
||||
`V2__test_data.sql`.
|
||||
3. Создать профиль специальности.
|
||||
4. Создать календарный график и заполнить дневную сетку.
|
||||
5. Назначить график группе.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { api } from '../api.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
|
||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
||||
@@ -209,6 +212,14 @@ export async function initAcademicCalendar() {
|
||||
let studyForms = [];
|
||||
let activityTypes = [];
|
||||
let calendars = [];
|
||||
let calendarPage = 0;
|
||||
let calendarSize = 25;
|
||||
let calendarRequest = 0;
|
||||
const calendarCache = new Map();
|
||||
const calendarComboboxes = new Map();
|
||||
const calendarSearch = document.getElementById('academic-calendars-search');
|
||||
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
|
||||
const calendarPagination = document.getElementById('academic-calendars-pagination');
|
||||
let subjects = [];
|
||||
let calendarSubjectRows = [];
|
||||
let timeSlotScopes = [];
|
||||
@@ -224,9 +235,27 @@ export async function initAcademicCalendar() {
|
||||
let calendarDayTooltipPositionAnimation = null;
|
||||
let loadedGridCalendarId = '';
|
||||
let loadedSubjectCalendarId = '';
|
||||
let subjectLoadRequest = 0;
|
||||
let gridLoadRequest = 0;
|
||||
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
|
||||
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
|
||||
|
||||
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
|
||||
select.innerHTML = '<option value="">Выберите график</option>';
|
||||
calendarComboboxes.set(select, new AsyncCombobox(select, {
|
||||
placeholder: 'Поиск по направлению, профилю, форме или году',
|
||||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
|
||||
optionDescription: calendar => calendar.specialtyName || '',
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: 20 });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||
return items;
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
bindEvents();
|
||||
|
||||
try {
|
||||
@@ -261,6 +290,14 @@ export async function initAcademicCalendar() {
|
||||
});
|
||||
});
|
||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||
calendarSearch?.addEventListener('input', debounce(() => {
|
||||
calendarPage = 0;
|
||||
loadCalendars();
|
||||
}, 300));
|
||||
calendarYearFilter?.addEventListener('change', () => {
|
||||
calendarPage = 0;
|
||||
loadCalendars();
|
||||
});
|
||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||||
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
|
||||
@@ -363,6 +400,11 @@ export async function initAcademicCalendar() {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
renderAcademicYears();
|
||||
populateYearSelects();
|
||||
const selectedYear = calendarYearFilter.value;
|
||||
calendarYearFilter.innerHTML = '<option value="">Все учебные годы</option>' + academicYears.map(year =>
|
||||
`<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||
calendarYearFilter.value = selectedYear;
|
||||
refreshSelectVisuals(calendarYearFilter);
|
||||
} catch (error) {
|
||||
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
@@ -566,13 +608,28 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
const request = ++calendarRequest;
|
||||
calendarPagination?.replaceChildren();
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||
try {
|
||||
calendars = await api.get('/api/admin/academic-calendars');
|
||||
const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
|
||||
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
|
||||
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
|
||||
if (request !== calendarRequest) return;
|
||||
if (calendarPage > 0 && calendarPage >= page.totalPages) {
|
||||
calendarPage = Math.max(0, page.totalPages - 1);
|
||||
return loadCalendars();
|
||||
}
|
||||
calendars = page.items;
|
||||
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||
renderCalendars();
|
||||
populateEditorCalendarSelect();
|
||||
populateSubjectCalendarSelect();
|
||||
renderPagination(calendarPagination, page, {
|
||||
label: 'графиков',
|
||||
onPage: value => { calendarPage = value; loadCalendars(); },
|
||||
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
|
||||
});
|
||||
} catch (error) {
|
||||
if (request !== calendarRequest) return;
|
||||
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
throw error;
|
||||
}
|
||||
@@ -580,7 +637,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function renderCalendars() {
|
||||
if (!calendars.length) {
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
|
||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||
@@ -620,6 +677,10 @@ export async function initAcademicCalendar() {
|
||||
const saved = id
|
||||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||||
: await api.post('/api/admin/academic-calendars', payload);
|
||||
calendarCache.set(String(saved.id), saved);
|
||||
calendarComboboxes.forEach((combobox, select) => {
|
||||
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
|
||||
});
|
||||
const savedTitle = buildAcademicCalendarTitle(saved);
|
||||
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
|
||||
resetCalendarForm();
|
||||
@@ -654,7 +715,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
if (gridButton) {
|
||||
activateCalendarTab('grid');
|
||||
editorCalendarSelect.value = gridButton.dataset.id;
|
||||
setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
|
||||
refreshSelectVisuals(editorCalendarSelect);
|
||||
await loadCalendarGrid();
|
||||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -663,7 +724,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
if (subjectsButton) {
|
||||
activateCalendarTab('subjects');
|
||||
subjectCalendarSelect.value = subjectsButton.dataset.id;
|
||||
setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
|
||||
refreshSelectVisuals(subjectCalendarSelect);
|
||||
await loadCalendarSubjects();
|
||||
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -679,7 +740,22 @@ export async function initAcademicCalendar() {
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
||||
const deletedId = deleteButton.dataset.id;
|
||||
await api.delete('/api/admin/academic-calendars/' + deletedId);
|
||||
calendarCache.delete(deletedId);
|
||||
calendarComboboxes.forEach((combobox, select) => {
|
||||
if (select.value === deletedId) {
|
||||
setCalendarSelection(select, '');
|
||||
if (select === editorCalendarSelect) {
|
||||
loadedGridCalendarId = '';
|
||||
calendarGrid.replaceChildren();
|
||||
calendarTotals.replaceChildren();
|
||||
clearGridDirty();
|
||||
} else {
|
||||
loadCalendarSubjects();
|
||||
}
|
||||
}
|
||||
});
|
||||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||||
await loadCalendars();
|
||||
} catch (error) {
|
||||
@@ -713,15 +789,18 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendarSubjects() {
|
||||
const request = ++subjectLoadRequest;
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
subjectCalendarSelect.value = loadedSubjectCalendarId;
|
||||
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
|
||||
refreshSelectVisuals(subjectCalendarSelect);
|
||||
return;
|
||||
}
|
||||
if (request !== subjectLoadRequest) return;
|
||||
populateSubjectSemesterSelect();
|
||||
calendarSubjectRows = [];
|
||||
loadedSubjectCalendarId = '';
|
||||
if (!calendar) {
|
||||
loadedSubjectCalendarId = '';
|
||||
clearSubjectDirty();
|
||||
@@ -731,11 +810,13 @@ export async function initAcademicCalendar() {
|
||||
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||
try {
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
|
||||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||
loadedSubjectCalendarId = String(calendar.id);
|
||||
clearSubjectDirty();
|
||||
renderCalendarSubjects();
|
||||
} catch (error) {
|
||||
if (request !== subjectLoadRequest) return;
|
||||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
||||
}
|
||||
@@ -744,6 +825,10 @@ export async function initAcademicCalendar() {
|
||||
function addCalendarSubject() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
const semesterNumber = Number(subjectSemesterSelect.value);
|
||||
const subjectId = Number(subjectSelect.value);
|
||||
if (!calendar || !semesterNumber || !subjectId) {
|
||||
@@ -781,6 +866,10 @@ export async function initAcademicCalendar() {
|
||||
async function saveCalendarSubjects() {
|
||||
hideAlert('calendar-subject-alert');
|
||||
const calendar = selectedSubjectCalendar();
|
||||
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
if (!calendar) {
|
||||
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
@@ -855,10 +944,11 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
async function loadCalendarGrid() {
|
||||
const request = ++gridLoadRequest;
|
||||
hideAlert('calendar-editor-alert');
|
||||
const calendar = selectedEditorCalendar();
|
||||
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||
editorCalendarSelect.value = loadedGridCalendarId;
|
||||
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
|
||||
refreshSelectVisuals(editorCalendarSelect);
|
||||
return;
|
||||
}
|
||||
@@ -869,6 +959,7 @@ export async function initAcademicCalendar() {
|
||||
try {
|
||||
await loadTimeSlotDateAssignments();
|
||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
||||
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
|
||||
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
||||
renderCalendarGrid(gridRows);
|
||||
loadedGridCalendarId = String(calendar.id);
|
||||
@@ -876,6 +967,7 @@ export async function initAcademicCalendar() {
|
||||
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
|
||||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||||
} catch (error) {
|
||||
if (request !== gridLoadRequest) return;
|
||||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||||
}
|
||||
}
|
||||
@@ -887,6 +979,10 @@ export async function initAcademicCalendar() {
|
||||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||||
return;
|
||||
}
|
||||
if (String(calendar.id) !== loadedGridCalendarId) {
|
||||
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
||||
calendarId: calendar.id,
|
||||
courseNumber: Number(day.dataset.course),
|
||||
@@ -1535,31 +1631,12 @@ export async function initAcademicCalendar() {
|
||||
syncSelects(fillActivitySelect);
|
||||
}
|
||||
|
||||
function populateEditorCalendarSelect() {
|
||||
const current = editorCalendarSelect.value;
|
||||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
||||
calendars.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('');
|
||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||
editorCalendarSelect.value = current;
|
||||
}
|
||||
syncSelects(editorCalendarSelect);
|
||||
}
|
||||
|
||||
function populateSubjectCalendarSelect() {
|
||||
if (!subjectCalendarSelect) return;
|
||||
const current = subjectCalendarSelect.value;
|
||||
subjectCalendarSelect.innerHTML = calendars.length
|
||||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
||||
).join('')
|
||||
: '<option value="">Нет графиков</option>';
|
||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
||||
subjectCalendarSelect.value = current;
|
||||
}
|
||||
populateSubjectSemesterSelect();
|
||||
syncSelects(subjectCalendarSelect);
|
||||
function setCalendarSelection(select, id) {
|
||||
const calendar = calendarCache.get(String(id));
|
||||
select.innerHTML = calendar
|
||||
? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
|
||||
: '<option value="">Выберите график</option>';
|
||||
calendarComboboxes.get(select)?.syncFromSelect();
|
||||
}
|
||||
|
||||
function populateSubjectSemesterSelect() {
|
||||
@@ -1603,11 +1680,11 @@ export async function initAcademicCalendar() {
|
||||
}
|
||||
|
||||
function selectedEditorCalendar() {
|
||||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
||||
return calendarCache.get(String(editorCalendarSelect.value));
|
||||
}
|
||||
|
||||
function selectedSubjectCalendar() {
|
||||
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
|
||||
return calendarCache.get(String(subjectCalendarSelect?.value));
|
||||
}
|
||||
|
||||
function calendarSemesterCount(calendar) {
|
||||
@@ -1795,6 +1872,7 @@ export async function initAcademicCalendar() {
|
||||
|
||||
function refreshSelectVisuals(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
calendarComboboxes.get(select)?.syncFromSelect();
|
||||
if (select.customSelectInstance) {
|
||||
select.customSelectInstance.rebuildMenu();
|
||||
}
|
||||
|
||||
@@ -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 = '') {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { api } from '../api.js';
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { refreshCustomDropdowns } from '../dropdown.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
@@ -188,6 +190,14 @@ export async function initSchedule() {
|
||||
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
||||
|
||||
let rules = [];
|
||||
let visualRules = [];
|
||||
let rulesPage = 0;
|
||||
let rulesSize = 25;
|
||||
let rulesRequest = 0;
|
||||
let visualRequest = 0;
|
||||
let rulesVersionId = '';
|
||||
const rulesSearch = document.getElementById('schedule-rules-search');
|
||||
const rulesPagination = document.getElementById('schedule-rules-pagination');
|
||||
let subjects = [];
|
||||
let groups = [];
|
||||
let lessonTypes = [];
|
||||
@@ -237,6 +247,10 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
rulesSearch?.addEventListener('input', debounce(() => {
|
||||
rulesPage = 0;
|
||||
loadRules();
|
||||
}, 300));
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
||||
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
||||
@@ -522,7 +536,7 @@ export async function initSchedule() {
|
||||
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
||||
lastVersionYearValue = scheduleVersionYearSelect.value;
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
||||
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||
refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
@@ -634,6 +648,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
const request = ++rulesRequest;
|
||||
++visualRequest;
|
||||
visualRules = [];
|
||||
rules = [];
|
||||
rulesPagination?.replaceChildren();
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) {
|
||||
@@ -642,26 +661,43 @@ export async function initSchedule() {
|
||||
setVisualState('Нет выбранного расписания');
|
||||
return;
|
||||
}
|
||||
if (rulesVersionId !== String(version.id)) {
|
||||
rulesPage = 0;
|
||||
rulesVersionId = String(version.id);
|
||||
}
|
||||
try {
|
||||
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||
const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' });
|
||||
const page = await api.get(`/api/admin/schedule-rules/page?${params}`);
|
||||
if (request !== rulesRequest) return;
|
||||
if (rulesPage > 0 && rulesPage >= page.totalPages) {
|
||||
rulesPage = Math.max(0, page.totalPages - 1);
|
||||
return loadRules();
|
||||
}
|
||||
rules = page.items;
|
||||
renderPagination(rulesPagination, page, {
|
||||
label: 'правил',
|
||||
onPage: value => { rulesPage = value; loadRules(); },
|
||||
onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); }
|
||||
});
|
||||
if (!visualPeriodTouched) {
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
renderRules();
|
||||
renderVisualSchedule();
|
||||
openRequestedRule();
|
||||
if (visualDrawer?.classList.contains('open')) await loadVisualRules();
|
||||
await openRequestedRule();
|
||||
} catch (error) {
|
||||
if (request !== rulesRequest) return;
|
||||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
setVisualState('Ошибка загрузки правил расписания');
|
||||
}
|
||||
}
|
||||
|
||||
function openRequestedRule() {
|
||||
async function openRequestedRule() {
|
||||
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
|
||||
if (!requestedRuleId) return;
|
||||
localStorage.removeItem('magistr.schedule.openRuleId');
|
||||
const rule = rules.find(item => String(item.id) === requestedRuleId);
|
||||
if (!rule) {
|
||||
const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
|
||||
if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
|
||||
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -670,7 +706,7 @@ export async function initSchedule() {
|
||||
|
||||
function renderRules() {
|
||||
if (!rules.length) {
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила не найдены</td></tr>';
|
||||
renderVisualSchedule();
|
||||
return;
|
||||
}
|
||||
@@ -709,6 +745,7 @@ export async function initSchedule() {
|
||||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||||
if (visualBackdrop) visualBackdrop.hidden = false;
|
||||
loadVisualRules();
|
||||
}
|
||||
|
||||
function closeVisualDrawer() {
|
||||
@@ -722,34 +759,49 @@ export async function initSchedule() {
|
||||
if (visualBackdrop) visualBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
async function loadVisualRules() {
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) return;
|
||||
const request = ++visualRequest;
|
||||
setVisualState('Загрузка визуального расписания...');
|
||||
try {
|
||||
const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||
if (request !== visualRequest) return;
|
||||
visualRules = loaded;
|
||||
renderVisualSchedule();
|
||||
} catch (error) {
|
||||
if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания');
|
||||
}
|
||||
}
|
||||
|
||||
function renderVisualSchedule() {
|
||||
if (!visualStage || !visualSummary) return;
|
||||
if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
|
||||
if (!selectedVisualSemesterId) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||
return;
|
||||
}
|
||||
if (!rules.length) {
|
||||
if (!visualRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = 'Нет сохранённых правил';
|
||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||
return;
|
||||
}
|
||||
|
||||
const visualRules = visualRulesForSelectedPeriod();
|
||||
const periodRules = visualRulesForSelectedPeriod();
|
||||
const periodLabel = visualPeriodLabel();
|
||||
if (!visualRules.length) {
|
||||
if (!periodRules.length) {
|
||||
syncVisualGroupFilter([]);
|
||||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||
return;
|
||||
}
|
||||
|
||||
const allGroups = collectVisualGroups(visualRules);
|
||||
const allGroups = collectVisualGroups(periodRules);
|
||||
syncVisualGroupFilter(allGroups);
|
||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules);
|
||||
if (!allGroups.length || !slots.length) {
|
||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||
setVisualState('У правил должны быть группы и слоты занятий');
|
||||
@@ -761,7 +813,7 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
|
||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||
if (!rows.length) {
|
||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||
@@ -769,7 +821,7 @@ export async function initSchedule() {
|
||||
return;
|
||||
}
|
||||
|
||||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||
visualStage.innerHTML = `
|
||||
<div class="schedule-visual-table-wrap">
|
||||
<table class="schedule-visual-table">
|
||||
@@ -1029,7 +1081,7 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function openVisualContextMenu(button) {
|
||||
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId));
|
||||
if (!rule) {
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
return;
|
||||
@@ -1074,7 +1126,7 @@ export async function initSchedule() {
|
||||
if (!actionButton || !visualContextTarget) return;
|
||||
const action = actionButton.dataset.action;
|
||||
const target = { ...visualContextTarget };
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||
if (!rule) {
|
||||
closeVisualContextMenu();
|
||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||
@@ -1117,7 +1169,7 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function openMoveSlotModal(target) {
|
||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||
const slotIndex = findRuleSlotIndex(rule, target);
|
||||
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
||||
if (!rule || !slot) {
|
||||
@@ -1160,7 +1212,7 @@ export async function initSchedule() {
|
||||
event.preventDefault();
|
||||
if (!pendingSlotMove) return;
|
||||
hideAlert('schedule-move-slot-alert');
|
||||
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||
const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
||||
if (!rule || !slot) {
|
||||
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||||
@@ -1330,7 +1382,7 @@ export async function initSchedule() {
|
||||
|
||||
function visualRulesForSelectedPeriod() {
|
||||
if (!selectedVisualSemesterId) return [];
|
||||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||
}
|
||||
|
||||
function firstSemesterWithRules() {
|
||||
@@ -1392,7 +1444,7 @@ export async function initSchedule() {
|
||||
const deleteButton = event.target.closest('.btn-delete-rule');
|
||||
|
||||
if (editButton) {
|
||||
const rule = rules.find(item => item.id == editButton.dataset.id)
|
||||
const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id)
|
||||
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
||||
fillRuleForm(rule);
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { renderPagination } from '../pagination.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
@@ -52,6 +54,29 @@ export async function initUniversityStructure() {
|
||||
let specialties = [];
|
||||
let currentProfiles = [];
|
||||
let allProfiles = [];
|
||||
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
|
||||
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
|
||||
Object.keys(listStates).forEach(key => {
|
||||
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
|
||||
listStates[key].page = 0;
|
||||
listRenderers[key]();
|
||||
}, 250));
|
||||
});
|
||||
|
||||
function listPage(key, items, fields) {
|
||||
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
|
||||
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
|
||||
const state = listStates[key];
|
||||
const totalPages = Math.ceil(filtered.length / state.size);
|
||||
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
|
||||
renderPagination(document.getElementById(`${key}-pagination`), {
|
||||
...state, totalItems: filtered.length, totalPages
|
||||
}, {
|
||||
onPage: value => { state.page = value; listRenderers[key](); },
|
||||
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
|
||||
});
|
||||
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
|
||||
}
|
||||
|
||||
// --- Логика переключения табов ---
|
||||
const structureTabs = [
|
||||
@@ -105,11 +130,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderDepartments() {
|
||||
if (!departments || !departments.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
||||
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
|
||||
if (!visible.length) {
|
||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
deptTbody.innerHTML = departments.map(d => `
|
||||
deptTbody.innerHTML = visible.map(d => `
|
||||
<tr>
|
||||
<td>${d.id}</td>
|
||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||
@@ -123,11 +149,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderSpecialties() {
|
||||
if (!specialties || !specialties.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
||||
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
|
||||
if (!visible.length) {
|
||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
specTbody.innerHTML = specialties.map(s => `
|
||||
specTbody.innerHTML = visible.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||
@@ -428,11 +455,12 @@ export async function initUniversityStructure() {
|
||||
}
|
||||
|
||||
function renderAllProfiles() {
|
||||
if (!allProfiles || !allProfiles.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
|
||||
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
|
||||
if (!visible.length) {
|
||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
|
||||
return;
|
||||
}
|
||||
tabProfileTbody.innerHTML = allProfiles.map(p => `
|
||||
tabProfileTbody.innerHTML = visible.map(p => `
|
||||
<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||
|
||||
@@ -76,6 +76,16 @@
|
||||
<h2>Календарные графики</h2>
|
||||
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-search">Поиск графиков</label>
|
||||
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="academic-calendars-year-filter">Учебный год</label>
|
||||
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -95,6 +105,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="academic-calendars-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card academic-year-builder-card">
|
||||
@@ -181,7 +192,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-editor-calendar">График</label>
|
||||
<select id="calendar-editor-calendar">
|
||||
<select id="calendar-editor-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -226,7 +237,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calendar-subject-calendar">График</label>
|
||||
<select id="calendar-subject-calendar">
|
||||
<select id="calendar-subject-calendar" data-native-select="true">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
<h2>Правила динамического расписания</h2>
|
||||
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule-rules-search">Поиск правил</label>
|
||||
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="schedule-table">
|
||||
<colgroup>
|
||||
@@ -250,6 +254,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="schedule-rules-pagination"></div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Кафедры</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="departments-search">Поиск по названию или коду кафедры</label>
|
||||
<input type="search" id="departments-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="departments-table">
|
||||
<thead>
|
||||
@@ -47,6 +51,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="departments-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +79,10 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Специальности</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="specialties-search">Поиск по названию или коду специальности</label>
|
||||
<input type="search" id="specialties-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="specialties-table">
|
||||
<thead>
|
||||
@@ -91,6 +100,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="specialties-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +138,10 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Профили обучения</h2>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
|
||||
<input type="search" id="profiles-search" placeholder="Введите название или код">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="profiles-table">
|
||||
<thead>
|
||||
@@ -146,6 +160,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="profiles-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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, 'малый');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user