много всего и тестовые данные
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -62,8 +62,14 @@ docker compose logs -f backend
|
|||||||
## Критические правила для агентов
|
## Критические правила для агентов
|
||||||
|
|
||||||
### Flyway миграции
|
### Flyway миграции
|
||||||
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
|
- До отдельного решения владельца проект работает в режиме пересобираемого baseline:
|
||||||
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
|
**все постоянные изменения схемы БД, функций, триггеров, индексов и ограничений вносить
|
||||||
|
только в `V1__init.sql`**.
|
||||||
|
- `V2__test_data.sql` зарезервирован исключительно для тестовых и демонстрационных данных.
|
||||||
|
Постоянный DDL в V2 запрещён; допустимы только временные staging-таблицы, удаляемые в конце миграции.
|
||||||
|
- Не создавать `V3` и последующие миграции, пока владелец явно не отменит этот временный режим.
|
||||||
|
- Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса всех затронутых
|
||||||
|
tenant-БД. Не выполнять такой сброс без прямой команды владельца.
|
||||||
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
|
||||||
|
|
||||||
### Языковые требования
|
### Языковые требования
|
||||||
|
|||||||
@@ -63,18 +63,22 @@
|
|||||||
|
|
||||||
**Файлы:**
|
**Файлы:**
|
||||||
|
|
||||||
- `backend/src/main/resources/db/migration/V1__init.sql:21-24,38-42,85-92,221-265`
|
- `backend/src/main/resources/db/migration/V2__test_data.sql`
|
||||||
- `docs/README.md:45-54`
|
- `docs/README.md`
|
||||||
|
|
||||||
**Проблема:** V1 создаёт администратора с публично документированным коротким паролем, а также несколько тестовых пользователей с одинаковым известным паролем. Эта же миграция запускается для tenant-БД, добавленных в production, и одновременно добавляет демонстрационные кафедры, группы и другие данные.
|
**Проблема:** схема и данные разделены: V1 теперь не содержит доменных записей, однако
|
||||||
|
`V2__test_data.sql` всё равно автоматически применяется общим Flyway location ко всем tenant-БД.
|
||||||
|
V2 создаёт администратора с публично документированным коротким паролем, несколько известных
|
||||||
|
demo-пользователей и большой демонстрационный набор. Пароли 150 массовых преподавателей случайны
|
||||||
|
и непубличны, но это не устраняет риск доступного администратора и загрязнения production-БД.
|
||||||
|
|
||||||
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
**Риск:** немедленный административный доступ к свежему tenant, если пароль не сменили вручную; загрязнение production-БД тестовыми сущностями.
|
||||||
|
|
||||||
**Как исправить:**
|
**Как исправить:**
|
||||||
|
|
||||||
- Не менять V1. Добавить новую миграцию `V3__disable_demo_accounts.sql`, которая архивирует/удаляет демонстрационные аккаунты и данные там, где они не были явно сохранены.
|
- Исключать `V2__test_data.sql` из production Flyway locations либо включать её только явным
|
||||||
|
dev/test-профилем.
|
||||||
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
- Создавать первого администратора отдельной bootstrap-процедурой с одноразовым случайным секретом.
|
||||||
- Разнести demo/dev seed и обязательную production-схему.
|
|
||||||
- Ротировать пароли уже созданных tenant-БД.
|
- Ротировать пароли уже созданных tenant-БД.
|
||||||
|
|
||||||
## Высокий приоритет
|
## Высокий приоритет
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ public class AcademicCalendarController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<AcademicCalendarDto> getCalendarsPage(
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(required = false) Long academicYearId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return PageResponse.from(calendarRepository.searchOptions(
|
||||||
|
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
|
||||||
|
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||||
|
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
|
||||||
|
.map(this::toCalendarDto));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/options")
|
@GetMapping("/options")
|
||||||
public List<AcademicCalendarDto> searchCalendarOptions(
|
public List<AcademicCalendarDto> searchCalendarOptions(
|
||||||
@RequestParam(defaultValue = "") String query,
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.magistr.app.controller;
|
|||||||
|
|
||||||
import com.magistr.app.config.auth.RequireRoles;
|
import com.magistr.app.config.auth.RequireRoles;
|
||||||
import com.magistr.app.dto.ScheduleRuleDto;
|
import com.magistr.app.dto.ScheduleRuleDto;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.service.ScheduleRuleConflictException;
|
import com.magistr.app.service.ScheduleRuleConflictException;
|
||||||
import com.magistr.app.service.ScheduleRuleService;
|
import com.magistr.app.service.ScheduleRuleService;
|
||||||
@@ -39,6 +40,15 @@ public class ScheduleRuleAdminController {
|
|||||||
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
return scheduleRuleService.getAll(semesterId, groupId, versionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
public PageResponse<ScheduleRuleDto> getPage(
|
||||||
|
@RequestParam Long versionId,
|
||||||
|
@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size) {
|
||||||
|
return scheduleRuleService.getPage(versionId, query, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
public ResponseEntity<ScheduleRuleDto> getById(@PathVariable("id") Long id) {
|
||||||
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
return ResponseEntity.ok(scheduleRuleService.getById(id));
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.magistr.app.repository;
|
|||||||
import com.magistr.app.model.ScheduleRule;
|
import com.magistr.app.model.ScheduleRule;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -13,6 +15,23 @@ import java.util.Collection;
|
|||||||
|
|
||||||
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
|
||||||
|
|
||||||
|
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
|
||||||
|
@Query("""
|
||||||
|
select r.id from ScheduleRule r
|
||||||
|
join r.scheduleVersion version
|
||||||
|
join r.subject subject
|
||||||
|
where r.status <> 'ARCHIVED'
|
||||||
|
and version.id = :versionId
|
||||||
|
and (:query = '' or str(r.id) = :query
|
||||||
|
or lower(subject.name) like concat('%', :query, '%')
|
||||||
|
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
|
||||||
|
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
|
||||||
|
order by r.id desc
|
||||||
|
""")
|
||||||
|
Page<Long> searchPageIds(
|
||||||
|
@Param("versionId") Long versionId, @Param("query") String query,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
select distinct r
|
select distinct r
|
||||||
from ScheduleRule r
|
from ScheduleRule r
|
||||||
@@ -210,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
|
|||||||
where r.id = :id
|
where r.id = :id
|
||||||
""")
|
""")
|
||||||
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select distinct r
|
||||||
|
from ScheduleRule r
|
||||||
|
left join fetch r.subject
|
||||||
|
left join fetch r.semester sem
|
||||||
|
left join fetch sem.academicYear
|
||||||
|
left join fetch r.scheduleVersion
|
||||||
|
left join fetch r.groups groups
|
||||||
|
left join fetch r.slots slots
|
||||||
|
left join fetch slots.timeSlot
|
||||||
|
left join fetch slots.subgroups slotSubgroups
|
||||||
|
left join fetch slotSubgroups.studentGroup
|
||||||
|
left join fetch slots.teacher
|
||||||
|
left join fetch slots.classroom
|
||||||
|
left join fetch slots.lessonType
|
||||||
|
where r.id in :ids
|
||||||
|
""")
|
||||||
|
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.magistr.app.service;
|
package com.magistr.app.service;
|
||||||
|
|
||||||
import com.magistr.app.dto.ScheduleRuleDto;
|
import com.magistr.app.dto.ScheduleRuleDto;
|
||||||
|
import com.magistr.app.dto.PageResponse;
|
||||||
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
import com.magistr.app.dto.ScheduleRuleSlotDto;
|
||||||
import com.magistr.app.config.auth.AuthContext;
|
import com.magistr.app.config.auth.AuthContext;
|
||||||
import com.magistr.app.model.Classroom;
|
import com.magistr.app.model.Classroom;
|
||||||
@@ -31,6 +32,7 @@ import com.magistr.app.repository.SubgroupRepository;
|
|||||||
import com.magistr.app.repository.TimeSlotRepository;
|
import com.magistr.app.repository.TimeSlotRepository;
|
||||||
import com.magistr.app.repository.UserRepository;
|
import com.magistr.app.repository.UserRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ import java.util.IdentityHashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -117,6 +120,20 @@ public class ScheduleRuleService {
|
|||||||
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<ScheduleRuleDto> getPage(Long versionId, String query, int page, int size) {
|
||||||
|
var ids = scheduleRuleRepository.searchPageIds(versionId,
|
||||||
|
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
|
||||||
|
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100))));
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return new PageResponse<>(List.of(), ids.getNumber(), ids.getSize(), ids.getTotalElements(),
|
||||||
|
ids.getTotalPages(), ids.isFirst(), ids.isLast());
|
||||||
|
}
|
||||||
|
var details = scheduleRuleRepository.findPageWithDetails(ids.getContent()).stream()
|
||||||
|
.collect(java.util.stream.Collectors.toMap(ScheduleRule::getId, this::toDto));
|
||||||
|
return PageResponse.from(ids.map(details::get));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
|
||||||
return getAll(semesterId, groupId, null);
|
return getAll(semesterId, groupId, null);
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO departments (name, code) VALUES
|
|
||||||
('Кафедра ИБ', 1),
|
|
||||||
('Кафедра ВТ', 2),
|
|
||||||
('Кафедра КТ', 3);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS specialties (
|
CREATE TABLE IF NOT EXISTS specialties (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
@@ -36,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO specialties (name, specialty_code) VALUES
|
|
||||||
('Информационная безопасность', '10.03.01'),
|
|
||||||
('Информатика и вычислительная техника', '09.03.01'),
|
|
||||||
('Программная инженерия', '09.03.04')
|
|
||||||
ON CONFLICT (specialty_code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
CREATE TABLE IF NOT EXISTS specialty_profiles (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
|
||||||
@@ -57,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
|
|||||||
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO specialty_profiles (specialty_id, name)
|
|
||||||
SELECT id, 'Без профиля'
|
|
||||||
FROM specialties
|
|
||||||
ON CONFLICT (specialty_id, name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Пользователи и роли
|
-- Пользователи и роли
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -83,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
|
|
||||||
INSERT INTO users (username, password, role, full_name, job_title, department_id)
|
|
||||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
|
|
||||||
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
|
|
||||||
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
|
|
||||||
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
|
|
||||||
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
|
|
||||||
ON CONFLICT (username) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -189,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
|
|||||||
ON teacher_department_assignments(teacher_id, department_id)
|
ON teacher_department_assignments(teacher_id, department_id)
|
||||||
WHERE valid_to IS NULL;
|
WHERE valid_to IS NULL;
|
||||||
|
|
||||||
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
|
|
||||||
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
|
|
||||||
FROM users u
|
|
||||||
WHERE u.role = 'TEACHER'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM teacher_department_assignments existing
|
|
||||||
WHERE existing.teacher_id = u.id
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
department_id BIGINT NOT NULL REFERENCES departments(id),
|
department_id BIGINT NOT NULL REFERENCES departments(id),
|
||||||
@@ -243,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO education_forms (name) VALUES
|
|
||||||
('Бакалавриат'),
|
|
||||||
('Магистратура'),
|
|
||||||
('Специалитет')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Учебные группы
|
-- Учебные группы
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -272,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Тестовая базовая группа для работы
|
|
||||||
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
|
|
||||||
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
|
|
||||||
FROM (VALUES
|
|
||||||
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
|
|
||||||
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
|
|
||||||
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
|
|
||||||
JOIN education_forms ef ON ef.name = data.education_form_name
|
|
||||||
JOIN departments dept ON dept.name = data.department_name
|
|
||||||
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
|
|
||||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -373,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
EXECUTE FUNCTION validate_subgroup_student_capacity();
|
||||||
|
|
||||||
INSERT INTO subgroups (group_id, name, student_capacity)
|
|
||||||
SELECT
|
|
||||||
sg.id,
|
|
||||||
data.name,
|
|
||||||
CASE
|
|
||||||
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
|
|
||||||
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
|
|
||||||
END
|
|
||||||
FROM student_groups sg
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('Подгруппа 1', 1),
|
|
||||||
('Подгруппа 2', 2)
|
|
||||||
) AS data(name, part_number)
|
|
||||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
|
||||||
ON CONFLICT (group_id, name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Справочники
|
-- Справочники
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -414,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
|
|||||||
COMMENT ON INDEX uq_subjects_name_ci IS
|
COMMENT ON INDEX uq_subjects_name_ci IS
|
||||||
'Глобально уникальное название дисциплины без учёта регистра';
|
'Глобально уникальное название дисциплины без учёта регистра';
|
||||||
|
|
||||||
INSERT INTO subjects (name, department_id) VALUES
|
|
||||||
('Высшая математика', 1),
|
|
||||||
('Философия', 1),
|
|
||||||
('Информатика', 1),
|
|
||||||
('Базы данных', 1),
|
|
||||||
('Английский язык', 1)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS subject_comments (
|
CREATE TABLE IF NOT EXISTS subject_comments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
@@ -441,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
|
|||||||
duration_minutes INT DEFAULT 90
|
duration_minutes INT DEFAULT 90
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO lesson_types (name, color_code) VALUES
|
|
||||||
('Лекция', '#FF6B6B'),
|
|
||||||
('Практика', '#4ECDC4'),
|
|
||||||
('Лабораторная работа', '#45B7D1')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Оборудование
|
-- Оборудование
|
||||||
CREATE TABLE IF NOT EXISTS equipments (
|
CREATE TABLE IF NOT EXISTS equipments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -461,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO equipments (name) VALUES
|
|
||||||
('Проектор'),
|
|
||||||
('ПК'),
|
|
||||||
('Лаборатория'),
|
|
||||||
('Интерактивная доска'),
|
|
||||||
('Документ-камера'),
|
|
||||||
('Аудиосистема')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Аудитории
|
-- Аудитории
|
||||||
CREATE TABLE IF NOT EXISTS classrooms (
|
CREATE TABLE IF NOT EXISTS classrooms (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -488,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
|
|||||||
archive_reason TEXT
|
archive_reason TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO classrooms (name, capacity, building, floor) VALUES
|
|
||||||
('101 Ленинская', 120, 'Главный корпус', 1),
|
|
||||||
('202 IT Lab', 20, 'Корпус IT', 2),
|
|
||||||
('303 Обычная', 30, 'Главный корпус', 3)
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Привязка оборудования к аудиториям (Many-to-Many)
|
-- Привязка оборудования к аудиториям (Many-to-Many)
|
||||||
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
CREATE TABLE IF NOT EXISTS classroom_equipments (
|
||||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||||
@@ -503,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
|
|||||||
PRIMARY KEY (classroom_id, equipment_id)
|
PRIMARY KEY (classroom_id, equipment_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
|
|
||||||
SELECT c.id, e.id,
|
|
||||||
CASE
|
|
||||||
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
|
|
||||||
WHEN e.name = 'ПК' THEN 1
|
|
||||||
ELSE 1
|
|
||||||
END
|
|
||||||
FROM classrooms c, equipments e
|
|
||||||
WHERE
|
|
||||||
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
|
|
||||||
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
|
|
||||||
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
|
|
||||||
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
|
||||||
@@ -575,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
|
|||||||
ON time_slot_scopes(day_of_week)
|
ON time_slot_scopes(day_of_week)
|
||||||
WHERE apply_mode = 'WEEKDAY';
|
WHERE apply_mode = 'WEEKDAY';
|
||||||
|
|
||||||
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
|
|
||||||
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
|
|
||||||
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS time_slots (
|
CREATE TABLE IF NOT EXISTS time_slots (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
|
||||||
@@ -597,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
|
||||||
|
|
||||||
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
|
|
||||||
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
|
|
||||||
FROM (VALUES
|
|
||||||
(1, '08:00', '09:30', 90),
|
|
||||||
(2, '09:40', '11:10', 90),
|
|
||||||
(3, '11:40', '13:10', 90),
|
|
||||||
(4, '13:20', '14:50', 90),
|
|
||||||
(5, '15:00', '16:30', 90),
|
|
||||||
(6, '16:50', '18:20', 90),
|
|
||||||
(7, '18:30', '20:00', 90)
|
|
||||||
) AS data(order_number, start_time, end_time, duration_minutes)
|
|
||||||
CROSS JOIN time_slot_scopes scope
|
|
||||||
WHERE scope.code IN ('default', 'saturday')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
assignment_date DATE NOT NULL UNIQUE,
|
assignment_date DATE NOT NULL UNIQUE,
|
||||||
@@ -629,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
|
|||||||
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO academic_years (title, start_date, end_date) VALUES
|
|
||||||
('2024-2025', '2024-09-01', '2025-06-30'),
|
|
||||||
('2025-2026', '2025-09-01', '2026-06-30')
|
|
||||||
ON CONFLICT (title) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS semesters (
|
CREATE TABLE IF NOT EXISTS semesters (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
|
||||||
@@ -645,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
|
|||||||
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
|
||||||
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
|
|
||||||
FROM academic_years
|
|
||||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
|
|
||||||
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
|
|
||||||
FROM academic_years
|
|
||||||
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
||||||
@@ -667,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
|
|||||||
description TEXT
|
description TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
|
|
||||||
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
|
|
||||||
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
|
|
||||||
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
|
|
||||||
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
|
|
||||||
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
|
|
||||||
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
|
|
||||||
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
|
|
||||||
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
|
|
||||||
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
|
|
||||||
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
|
|
||||||
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
|
|
||||||
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
|
|
||||||
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendars (
|
CREATE TABLE IF NOT EXISTS academic_calendars (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
@@ -700,21 +536,6 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
|
||||||
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
|
||||||
|
|
||||||
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
|
|
||||||
SELECT
|
|
||||||
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
|
|
||||||
ay.id,
|
|
||||||
specialty.id,
|
|
||||||
profile.id,
|
|
||||||
study_form.id,
|
|
||||||
4
|
|
||||||
FROM academic_years ay
|
|
||||||
CROSS JOIN education_forms study_form
|
|
||||||
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
|
|
||||||
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
|
|
||||||
WHERE ay.title = '2025-2026'
|
|
||||||
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||||
@@ -754,24 +575,6 @@ AS $$
|
|||||||
) + 1
|
) + 1
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
INSERT INTO academic_calendar_periods (
|
|
||||||
calendar_id,
|
|
||||||
course_number,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
activity_type_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
calendar.id,
|
|
||||||
course.course_number,
|
|
||||||
ay.start_date,
|
|
||||||
ay.end_date,
|
|
||||||
activity.id
|
|
||||||
FROM academic_calendars calendar
|
|
||||||
JOIN academic_years ay ON ay.id = calendar.academic_year_id
|
|
||||||
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
|
|
||||||
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
|
||||||
@@ -800,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
|
||||||
ON student_group_calendar_assignments(group_id, academic_year_id);
|
ON student_group_calendar_assignments(group_id, academic_year_id);
|
||||||
|
|
||||||
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
|
|
||||||
SELECT
|
|
||||||
sg.id,
|
|
||||||
ay.id,
|
|
||||||
calendar.id
|
|
||||||
FROM student_groups sg
|
|
||||||
JOIN academic_years ay ON ay.title = '2025-2026'
|
|
||||||
JOIN academic_calendars calendar
|
|
||||||
ON calendar.academic_year_id = ay.id
|
|
||||||
AND calendar.specialty_id = sg.specialty_id
|
|
||||||
AND calendar.specialty_profile_id = sg.specialty_profile_id
|
|
||||||
AND calendar.study_form_id = sg.education_form_id
|
|
||||||
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
|
|
||||||
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
subject_id BIGINT NOT NULL REFERENCES subjects(id),
|
||||||
@@ -991,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
|
||||||
|
|
||||||
CREATE TEMP TABLE tmp_default_rules AS
|
|
||||||
SELECT
|
|
||||||
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
|
|
||||||
rule_key,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
data.rule_key,
|
|
||||||
subj.id AS subject_id,
|
|
||||||
sem.id AS semester_id,
|
|
||||||
data.lecture_academic_hours,
|
|
||||||
data.laboratory_academic_hours,
|
|
||||||
data.practice_academic_hours,
|
|
||||||
data.lecture_start_week,
|
|
||||||
data.laboratory_start_week,
|
|
||||||
data.practice_start_week
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
|
|
||||||
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
|
|
||||||
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
|
|
||||||
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
|
|
||||||
) AS data(
|
|
||||||
rule_key,
|
|
||||||
subject_name,
|
|
||||||
year_title,
|
|
||||||
semester_type,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
)
|
|
||||||
JOIN subjects subj ON subj.name = data.subject_name
|
|
||||||
JOIN academic_years ay ON ay.title = data.year_title
|
|
||||||
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
|
|
||||||
) prepared_rules;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rules (
|
|
||||||
id,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
schedule_rule_id,
|
|
||||||
subject_id,
|
|
||||||
semester_id,
|
|
||||||
lecture_academic_hours,
|
|
||||||
laboratory_academic_hours,
|
|
||||||
practice_academic_hours,
|
|
||||||
lecture_start_week,
|
|
||||||
laboratory_start_week,
|
|
||||||
practice_start_week
|
|
||||||
FROM tmp_default_rules;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
|
|
||||||
SELECT
|
|
||||||
rules.schedule_rule_id,
|
|
||||||
sg.id
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 'ИВТ-21-1'),
|
|
||||||
('stream_math', 'ИБ-41м'),
|
|
||||||
('ivt_informatics_lab', 'ИВТ-21-1'),
|
|
||||||
('ivt_databases_practice', 'ИВТ-21-1'),
|
|
||||||
('ib_philosophy', 'ИБ-41м')
|
|
||||||
) AS data(rule_key, group_name)
|
|
||||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
|
||||||
JOIN student_groups sg ON sg.name = data.group_name
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO schedule_rule_slots (
|
|
||||||
schedule_rule_id,
|
|
||||||
day_of_week,
|
|
||||||
parity,
|
|
||||||
time_slot_id,
|
|
||||||
teacher_id,
|
|
||||||
classroom_id,
|
|
||||||
lesson_type_id,
|
|
||||||
lesson_format
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
rules.schedule_rule_id,
|
|
||||||
data.day_of_week,
|
|
||||||
data.parity,
|
|
||||||
ts.id,
|
|
||||||
teacher.id,
|
|
||||||
classroom.id,
|
|
||||||
lt.id,
|
|
||||||
data.lesson_format
|
|
||||||
FROM (VALUES
|
|
||||||
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
|
|
||||||
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
|
|
||||||
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
|
|
||||||
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
|
|
||||||
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
|
|
||||||
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
|
|
||||||
JOIN time_slot_scopes scope ON scope.code = 'default'
|
|
||||||
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
|
|
||||||
JOIN users teacher ON teacher.username = data.teacher_username
|
|
||||||
JOIN classrooms classroom ON classroom.name = data.classroom_name
|
|
||||||
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
|
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- Функция обновления timestamp
|
-- Функция обновления timestamp
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
@@ -1371,7 +1044,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
cancel_violations,
|
cancel_violations,
|
||||||
move_violations,
|
move_violations,
|
||||||
replace_violations,
|
replace_violations,
|
||||||
@@ -1470,7 +1143,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
lecture_violations,
|
lecture_violations,
|
||||||
laboratory_violations,
|
laboratory_violations,
|
||||||
practice_violations
|
practice_violations
|
||||||
@@ -1511,7 +1184,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23505',
|
ERRCODE = '23505',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
duplicate_slot_groups
|
duplicate_slot_groups
|
||||||
);
|
);
|
||||||
END IF;
|
END IF;
|
||||||
@@ -1577,7 +1250,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
duration_violations,
|
duration_violations,
|
||||||
overlap_violations,
|
overlap_violations,
|
||||||
scope_violations
|
scope_violations
|
||||||
@@ -1733,7 +1406,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
academic_year_overlap_violations,
|
academic_year_overlap_violations,
|
||||||
semester_bounds_violations,
|
semester_bounds_violations,
|
||||||
semester_overlap_violations
|
semester_overlap_violations
|
||||||
@@ -1864,7 +1537,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION USING
|
RAISE EXCEPTION USING
|
||||||
ERRCODE = '23514',
|
ERRCODE = '23514',
|
||||||
MESSAGE = format(
|
MESSAGE = format(
|
||||||
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
|
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
|
||||||
assignment_violations,
|
assignment_violations,
|
||||||
calendar_period_violations,
|
calendar_period_violations,
|
||||||
calendar_subject_violations
|
calendar_subject_violations
|
||||||
@@ -2430,33 +2103,9 @@ CREATE UNIQUE INDEX uq_schedule_versions_published_semester
|
|||||||
CREATE INDEX idx_schedule_versions_semester_history
|
CREATE INDEX idx_schedule_versions_semester_history
|
||||||
ON schedule_versions(semester_id, version_number DESC);
|
ON schedule_versions(semester_id, version_number DESC);
|
||||||
|
|
||||||
INSERT INTO schedule_versions (
|
|
||||||
semester_id,
|
|
||||||
version_number,
|
|
||||||
name,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
published_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
semester.id,
|
|
||||||
1,
|
|
||||||
'Опубликованная версия 1',
|
|
||||||
'PUBLISHED',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM semesters semester;
|
|
||||||
|
|
||||||
ALTER TABLE schedule_rules
|
ALTER TABLE schedule_rules
|
||||||
ADD COLUMN schedule_version_id BIGINT;
|
ADD COLUMN schedule_version_id BIGINT;
|
||||||
|
|
||||||
UPDATE schedule_rules rule
|
|
||||||
SET schedule_version_id = version.id,
|
|
||||||
version_group_id = COALESCE(rule.version_group_id, rule.id)
|
|
||||||
FROM schedule_versions version
|
|
||||||
WHERE version.semester_id = rule.semester_id
|
|
||||||
AND version.status = 'PUBLISHED';
|
|
||||||
|
|
||||||
ALTER TABLE schedule_rules
|
ALTER TABLE schedule_rules
|
||||||
ALTER COLUMN schedule_version_id SET NOT NULL,
|
ALTER COLUMN schedule_version_id SET NOT NULL,
|
||||||
ADD CONSTRAINT fk_schedule_rules_version
|
ADD CONSTRAINT fk_schedule_rules_version
|
||||||
@@ -2487,14 +2136,6 @@ CREATE TABLE schedule_version_history (
|
|||||||
CREATE INDEX idx_schedule_version_history_version
|
CREATE INDEX idx_schedule_version_history_version
|
||||||
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
ON schedule_version_history(version_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
|
||||||
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
|
|
||||||
FROM schedule_versions;
|
|
||||||
|
|
||||||
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
|
|
||||||
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
|
|
||||||
FROM schedule_versions;
|
|
||||||
|
|
||||||
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
|
||||||
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
|
||||||
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
|
||||||
|
|||||||
@@ -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 {
|
class AcademicCalendarControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
|
||||||
|
void returnsBoundedCalendarPage() throws Exception {
|
||||||
|
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
|
||||||
|
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
|
||||||
|
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||||
|
.and(org.springframework.data.domain.Sort.by("title", "id")));
|
||||||
|
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
|
||||||
|
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
|
||||||
|
var controller = new AcademicCalendarController(repository,
|
||||||
|
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
|
||||||
|
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
|
||||||
|
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
|
||||||
|
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
|
||||||
|
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.get("/api/admin/academic-calendars/page")
|
||||||
|
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
|
||||||
|
.param("page", "-1").param("size", "1000"))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createCalendarReturnsCalendarCreatedByTransactionalService() {
|
void createCalendarReturnsCalendarCreatedByTransactionalService() {
|
||||||
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ class ScheduleRuleAdminControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страница правил принимает фильтры и возвращает метаданные пагинации")
|
||||||
|
void returnsRulesPage() throws Exception {
|
||||||
|
when(service.getPage(7L, "Математика", 2, 25)).thenReturn(
|
||||||
|
new com.magistr.app.dto.PageResponse<>(List.of(), 2, 25, 50, 2, false, true));
|
||||||
|
mockMvc.perform(get("/api/admin/schedule-rules/page")
|
||||||
|
.param("versionId", "7").param("query", "Математика").param("page", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.items").isArray())
|
||||||
|
.andExpect(jsonPath("$.page").value(2))
|
||||||
|
.andExpect(jsonPath("$.size").value(25))
|
||||||
|
.andExpect(jsonPath("$.totalItems").value(50));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
void returnsBadRequestWithRussianValidationMessage() throws Exception {
|
||||||
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
when(service.create(any(ScheduleRuleDto.class))).thenThrow(new IllegalArgumentException(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import java.sql.SQLException;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@DisplayName("Миграция начальных периодов календарного графика")
|
@DisplayName("Миграция тестовых данных и начальных периодов календарного графика")
|
||||||
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
@@ -33,6 +33,7 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
long emptyCalendarId;
|
long emptyCalendarId;
|
||||||
long configuredCalendarId;
|
long configuredCalendarId;
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
prepareCustomCalendarPrerequisites(connection);
|
||||||
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
|
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
|
||||||
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
|
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
|
||||||
insertConfiguredPeriod(connection, configuredCalendarId);
|
insertConfiguredPeriod(connection, configuredCalendarId);
|
||||||
@@ -52,6 +53,51 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("V1 создаёт только схему, а V2 загружает полный тестовый набор")
|
||||||
|
void migrationSeparatesSchemaAndTestData() throws SQLException {
|
||||||
|
Flyway baseline = flyway("1");
|
||||||
|
baseline.clean();
|
||||||
|
baseline.migrate();
|
||||||
|
|
||||||
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
assertThat(queryLong(connection, """
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM departments)
|
||||||
|
+ (SELECT COUNT(*) FROM users)
|
||||||
|
+ (SELECT COUNT(*) FROM student_groups)
|
||||||
|
+ (SELECT COUNT(*) FROM subjects)
|
||||||
|
+ (SELECT COUNT(*) FROM academic_years)
|
||||||
|
+ (SELECT COUNT(*) FROM schedule_rules)
|
||||||
|
"""))
|
||||||
|
.as("После V1 доменные таблицы должны быть пустыми")
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
Flyway allMigrations = flyway(null);
|
||||||
|
allMigrations.migrate();
|
||||||
|
|
||||||
|
assertThat(allMigrations.info().current()).isNotNull();
|
||||||
|
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM departments")).isEqualTo(41);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM specialties")).isEqualTo(42);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM users WHERE role = 'TEACHER'"))
|
||||||
|
.isEqualTo(151);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_groups")).isEqualTo(202);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subgroups")).isEqualTo(404);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM subjects")).isEqualTo(229);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM classrooms")).isEqualTo(80);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendars")).isEqualTo(132);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM academic_calendar_periods")).isEqualTo(576);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM student_group_calendar_assignments"))
|
||||||
|
.isEqualTo(202);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rules")).isEqualTo(610);
|
||||||
|
assertThat(queryLong(connection, "SELECT COUNT(*) FROM schedule_rule_slots")).isEqualTo(610);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Flyway flyway(String targetVersion) {
|
private Flyway flyway(String targetVersion) {
|
||||||
var configuration = Flyway.configure()
|
var configuration = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
@@ -75,10 +121,12 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
study_form_id,
|
study_form_id,
|
||||||
course_count
|
course_count
|
||||||
)
|
)
|
||||||
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
|
SELECT ?, academic_year.id, specialty.id, profile.id, study_form.id, ?
|
||||||
FROM academic_calendars
|
FROM academic_years academic_year
|
||||||
ORDER BY id
|
JOIN specialties specialty ON specialty.specialty_code = '99.99.99'
|
||||||
LIMIT 1
|
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id
|
||||||
|
JOIN education_forms study_form ON study_form.name = 'Тестовая форма'
|
||||||
|
WHERE academic_year.title = '2030-2031'
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""")) {
|
""")) {
|
||||||
statement.setString(1, title);
|
statement.setString(1, title);
|
||||||
@@ -90,6 +138,40 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void prepareCustomCalendarPrerequisites(Connection connection) throws SQLException {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO departments (name, code)
|
||||||
|
VALUES ('Тестовая кафедра для V2', 9999)
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO specialties (name, specialty_code)
|
||||||
|
VALUES ('Тестовое направление для V2', '99.99.99')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO specialty_profiles (specialty_id, name)
|
||||||
|
SELECT id, 'Тестовый профиль для V2'
|
||||||
|
FROM specialties
|
||||||
|
WHERE specialty_code = '99.99.99'
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO education_forms (name)
|
||||||
|
VALUES ('Тестовая форма')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO academic_years (title, start_date, end_date)
|
||||||
|
VALUES ('2030-2031', DATE '2030-09-01', DATE '2031-06-30')
|
||||||
|
""");
|
||||||
|
statement.executeUpdate("""
|
||||||
|
INSERT INTO academic_calendar_activity_types (
|
||||||
|
code, name, allow_schedule, color_code, display_order
|
||||||
|
) VALUES
|
||||||
|
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10),
|
||||||
|
('К', 'Каникулы', FALSE, '#38bdf8', 30)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
|
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
INSERT INTO academic_calendar_periods (
|
INSERT INTO academic_calendar_periods (
|
||||||
@@ -140,6 +222,14 @@ class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long queryLong(Connection connection, String sql) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql);
|
||||||
|
ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
assertThat(resultSet.next()).isTrue();
|
||||||
|
return resultSet.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record PeriodSummary(int count,
|
private record PeriodSummary(int count,
|
||||||
int minCourse,
|
int minCourse,
|
||||||
int maxCourse,
|
int maxCourse,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current()).isNotNull();
|
assertThat(flyway.info().current()).isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
long groupId = queryLong(
|
long groupId = queryLong(
|
||||||
@@ -81,6 +81,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
"23514",
|
"23514",
|
||||||
"сохранённые назначения календарных графиков станут несовместимыми"
|
"сохранённые назначения календарных графиков станут несовместимыми"
|
||||||
);
|
);
|
||||||
|
deleteOtherCalendarAssignments(connection, calendarId, assignmentId);
|
||||||
assertSqlState(
|
assertSqlState(
|
||||||
() -> updateInt(
|
() -> updateInt(
|
||||||
connection,
|
connection,
|
||||||
@@ -386,7 +387,7 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
@@ -541,6 +542,20 @@ class AcademicStructureInvariantIntegrationTest {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void deleteOtherCalendarAssignments(Connection connection,
|
||||||
|
long calendarId,
|
||||||
|
long preservedAssignmentId) throws SQLException {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
|
DELETE FROM student_group_calendar_assignments
|
||||||
|
WHERE calendar_id = ?
|
||||||
|
AND id <> ?
|
||||||
|
""")) {
|
||||||
|
statement.setLong(1, calendarId);
|
||||||
|
statement.setLong(2, preservedAssignmentId);
|
||||||
|
statement.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
private void updateLong(Connection connection, String sql, long value, long id) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
statement.setLong(1, value);
|
statement.setLong(1, value);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V1")
|
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||||
.isEqualTo("1");
|
.isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -149,7 +149,7 @@ class ScheduleOverrideMigrationIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
.as("После полной миграции должна существовать текущая версия")
|
.as("После полной миграции должна существовать текущая версия")
|
||||||
.isNotNull();
|
.isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion())
|
assertThat(flyway.info().current().getVersion().getVersion())
|
||||||
.as("Последней успешно применённой миграцией должна быть V1")
|
.as("Тестовые данные должны быть загружены миграцией V2")
|
||||||
.isEqualTo("1");
|
.isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
SeedIds seedIds = loadSeedIds(connection);
|
SeedIds seedIds = loadSeedIds(connection);
|
||||||
@@ -82,7 +82,7 @@ class ScheduleRuleHoursMigrationIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class SubjectOwnershipInvariantIntegrationTest {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class TeacherDepartmentInvariantIntegrationTest {
|
|||||||
return Flyway.configure()
|
return Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ class TeacherPreferencesMigrationIntegrationTest {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
.target(MigrationVersion.fromVersion("1"))
|
.target(MigrationVersion.fromVersion("2"))
|
||||||
.cleanDisabled(false)
|
.cleanDisabled(false)
|
||||||
.load();
|
.load();
|
||||||
flyway.clean();
|
flyway.clean();
|
||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
assertThat(queryLong(connection, """
|
assertThat(queryLong(connection, """
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
|||||||
flyway.migrate();
|
flyway.migrate();
|
||||||
|
|
||||||
assertThat(flyway.info().current()).isNotNull();
|
assertThat(flyway.info().current()).isNotNull();
|
||||||
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
|
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("2");
|
||||||
|
|
||||||
try (Connection connection = POSTGRES.createConnection("")) {
|
try (Connection connection = POSTGRES.createConnection("")) {
|
||||||
long manualScopeId = insertManualScope(connection, "migration_checks");
|
long manualScopeId = insertManualScope(connection, "migration_checks");
|
||||||
@@ -171,7 +171,7 @@ class TimeSlotInvariantMigrationIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Flyway baselineFlyway() {
|
private Flyway baselineFlyway() {
|
||||||
return flyway("1");
|
return flyway("2");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Flyway flyway(String targetVersion) {
|
private Flyway flyway(String targetVersion) {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import static org.mockito.Mockito.verify;
|
|||||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||||
properties = {
|
properties = {
|
||||||
"spring.jpa.open-in-view=false",
|
"spring.jpa.open-in-view=false",
|
||||||
|
"spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true",
|
||||||
"management.endpoint.health.validate-group-membership=false"
|
"management.endpoint.health.validate-group-membership=false"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,48 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
|
|||||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Страницы правил не пересекаются, поиск работает вне первой страницы")
|
||||||
|
void pagesRulesAndSearchesAcrossWholeVersion() {
|
||||||
|
Long versionId = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT schedule_version_id FROM schedule_rules
|
||||||
|
WHERE status <> 'ARCHIVED' GROUP BY schedule_version_id
|
||||||
|
ORDER BY COUNT(*) DESC LIMIT 1
|
||||||
|
""", Long.class);
|
||||||
|
var first = service.getPage(versionId, "", 0, 25);
|
||||||
|
var second = service.getPage(versionId, "", 1, 25);
|
||||||
|
assertThat(first.items()).hasSize(25);
|
||||||
|
assertThat(second.items()).hasSize(25);
|
||||||
|
assertThat(first.totalItems()).isGreaterThan(50);
|
||||||
|
assertThat(first.items()).extracting(ScheduleRuleDto::id)
|
||||||
|
.doesNotContainAnyElementsOf(second.items().stream().map(ScheduleRuleDto::id).toList());
|
||||||
|
var rule = second.items().get(0);
|
||||||
|
assertThat(rule.slots()).isNotEmpty();
|
||||||
|
assertThat(service.getPage(versionId, rule.id().toString(), 0, 25).items())
|
||||||
|
.extracting(ScheduleRuleDto::id).contains(rule.id());
|
||||||
|
assertThat(service.getPage(versionId, rule.subjectName().toUpperCase(java.util.Locale.ROOT), 0, 25).items())
|
||||||
|
.isNotEmpty().allMatch(item -> item.subjectName().equals(rule.subjectName()));
|
||||||
|
assertThat(service.getPage(versionId, rule.groupNames().get(0), 0, 25).items())
|
||||||
|
.isNotEmpty().allMatch(item -> item.groupNames().contains(rule.groupNames().get(0)));
|
||||||
|
assertThat(service.getPage(versionId, "НетТакойДисциплины", 0, 25).totalItems()).isZero();
|
||||||
|
assertThat(service.getPage(versionId, "", 100000, 25).items()).isEmpty();
|
||||||
|
assertThat(service.getPage(versionId, "", -1, 1000).size()).isEqualTo(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.magistr.app.repository.AcademicCalendarRepository calendarRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.DisplayName("Календарные графики ищутся по году с ограничением страницы в БД")
|
||||||
|
void pagesCalendarsForFutureYears() {
|
||||||
|
var page = calendarRepository.searchOptions("2027-2028", null, null, null, null,
|
||||||
|
org.springframework.data.domain.PageRequest.of(0, 25,
|
||||||
|
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
|
||||||
|
.and(org.springframework.data.domain.Sort.by("title", "id"))));
|
||||||
|
assertThat(page.getTotalElements()).isEqualTo(44);
|
||||||
|
assertThat(page.getContent()).hasSize(25).allMatch(calendar -> calendar.getAcademicYear().getTitle().equals("2027-2028"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
void serializesConcurrentCreateInsideOneSemesterAcrossSpringTransactions() throws Exception {
|
||||||
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
assertThat(AopUtils.isAopProxy(service)).isTrue();
|
||||||
|
|||||||
@@ -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 | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
| `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` |
|
||||||
|
| `GET` | `/api/admin/academic-calendars/page` | Страница графиков: `query`, `academicYearId`, `page`, `size` |
|
||||||
| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
|
| `GET` | `/api/admin/academic-calendars/options` | Асинхронный поиск совместимых графиков для комбобокса |
|
||||||
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
| `GET` | `/api/admin/academic-calendars/{id}` | Один график |
|
||||||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||||||
@@ -624,6 +625,13 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
|||||||
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
|
название автоматически как `код специальности — профиль обучения — форма обучения — учебный год`.
|
||||||
Отдельного пользовательского названия у календарного графика нет.
|
Отдельного пользовательского названия у календарного графика нет.
|
||||||
|
|
||||||
|
`GET /api/admin/academic-calendars/page` возвращает `PageResponse` с полями
|
||||||
|
`items`, `page`, `size`, `totalItems`, `totalPages`, `first`, `last`.
|
||||||
|
Нумерация страниц начинается с 0, размер по умолчанию 25, сервер ограничивает его диапазоном 1–100.
|
||||||
|
Поиск `query` без учёта регистра охватывает название графика, учебный год, код и название
|
||||||
|
специальности, профиль и форму обучения. Сортировка: начало учебного года по убыванию,
|
||||||
|
затем название и ID по возрастанию.
|
||||||
|
|
||||||
`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
|
`GET /api/admin/academic-calendars/options` принимает `query`, `academicYearId`,
|
||||||
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
|
`specialtyId`, `profileId`, `studyFormId`, `selectedId` и `limit` (`1..50`). Поиск
|
||||||
выполняется по составному названию, учебному году, коду/названию специальности, профилю и
|
выполняется по составному названию, учебному году, коду/названию специальности, профилю и
|
||||||
@@ -718,11 +726,18 @@ CRUD доступен по:
|
|||||||
| Метод | URL | Назначение |
|
| Метод | URL | Назначение |
|
||||||
|-------|-----|------------|
|
|-------|-----|------------|
|
||||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||||
|
| `GET` | `/api/admin/schedule-rules/page` | Страница правил: обязательный `versionId`, параметры `query`, `page`, `size` |
|
||||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||||
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
|
||||||
|
|
||||||
|
`GET /api/admin/schedule-rules/page` возвращает тот же формат `PageResponse`;
|
||||||
|
`page` по умолчанию 0, `size` — 25 (ограничение 1–100). Правила выбранной версии,
|
||||||
|
кроме архивных, сортируются по ID по убыванию. `query` ищет по названию дисциплины,
|
||||||
|
названию группы или точному ID. Сначала БД выбирает страницу ID, затем загружаются
|
||||||
|
группы и слоты только этих правил; пагинация не выполняется в памяти над полной коллекцией.
|
||||||
|
|
||||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||||
|
|
||||||
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
|||||||
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
курса создаётся период `Т` от первой до последней даты учебного года включительно. Таким образом,
|
||||||
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
дни обычных занятий доступны генератору сразу, а пользователь редактирует только исключения:
|
||||||
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
сессию, каникулы, практику и другие активности. Редактирование реквизитов уже существующего
|
||||||
графика не сбрасывает его периоды. Миграция `V2__backfill_default_theory_periods.sql` применяет
|
графика не сбрасывает его периоды. Та же логика сохранена внутри тестовой миграции
|
||||||
такое заполнение к ранее созданным графикам только при полном отсутствии периодов; частично или
|
`V2__test_data.sql`: после создания seed-графиков она добавляет период `Т` только графикам
|
||||||
полностью настроенные сетки она не изменяет.
|
без единого периода; частично или полностью настроенные сетки не изменяются.
|
||||||
|
|
||||||
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
Учебные годы и семестры изменяются через транзакционный `AcademicPeriodService`. Границы
|
||||||
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
считаются включительными: разные учебные годы не могут иметь общую дату, а семестры не
|
||||||
|
|||||||
@@ -953,8 +953,8 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
|||||||
|
|
||||||
Пара `semester_id + version_number` уникальна. Частичный индекс
|
Пара `semester_id + version_number` уникальна. Частичный индекс
|
||||||
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
||||||
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
|
семестр. Тестовая загрузка V2 создаёт опубликованную версию 1 для каждого seed-семестра и
|
||||||
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
|
привязывает к ней тестовые правила. Для семестров, создаваемых через API после
|
||||||
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
||||||
`Основное расписание` и две записи истории: создание и публикацию.
|
`Основное расписание` и две записи истории: создание и публикацию.
|
||||||
|
|
||||||
@@ -1177,10 +1177,15 @@ CHECK фиксирует допустимую форму каждого типа
|
|||||||
### Правила работы
|
### Правила работы
|
||||||
|
|
||||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
2. Пока действует режим пересобираемого baseline, вся постоянная схема, функции, индексы,
|
||||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
триггеры и ограничения изменяются только в `V1__init.sql`.
|
||||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
3. `V2__test_data.sql` содержит только тестовые/демонстрационные данные. Постоянный DDL в
|
||||||
5. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
ней запрещён; временные staging-таблицы должны удаляться до завершения миграции.
|
||||||
|
4. `V3` и последующие миграции не создаются до отдельного решения владельца.
|
||||||
|
5. Изменение V1/V2 меняет контрольные суммы Flyway и требует полного сброса затронутых
|
||||||
|
tenant-БД. Сброс выполняется только по прямой команде владельца.
|
||||||
|
6. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||||
|
7. Настройка `baselineOnMigrate=true` — непустая БД без истории будет помечена baseline и
|
||||||
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
`V1` не выполнится; поэтому текущую консолидированную V1 применяют только к полностью
|
||||||
пустой tenant-схеме
|
пустой tenant-схеме
|
||||||
|
|
||||||
@@ -1188,19 +1193,46 @@ CHECK фиксирует допустимую форму каждого типа
|
|||||||
|
|
||||||
| Файл | Описание |
|
| Файл | Описание |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
| `V1__init.sql` | Полная schema-only baseline: таблицы, связи, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия, пожелания и заявки преподавателей, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии. Постоянных доменных данных нет |
|
||||||
| `V2__backfill_default_theory_periods.sql` | Добавляет периоды `Т` на весь учебный год для каждого курса только в полностью пустых календарных графиках; уже настроенные графики не изменяет |
|
| `V2__test_data.sql` | Единый тестовый seed: прежние демонстрационные записи V1, справочники и полный набор связанных нагрузочных данных. Здесь же после создания графиков сохранена логика прежней V2: период `Т` на весь год добавляется каждому курсу только полностью пустого графика |
|
||||||
|
|
||||||
### Этап разработки
|
### Этап разработки
|
||||||
|
|
||||||
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
|
Исторические разработческие изменения объединены в schema-only baseline `V1`. По решению
|
||||||
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
|
владельца проект временно не наращивает цепочку миграций: изменения постоянной схемы вносятся
|
||||||
проекта объединены в baseline `V1`. После фиксации baseline новые изменения оформляются
|
в V1, а V2 полностью зарезервирована под тестовый набор. Такой режим рассчитан на пересоздание
|
||||||
отдельными инкрементальными миграциями; первой стала `V2__backfill_default_theory_periods.sql`.
|
tenant-БД с нуля и несовместим с сохранением прежних контрольных сумм V1/V2.
|
||||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
|
||||||
непосредственно в V1.
|
`V2__test_data.sql` применяется общим `TenantDatabaseMigrationService` без разделения по
|
||||||
Существующая tenant-БД с уже применённой V1 получает V2 без изменения контрольной суммы
|
Spring-профилям, поэтому сейчас тестовый набор попадёт в каждый новый tenant, включая
|
||||||
baseline. Для развёртывания V1 с нуля по-прежнему требуется пустая tenant-схема.
|
production. Это сознательное ограничение текущего режима разработки; перед production-релизом
|
||||||
|
V2 необходимо сделать условной или исключить из production locations.
|
||||||
|
|
||||||
|
### Объём тестового набора V2
|
||||||
|
|
||||||
|
На чистой схеме V2 создаёт детерминированный связанный набор:
|
||||||
|
|
||||||
|
| Сущности | Количество |
|
||||||
|
|----------|-----------:|
|
||||||
|
| Кафедры / специальности / профили | 41 / 42 / 42 |
|
||||||
|
| Пользователи / преподаватели | 155 / 151 |
|
||||||
|
| Группы / подгруппы | 202 / 404 |
|
||||||
|
| Дисциплины / связи преподаватель–дисциплина / разрешённые типы занятий | 229 / 844 / 2532 |
|
||||||
|
| Аудитории / связи с оборудованием | 80 / 174 |
|
||||||
|
| Календарные графики / периоды / дисциплины графиков / назначения групп | 132 / 576 / 3456 / 202 |
|
||||||
|
| Версии / события истории | 8 / 16 |
|
||||||
|
| Правила / связи с группами / слоты / лабораторные подгруппы | 610 / 611 / 610 / 202 |
|
||||||
|
| Пожелания / отсутствия / комментарии к дисциплинам | 60 / 20 / 40 |
|
||||||
|
|
||||||
|
Учебные годы: 2024–2025, 2025–2026, 2026–2027 и 2027–2028. Для трёх последних
|
||||||
|
создаётся одинаковый набор из 44 графиков направлений с периодами теоретического обучения
|
||||||
|
и дисциплинами по семестрам. Назначения существующих групп сохраняются на 2025–2026 год.
|
||||||
|
|
||||||
|
Из 202 групп две (`ИВТ-21-1`, `ИБ-41м`) перенесены из прежней V1, ещё 200 созданы по
|
||||||
|
шифрам направлений ЮЗГУ. Источниками названий служат публичные страницы структуры,
|
||||||
|
приёмной кампании и перечней дисциплин ЮЗГУ; ФИО 150 массовых преподавателей синтетические
|
||||||
|
и имеют непубликуемый случайный пароль. Доступны для ручного входа только документированные
|
||||||
|
исторические demo-аккаунты.
|
||||||
|
|
||||||
### Полный сброс БД (локально)
|
### Полный сброс БД (локально)
|
||||||
|
|
||||||
|
|||||||
@@ -251,18 +251,18 @@ public class AbsenceController {
|
|||||||
|
|
||||||
### Правила
|
### Правила
|
||||||
|
|
||||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
1. До отдельного решения владельца вся постоянная схема изменяется только в `V1__init.sql`
|
||||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
2. `V2__test_data.sql` предназначена исключительно для тестовых и демонстрационных данных;
|
||||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
постоянный DDL в ней запрещён
|
||||||
4. После добавления — перезапустите backend для применения
|
3. Не создавайте `V3` и следующие миграции, пока владелец не отменит режим пересобираемого baseline
|
||||||
|
4. Любое изменение V1/V2 требует полного сброса затронутых tenant-БД из-за checksum mismatch
|
||||||
|
5. Не сбрасывайте tenant-БД без прямой команды владельца
|
||||||
|
|
||||||
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
`V1__init.sql` содержит только структуру: таблицы, функции, триггеры, индексы, ограничения
|
||||||
|
и комментарии. `V2__test_data.sql` содержит все прежние seed-записи, большой связанный набор
|
||||||
`V1__init.sql` зафиксирован как baseline: по прямому решению владельца в него объединено
|
и заполнение периодов `Т` для полностью пустых календарных графиков. Такое разделение позволяет
|
||||||
содержимое прежних разработческих V2–V7, а также таблицы отсутствий, решений по заменам,
|
отдельно проверить schema-only V1, но обе миграции по-прежнему автоматически применяются ко
|
||||||
пожеланий и заявок преподавателей. Новые изменения существующих tenant-БД оформляются только
|
всем tenant-БД.
|
||||||
отдельными инкрементальными миграциями. Текущая `V2__backfill_default_theory_periods.sql`
|
|
||||||
заполняет кодом `Т` полностью пустые календарные графики и не изменяет V1.
|
|
||||||
|
|
||||||
### Применение
|
### Применение
|
||||||
|
|
||||||
@@ -270,6 +270,10 @@ public class AbsenceController {
|
|||||||
# Локально — сброс и повтор всех миграций
|
# Локально — сброс и повтор всех миграций
|
||||||
docker compose down -v && docker compose up -d
|
docker compose down -v && docker compose up -d
|
||||||
|
|
||||||
|
# Интеграционная проверка V1/V2 на PostgreSQL через Testcontainers
|
||||||
|
cd backend
|
||||||
|
mvn -Dtest='com.magistr.app.migration.*IntegrationTest' test
|
||||||
|
|
||||||
# Продакшн — применить к существующим тенантам
|
# Продакшн — применить к существующим тенантам
|
||||||
kubectl rollout restart deployment backend -n magistr
|
kubectl rollout restart deployment backend -n magistr
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Новая модель является базовой схемой проекта и создаётся в `backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по типам занятий уже включены в эту базовую схему; применение рассчитано на полный сброс БД.
|
Новая модель является базовой схемой проекта и создаётся в
|
||||||
|
`backend/src/main/resources/db/migration/V1__init.sql`. Раздельные часы и недели начала по
|
||||||
|
типам занятий уже включены в schema-only baseline, а все демонстрационные записи загружаются
|
||||||
|
отдельно из `V2__test_data.sql`. Применение рассчитано на полный сброс БД.
|
||||||
|
|
||||||
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
Старый ручной слой занятий удалён. Динамическое расписание строится из правил `schedule_rules`, слотов `schedule_rule_slots` и календарного учебного графика группы.
|
||||||
|
|
||||||
## База данных
|
## База данных
|
||||||
|
|
||||||
`V1__init.sql` создаёт и заполняет:
|
`V1__init.sql` создаёт структуру следующих таблиц, а `V2__test_data.sql` заполняет их
|
||||||
|
связанным тестовым набором:
|
||||||
|
|
||||||
| Таблица | Назначение |
|
| Таблица | Назначение |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -65,6 +65,11 @@ Caddy использует локальный корневой сертифик
|
|||||||
| `admin` | `admin` | Администратор |
|
| `admin` | `admin` | Администратор |
|
||||||
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
| `Тестовый преподаватель` | `1234567890` | Преподаватель |
|
||||||
|
|
||||||
|
Эти и остальные прежние демонстрационные записи загружаются миграцией
|
||||||
|
`V2__test_data.sql`. Она также создаёт 200 дополнительных групп, 150 синтетических
|
||||||
|
преподавателей и связанные дисциплины, аудитории, календари и правила расписания.
|
||||||
|
Пароли массовых преподавателей генерируются случайно и не предназначены для входа.
|
||||||
|
|
||||||
### Полезные команды
|
### Полезные команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
2. Для группы находится назначенный календарный учебный график.
|
2. Для группы находится назначенный календарный учебный график.
|
||||||
3. По году начала обучения вычисляется текущий курс группы.
|
3. По году начала обучения вычисляется текущий курс группы.
|
||||||
4. По курсу и дате находится код активности.
|
4. По курсу и дате находится код активности.
|
||||||
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в seed-данных это `Т`.
|
5. Обычные пары генерируются только для кода, где `allow_schedule = true`; в тестовых данных
|
||||||
|
`V2__test_data.sql` это `Т`.
|
||||||
6. Для разрешённого дня выбираются активные правила и слоты.
|
6. Для разрешённого дня выбираются активные правила и слоты.
|
||||||
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
7. Лимит часов считается только по реально проведённым занятиям; для лабораторных подгрупп счётчик ведётся отдельно по каждой подгруппе.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
## Проверочный сценарий
|
## Проверочный сценарий
|
||||||
|
|
||||||
1. Полностью сбросить локальную БД.
|
1. Полностью сбросить локальную БД.
|
||||||
2. Запустить приложение и применить `V1__init.sql`.
|
2. Запустить приложение и применить schema-only `V1__init.sql`, затем тестовый набор
|
||||||
|
`V2__test_data.sql`.
|
||||||
3. Создать профиль специальности.
|
3. Создать профиль специальности.
|
||||||
4. Создать календарный график и заполнить дневную сетку.
|
4. Создать календарный график и заполнить дневную сетку.
|
||||||
5. Назначить график группе.
|
5. Назначить график группе.
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { AsyncCombobox } from '../async-combobox.js';
|
||||||
|
import { renderPagination } from '../pagination.js';
|
||||||
|
import { debounce } from '../url-state.js';
|
||||||
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
|
import { bindDateInputMask, readDateInputIso, setDateInputIso } from '../date-input.js';
|
||||||
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
|
||||||
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
import { academicCalendarWeekNumber } from './academic-calendar-grid.js';
|
||||||
@@ -209,6 +212,14 @@ export async function initAcademicCalendar() {
|
|||||||
let studyForms = [];
|
let studyForms = [];
|
||||||
let activityTypes = [];
|
let activityTypes = [];
|
||||||
let calendars = [];
|
let calendars = [];
|
||||||
|
let calendarPage = 0;
|
||||||
|
let calendarSize = 25;
|
||||||
|
let calendarRequest = 0;
|
||||||
|
const calendarCache = new Map();
|
||||||
|
const calendarComboboxes = new Map();
|
||||||
|
const calendarSearch = document.getElementById('academic-calendars-search');
|
||||||
|
const calendarYearFilter = document.getElementById('academic-calendars-year-filter');
|
||||||
|
const calendarPagination = document.getElementById('academic-calendars-pagination');
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
let calendarSubjectRows = [];
|
let calendarSubjectRows = [];
|
||||||
let timeSlotScopes = [];
|
let timeSlotScopes = [];
|
||||||
@@ -224,9 +235,27 @@ export async function initAcademicCalendar() {
|
|||||||
let calendarDayTooltipPositionAnimation = null;
|
let calendarDayTooltipPositionAnimation = null;
|
||||||
let loadedGridCalendarId = '';
|
let loadedGridCalendarId = '';
|
||||||
let loadedSubjectCalendarId = '';
|
let loadedSubjectCalendarId = '';
|
||||||
|
let subjectLoadRequest = 0;
|
||||||
|
let gridLoadRequest = 0;
|
||||||
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
|
const gridDirtyScope = createDirtyScope('academic-calendar-grid', 'В календарной сетке есть несохранённые изменения. Загрузить другой график и потерять их?');
|
||||||
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
|
const subjectDirtyScope = createDirtyScope('academic-calendar-subjects', 'В привязках дисциплин есть несохранённые изменения. Продолжить без сохранения?');
|
||||||
|
|
||||||
|
[editorCalendarSelect, subjectCalendarSelect].filter(Boolean).forEach(select => {
|
||||||
|
select.innerHTML = '<option value="">Выберите график</option>';
|
||||||
|
calendarComboboxes.set(select, new AsyncCombobox(select, {
|
||||||
|
placeholder: 'Поиск по направлению, профилю, форме или году',
|
||||||
|
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title,
|
||||||
|
optionDescription: calendar => calendar.specialtyName || '',
|
||||||
|
load: async (query, selectedId) => {
|
||||||
|
const params = new URLSearchParams({ query, limit: 20 });
|
||||||
|
if (selectedId) params.set('selectedId', selectedId);
|
||||||
|
const items = await api.get(`/api/admin/academic-calendars/options?${params}`);
|
||||||
|
items.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -261,6 +290,14 @@ export async function initAcademicCalendar() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
academicYearsRefreshButton?.addEventListener('click', loadYears);
|
||||||
|
calendarSearch?.addEventListener('input', debounce(() => {
|
||||||
|
calendarPage = 0;
|
||||||
|
loadCalendars();
|
||||||
|
}, 300));
|
||||||
|
calendarYearFilter?.addEventListener('change', () => {
|
||||||
|
calendarPage = 0;
|
||||||
|
loadCalendars();
|
||||||
|
});
|
||||||
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
calendarsRefreshButton?.addEventListener('click', loadCalendars);
|
||||||
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
|
||||||
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
|
academicYearStartInput?.addEventListener('input', handleAcademicYearStartInput);
|
||||||
@@ -363,6 +400,11 @@ export async function initAcademicCalendar() {
|
|||||||
academicYears = await api.get('/api/admin/calendar/years');
|
academicYears = await api.get('/api/admin/calendar/years');
|
||||||
renderAcademicYears();
|
renderAcademicYears();
|
||||||
populateYearSelects();
|
populateYearSelects();
|
||||||
|
const selectedYear = calendarYearFilter.value;
|
||||||
|
calendarYearFilter.innerHTML = '<option value="">Все учебные годы</option>' + academicYears.map(year =>
|
||||||
|
`<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||||||
|
calendarYearFilter.value = selectedYear;
|
||||||
|
refreshSelectVisuals(calendarYearFilter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -566,13 +608,28 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendars() {
|
async function loadCalendars() {
|
||||||
|
const request = ++calendarRequest;
|
||||||
|
calendarPagination?.replaceChildren();
|
||||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Загрузка...</td></tr>';
|
||||||
try {
|
try {
|
||||||
calendars = await api.get('/api/admin/academic-calendars');
|
const params = new URLSearchParams({ page: calendarPage, size: calendarSize, query: calendarSearch?.value.trim() || '' });
|
||||||
|
if (calendarYearFilter?.value) params.set('academicYearId', calendarYearFilter.value);
|
||||||
|
const page = await api.get(`/api/admin/academic-calendars/page?${params}`);
|
||||||
|
if (request !== calendarRequest) return;
|
||||||
|
if (calendarPage > 0 && calendarPage >= page.totalPages) {
|
||||||
|
calendarPage = Math.max(0, page.totalPages - 1);
|
||||||
|
return loadCalendars();
|
||||||
|
}
|
||||||
|
calendars = page.items;
|
||||||
|
calendars.forEach(calendar => calendarCache.set(String(calendar.id), calendar));
|
||||||
renderCalendars();
|
renderCalendars();
|
||||||
populateEditorCalendarSelect();
|
renderPagination(calendarPagination, page, {
|
||||||
populateSubjectCalendarSelect();
|
label: 'графиков',
|
||||||
|
onPage: value => { calendarPage = value; loadCalendars(); },
|
||||||
|
onSize: value => { calendarSize = value; calendarPage = 0; loadCalendars(); }
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (request !== calendarRequest) return;
|
||||||
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
calendarsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -580,7 +637,7 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
function renderCalendars() {
|
function renderCalendars() {
|
||||||
if (!calendars.length) {
|
if (!calendars.length) {
|
||||||
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не созданы</td></tr>';
|
calendarsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Календарные графики не найдены</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
calendarsTbody.innerHTML = calendars.map(calendar => `
|
calendarsTbody.innerHTML = calendars.map(calendar => `
|
||||||
@@ -620,6 +677,10 @@ export async function initAcademicCalendar() {
|
|||||||
const saved = id
|
const saved = id
|
||||||
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
? await api.put('/api/admin/academic-calendars/' + id, payload)
|
||||||
: await api.post('/api/admin/academic-calendars', payload);
|
: await api.post('/api/admin/academic-calendars', payload);
|
||||||
|
calendarCache.set(String(saved.id), saved);
|
||||||
|
calendarComboboxes.forEach((combobox, select) => {
|
||||||
|
if (select.value === String(saved.id)) setCalendarSelection(select, saved.id);
|
||||||
|
});
|
||||||
const savedTitle = buildAcademicCalendarTitle(saved);
|
const savedTitle = buildAcademicCalendarTitle(saved);
|
||||||
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
|
showAlert('academic-calendar-alert', `График "${escapeHtml(savedTitle)}" сохранён`, 'success');
|
||||||
resetCalendarForm();
|
resetCalendarForm();
|
||||||
@@ -654,7 +715,7 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
if (gridButton) {
|
if (gridButton) {
|
||||||
activateCalendarTab('grid');
|
activateCalendarTab('grid');
|
||||||
editorCalendarSelect.value = gridButton.dataset.id;
|
setCalendarSelection(editorCalendarSelect, gridButton.dataset.id);
|
||||||
refreshSelectVisuals(editorCalendarSelect);
|
refreshSelectVisuals(editorCalendarSelect);
|
||||||
await loadCalendarGrid();
|
await loadCalendarGrid();
|
||||||
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -663,7 +724,7 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
if (subjectsButton) {
|
if (subjectsButton) {
|
||||||
activateCalendarTab('subjects');
|
activateCalendarTab('subjects');
|
||||||
subjectCalendarSelect.value = subjectsButton.dataset.id;
|
setCalendarSelection(subjectCalendarSelect, subjectsButton.dataset.id);
|
||||||
refreshSelectVisuals(subjectCalendarSelect);
|
refreshSelectVisuals(subjectCalendarSelect);
|
||||||
await loadCalendarSubjects();
|
await loadCalendarSubjects();
|
||||||
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
subjectSummary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -679,7 +740,22 @@ export async function initAcademicCalendar() {
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
|
const deletedId = deleteButton.dataset.id;
|
||||||
|
await api.delete('/api/admin/academic-calendars/' + deletedId);
|
||||||
|
calendarCache.delete(deletedId);
|
||||||
|
calendarComboboxes.forEach((combobox, select) => {
|
||||||
|
if (select.value === deletedId) {
|
||||||
|
setCalendarSelection(select, '');
|
||||||
|
if (select === editorCalendarSelect) {
|
||||||
|
loadedGridCalendarId = '';
|
||||||
|
calendarGrid.replaceChildren();
|
||||||
|
calendarTotals.replaceChildren();
|
||||||
|
clearGridDirty();
|
||||||
|
} else {
|
||||||
|
loadCalendarSubjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
|
||||||
await loadCalendars();
|
await loadCalendars();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -713,15 +789,18 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendarSubjects() {
|
async function loadCalendarSubjects() {
|
||||||
|
const request = ++subjectLoadRequest;
|
||||||
hideAlert('calendar-subject-alert');
|
hideAlert('calendar-subject-alert');
|
||||||
const calendar = selectedSubjectCalendar();
|
const calendar = selectedSubjectCalendar();
|
||||||
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
if (subjectDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||||
subjectCalendarSelect.value = loadedSubjectCalendarId;
|
setCalendarSelection(subjectCalendarSelect, loadedSubjectCalendarId);
|
||||||
refreshSelectVisuals(subjectCalendarSelect);
|
refreshSelectVisuals(subjectCalendarSelect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (request !== subjectLoadRequest) return;
|
||||||
populateSubjectSemesterSelect();
|
populateSubjectSemesterSelect();
|
||||||
calendarSubjectRows = [];
|
calendarSubjectRows = [];
|
||||||
|
loadedSubjectCalendarId = '';
|
||||||
if (!calendar) {
|
if (!calendar) {
|
||||||
loadedSubjectCalendarId = '';
|
loadedSubjectCalendarId = '';
|
||||||
clearSubjectDirty();
|
clearSubjectDirty();
|
||||||
@@ -731,11 +810,13 @@ export async function initAcademicCalendar() {
|
|||||||
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
subjectSummary.innerHTML = '<div class="loading-row">Загрузка...</div>';
|
||||||
try {
|
try {
|
||||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/subjects`);
|
||||||
|
if (request !== subjectLoadRequest || String(calendar.id) !== subjectCalendarSelect.value) return;
|
||||||
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
calendarSubjectRows = sortCalendarSubjectRows(rows || []);
|
||||||
loadedSubjectCalendarId = String(calendar.id);
|
loadedSubjectCalendarId = String(calendar.id);
|
||||||
clearSubjectDirty();
|
clearSubjectDirty();
|
||||||
renderCalendarSubjects();
|
renderCalendarSubjects();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (request !== subjectLoadRequest) return;
|
||||||
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
subjectSummary.innerHTML = `<div class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</div>`;
|
||||||
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
showAlert('calendar-subject-alert', error.message || 'Ошибка загрузки дисциплин графика', 'error');
|
||||||
}
|
}
|
||||||
@@ -744,6 +825,10 @@ export async function initAcademicCalendar() {
|
|||||||
function addCalendarSubject() {
|
function addCalendarSubject() {
|
||||||
hideAlert('calendar-subject-alert');
|
hideAlert('calendar-subject-alert');
|
||||||
const calendar = selectedSubjectCalendar();
|
const calendar = selectedSubjectCalendar();
|
||||||
|
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||||
|
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const semesterNumber = Number(subjectSemesterSelect.value);
|
const semesterNumber = Number(subjectSemesterSelect.value);
|
||||||
const subjectId = Number(subjectSelect.value);
|
const subjectId = Number(subjectSelect.value);
|
||||||
if (!calendar || !semesterNumber || !subjectId) {
|
if (!calendar || !semesterNumber || !subjectId) {
|
||||||
@@ -781,6 +866,10 @@ export async function initAcademicCalendar() {
|
|||||||
async function saveCalendarSubjects() {
|
async function saveCalendarSubjects() {
|
||||||
hideAlert('calendar-subject-alert');
|
hideAlert('calendar-subject-alert');
|
||||||
const calendar = selectedSubjectCalendar();
|
const calendar = selectedSubjectCalendar();
|
||||||
|
if (calendar && String(calendar.id) !== loadedSubjectCalendarId) {
|
||||||
|
showAlert('calendar-subject-alert', 'Дождитесь загрузки дисциплин выбранного графика', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!calendar) {
|
if (!calendar) {
|
||||||
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
showAlert('calendar-subject-alert', 'Выберите календарный график', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -855,10 +944,11 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendarGrid() {
|
async function loadCalendarGrid() {
|
||||||
|
const request = ++gridLoadRequest;
|
||||||
hideAlert('calendar-editor-alert');
|
hideAlert('calendar-editor-alert');
|
||||||
const calendar = selectedEditorCalendar();
|
const calendar = selectedEditorCalendar();
|
||||||
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
if (gridDirtyScope.isDirty() && !await confirmAbandonDirtyChanges()) {
|
||||||
editorCalendarSelect.value = loadedGridCalendarId;
|
setCalendarSelection(editorCalendarSelect, loadedGridCalendarId);
|
||||||
refreshSelectVisuals(editorCalendarSelect);
|
refreshSelectVisuals(editorCalendarSelect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -869,6 +959,7 @@ export async function initAcademicCalendar() {
|
|||||||
try {
|
try {
|
||||||
await loadTimeSlotDateAssignments();
|
await loadTimeSlotDateAssignments();
|
||||||
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
|
||||||
|
if (request !== gridLoadRequest || String(calendar.id) !== editorCalendarSelect.value) return;
|
||||||
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
|
||||||
renderCalendarGrid(gridRows);
|
renderCalendarGrid(gridRows);
|
||||||
loadedGridCalendarId = String(calendar.id);
|
loadedGridCalendarId = String(calendar.id);
|
||||||
@@ -876,6 +967,7 @@ export async function initAcademicCalendar() {
|
|||||||
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
|
trackUxEvent('calendar_grid_loaded', { calendarId: calendar.id, hasSavedRows: rows.length > 0 });
|
||||||
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (request !== gridLoadRequest) return;
|
||||||
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,6 +979,10 @@ export async function initAcademicCalendar() {
|
|||||||
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (String(calendar.id) !== loadedGridCalendarId) {
|
||||||
|
showAlert('calendar-editor-alert', 'Сначала загрузите сетку выбранного графика', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
courseNumber: Number(day.dataset.course),
|
courseNumber: Number(day.dataset.course),
|
||||||
@@ -1535,31 +1631,12 @@ export async function initAcademicCalendar() {
|
|||||||
syncSelects(fillActivitySelect);
|
syncSelects(fillActivitySelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateEditorCalendarSelect() {
|
function setCalendarSelection(select, id) {
|
||||||
const current = editorCalendarSelect.value;
|
const calendar = calendarCache.get(String(id));
|
||||||
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
|
select.innerHTML = calendar
|
||||||
calendars.map(calendar =>
|
? `<option value="${calendar.id}" selected>${escapeHtml(buildAcademicCalendarTitle(calendar) || calendar.title)}</option>`
|
||||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
: '<option value="">Выберите график</option>';
|
||||||
).join('');
|
calendarComboboxes.get(select)?.syncFromSelect();
|
||||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
|
||||||
editorCalendarSelect.value = current;
|
|
||||||
}
|
|
||||||
syncSelects(editorCalendarSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateSubjectCalendarSelect() {
|
|
||||||
if (!subjectCalendarSelect) return;
|
|
||||||
const current = subjectCalendarSelect.value;
|
|
||||||
subjectCalendarSelect.innerHTML = calendars.length
|
|
||||||
? '<option value="">Выберите график</option>' + calendars.map(calendar =>
|
|
||||||
`<option value="${calendar.id}">${escapeHtml(buildAcademicCalendarTitle(calendar) || '-')}</option>`
|
|
||||||
).join('')
|
|
||||||
: '<option value="">Нет графиков</option>';
|
|
||||||
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
|
|
||||||
subjectCalendarSelect.value = current;
|
|
||||||
}
|
|
||||||
populateSubjectSemesterSelect();
|
|
||||||
syncSelects(subjectCalendarSelect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSubjectSemesterSelect() {
|
function populateSubjectSemesterSelect() {
|
||||||
@@ -1603,11 +1680,11 @@ export async function initAcademicCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectedEditorCalendar() {
|
function selectedEditorCalendar() {
|
||||||
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
|
return calendarCache.get(String(editorCalendarSelect.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedSubjectCalendar() {
|
function selectedSubjectCalendar() {
|
||||||
return calendars.find(calendar => String(calendar.id) === String(subjectCalendarSelect?.value));
|
return calendarCache.get(String(subjectCalendarSelect?.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function calendarSemesterCount(calendar) {
|
function calendarSemesterCount(calendar) {
|
||||||
@@ -1795,6 +1872,7 @@ export async function initAcademicCalendar() {
|
|||||||
|
|
||||||
function refreshSelectVisuals(...selects) {
|
function refreshSelectVisuals(...selects) {
|
||||||
selects.filter(Boolean).forEach(select => {
|
selects.filter(Boolean).forEach(select => {
|
||||||
|
calendarComboboxes.get(select)?.syncFromSelect();
|
||||||
if (select.customSelectInstance) {
|
if (select.customSelectInstance) {
|
||||||
select.customSelectInstance.rebuildMenu();
|
select.customSelectInstance.rebuildMenu();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export function filterQualityProblems(problems, severity = '', category = '') {
|
|||||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||||
return severityMatches && categoryMatches;
|
return severityMatches && categoryMatches;
|
||||||
});
|
}).sort((a, b) => (Number(b.penalty) || 0) - (Number(a.penalty) || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function problemCategory(type = '') {
|
function problemCategory(type = '') {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { renderPagination } from '../pagination.js';
|
||||||
|
import { refreshCustomDropdowns } from '../dropdown.js';
|
||||||
import {
|
import {
|
||||||
academicYearsFromSemesters,
|
academicYearsFromSemesters,
|
||||||
resolveAcademicPeriod,
|
resolveAcademicPeriod,
|
||||||
@@ -188,6 +190,14 @@ export async function initSchedule() {
|
|||||||
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
||||||
|
|
||||||
let rules = [];
|
let rules = [];
|
||||||
|
let visualRules = [];
|
||||||
|
let rulesPage = 0;
|
||||||
|
let rulesSize = 25;
|
||||||
|
let rulesRequest = 0;
|
||||||
|
let visualRequest = 0;
|
||||||
|
let rulesVersionId = '';
|
||||||
|
const rulesSearch = document.getElementById('schedule-rules-search');
|
||||||
|
const rulesPagination = document.getElementById('schedule-rules-pagination');
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
let groups = [];
|
let groups = [];
|
||||||
let lessonTypes = [];
|
let lessonTypes = [];
|
||||||
@@ -237,6 +247,10 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
|
rulesSearch?.addEventListener('input', debounce(() => {
|
||||||
|
rulesPage = 0;
|
||||||
|
loadRules();
|
||||||
|
}, 300));
|
||||||
refreshRulesButton?.addEventListener('click', loadRules);
|
refreshRulesButton?.addEventListener('click', loadRules);
|
||||||
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
||||||
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
||||||
@@ -522,7 +536,7 @@ export async function initSchedule() {
|
|||||||
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
||||||
lastVersionYearValue = scheduleVersionYearSelect.value;
|
lastVersionYearValue = scheduleVersionYearSelect.value;
|
||||||
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
||||||
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
refreshCustomDropdowns(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionOptionGroup(label, versions) {
|
function versionOptionGroup(label, versions) {
|
||||||
@@ -634,6 +648,11 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
|
const request = ++rulesRequest;
|
||||||
|
++visualRequest;
|
||||||
|
visualRules = [];
|
||||||
|
rules = [];
|
||||||
|
rulesPagination?.replaceChildren();
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||||||
const version = selectedScheduleVersion();
|
const version = selectedScheduleVersion();
|
||||||
if (!version) {
|
if (!version) {
|
||||||
@@ -642,26 +661,43 @@ export async function initSchedule() {
|
|||||||
setVisualState('Нет выбранного расписания');
|
setVisualState('Нет выбранного расписания');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rulesVersionId !== String(version.id)) {
|
||||||
|
rulesPage = 0;
|
||||||
|
rulesVersionId = String(version.id);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
const params = new URLSearchParams({ versionId: version.id, page: rulesPage, size: rulesSize, query: rulesSearch?.value.trim() || '' });
|
||||||
|
const page = await api.get(`/api/admin/schedule-rules/page?${params}`);
|
||||||
|
if (request !== rulesRequest) return;
|
||||||
|
if (rulesPage > 0 && rulesPage >= page.totalPages) {
|
||||||
|
rulesPage = Math.max(0, page.totalPages - 1);
|
||||||
|
return loadRules();
|
||||||
|
}
|
||||||
|
rules = page.items;
|
||||||
|
renderPagination(rulesPagination, page, {
|
||||||
|
label: 'правил',
|
||||||
|
onPage: value => { rulesPage = value; loadRules(); },
|
||||||
|
onSize: value => { rulesSize = value; rulesPage = 0; loadRules(); }
|
||||||
|
});
|
||||||
if (!visualPeriodTouched) {
|
if (!visualPeriodTouched) {
|
||||||
selectDefaultVisualPeriod();
|
selectDefaultVisualPeriod();
|
||||||
}
|
}
|
||||||
renderRules();
|
renderRules();
|
||||||
renderVisualSchedule();
|
if (visualDrawer?.classList.contains('open')) await loadVisualRules();
|
||||||
openRequestedRule();
|
await openRequestedRule();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (request !== rulesRequest) return;
|
||||||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
setVisualState('Ошибка загрузки правил расписания');
|
setVisualState('Ошибка загрузки правил расписания');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRequestedRule() {
|
async function openRequestedRule() {
|
||||||
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
|
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
|
||||||
if (!requestedRuleId) return;
|
if (!requestedRuleId) return;
|
||||||
localStorage.removeItem('magistr.schedule.openRuleId');
|
localStorage.removeItem('magistr.schedule.openRuleId');
|
||||||
const rule = rules.find(item => String(item.id) === requestedRuleId);
|
const rule = await api.get(`/api/admin/schedule-rules/${encodeURIComponent(requestedRuleId)}`);
|
||||||
if (!rule) {
|
if (!rule || String(rule.scheduleVersionId) !== String(selectedScheduleVersion()?.id)) {
|
||||||
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
|
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -670,7 +706,7 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function renderRules() {
|
function renderRules() {
|
||||||
if (!rules.length) {
|
if (!rules.length) {
|
||||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила не найдены</td></tr>';
|
||||||
renderVisualSchedule();
|
renderVisualSchedule();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -709,6 +745,7 @@ export async function initSchedule() {
|
|||||||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||||||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||||||
if (visualBackdrop) visualBackdrop.hidden = false;
|
if (visualBackdrop) visualBackdrop.hidden = false;
|
||||||
|
loadVisualRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeVisualDrawer() {
|
function closeVisualDrawer() {
|
||||||
@@ -722,34 +759,49 @@ export async function initSchedule() {
|
|||||||
if (visualBackdrop) visualBackdrop.hidden = true;
|
if (visualBackdrop) visualBackdrop.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVisualRules() {
|
||||||
|
const version = selectedScheduleVersion();
|
||||||
|
if (!version) return;
|
||||||
|
const request = ++visualRequest;
|
||||||
|
setVisualState('Загрузка визуального расписания...');
|
||||||
|
try {
|
||||||
|
const loaded = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||||||
|
if (request !== visualRequest) return;
|
||||||
|
visualRules = loaded;
|
||||||
|
renderVisualSchedule();
|
||||||
|
} catch (error) {
|
||||||
|
if (request === visualRequest) setVisualState(error.message || 'Ошибка загрузки визуального расписания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderVisualSchedule() {
|
function renderVisualSchedule() {
|
||||||
if (!visualStage || !visualSummary) return;
|
if (!visualStage || !visualSummary || !visualDrawer?.classList.contains('open')) return;
|
||||||
if (!selectedVisualSemesterId) {
|
if (!selectedVisualSemesterId) {
|
||||||
syncVisualGroupFilter([]);
|
syncVisualGroupFilter([]);
|
||||||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||||||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rules.length) {
|
if (!visualRules.length) {
|
||||||
syncVisualGroupFilter([]);
|
syncVisualGroupFilter([]);
|
||||||
visualSummary.textContent = 'Нет сохранённых правил';
|
visualSummary.textContent = 'Нет сохранённых правил';
|
||||||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visualRules = visualRulesForSelectedPeriod();
|
const periodRules = visualRulesForSelectedPeriod();
|
||||||
const periodLabel = visualPeriodLabel();
|
const periodLabel = visualPeriodLabel();
|
||||||
if (!visualRules.length) {
|
if (!periodRules.length) {
|
||||||
syncVisualGroupFilter([]);
|
syncVisualGroupFilter([]);
|
||||||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||||||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allGroups = collectVisualGroups(visualRules);
|
const allGroups = collectVisualGroups(periodRules);
|
||||||
syncVisualGroupFilter(allGroups);
|
syncVisualGroupFilter(allGroups);
|
||||||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||||||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(periodRules);
|
||||||
if (!allGroups.length || !slots.length) {
|
if (!allGroups.length || !slots.length) {
|
||||||
visualSummary.textContent = 'Недостаточно данных для построения';
|
visualSummary.textContent = 'Недостаточно данных для построения';
|
||||||
setVisualState('У правил должны быть группы и слоты занятий');
|
setVisualState('У правил должны быть группы и слоты занятий');
|
||||||
@@ -761,7 +813,7 @@ export async function initSchedule() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, periodRules);
|
||||||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||||||
@@ -769,7 +821,7 @@ export async function initSchedule() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
visualSummary.textContent = `${periodLabel} · ${periodRules.length} ${ruleCountLabel(periodRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||||||
visualStage.innerHTML = `
|
visualStage.innerHTML = `
|
||||||
<div class="schedule-visual-table-wrap">
|
<div class="schedule-visual-table-wrap">
|
||||||
<table class="schedule-visual-table">
|
<table class="schedule-visual-table">
|
||||||
@@ -1029,7 +1081,7 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openVisualContextMenu(button) {
|
function openVisualContextMenu(button) {
|
||||||
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
|
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(button.dataset.ruleId));
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -1074,7 +1126,7 @@ export async function initSchedule() {
|
|||||||
if (!actionButton || !visualContextTarget) return;
|
if (!actionButton || !visualContextTarget) return;
|
||||||
const action = actionButton.dataset.action;
|
const action = actionButton.dataset.action;
|
||||||
const target = { ...visualContextTarget };
|
const target = { ...visualContextTarget };
|
||||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
closeVisualContextMenu();
|
closeVisualContextMenu();
|
||||||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||||||
@@ -1117,7 +1169,7 @@ export async function initSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openMoveSlotModal(target) {
|
function openMoveSlotModal(target) {
|
||||||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
const rule = [...rules, ...visualRules].find(item => String(item.id) === String(target.ruleId));
|
||||||
const slotIndex = findRuleSlotIndex(rule, target);
|
const slotIndex = findRuleSlotIndex(rule, target);
|
||||||
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
||||||
if (!rule || !slot) {
|
if (!rule || !slot) {
|
||||||
@@ -1160,7 +1212,7 @@ export async function initSchedule() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!pendingSlotMove) return;
|
if (!pendingSlotMove) return;
|
||||||
hideAlert('schedule-move-slot-alert');
|
hideAlert('schedule-move-slot-alert');
|
||||||
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
|
const rule = [...rules, ...visualRules].find(item => String(item.id) === pendingSlotMove.ruleId);
|
||||||
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
||||||
if (!rule || !slot) {
|
if (!rule || !slot) {
|
||||||
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||||||
@@ -1330,7 +1382,7 @@ export async function initSchedule() {
|
|||||||
|
|
||||||
function visualRulesForSelectedPeriod() {
|
function visualRulesForSelectedPeriod() {
|
||||||
if (!selectedVisualSemesterId) return [];
|
if (!selectedVisualSemesterId) return [];
|
||||||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
return visualRules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstSemesterWithRules() {
|
function firstSemesterWithRules() {
|
||||||
@@ -1392,7 +1444,7 @@ export async function initSchedule() {
|
|||||||
const deleteButton = event.target.closest('.btn-delete-rule');
|
const deleteButton = event.target.closest('.btn-delete-rule');
|
||||||
|
|
||||||
if (editButton) {
|
if (editButton) {
|
||||||
const rule = rules.find(item => item.id == editButton.dataset.id)
|
const rule = [...rules, ...visualRules].find(item => item.id == editButton.dataset.id)
|
||||||
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
||||||
fillRuleForm(rule);
|
fillRuleForm(rule);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { renderPagination } from '../pagination.js';
|
||||||
|
import { debounce } from '../url-state.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||||
import { confirmAction } from '../dialog.js';
|
import { confirmAction } from '../dialog.js';
|
||||||
@@ -52,6 +54,29 @@ export async function initUniversityStructure() {
|
|||||||
let specialties = [];
|
let specialties = [];
|
||||||
let currentProfiles = [];
|
let currentProfiles = [];
|
||||||
let allProfiles = [];
|
let allProfiles = [];
|
||||||
|
const listStates = Object.fromEntries(['departments', 'specialties', 'profiles'].map(key => [key, { page: 0, size: 25 }]));
|
||||||
|
const listRenderers = { departments: renderDepartments, specialties: renderSpecialties, profiles: renderAllProfiles };
|
||||||
|
Object.keys(listStates).forEach(key => {
|
||||||
|
document.getElementById(`${key}-search`)?.addEventListener('input', debounce(() => {
|
||||||
|
listStates[key].page = 0;
|
||||||
|
listRenderers[key]();
|
||||||
|
}, 250));
|
||||||
|
});
|
||||||
|
|
||||||
|
function listPage(key, items, fields) {
|
||||||
|
const query = document.getElementById(`${key}-search`).value.trim().toLocaleLowerCase('ru');
|
||||||
|
const filtered = items.filter(item => fields(item).join(' ').toLocaleLowerCase('ru').includes(query));
|
||||||
|
const state = listStates[key];
|
||||||
|
const totalPages = Math.ceil(filtered.length / state.size);
|
||||||
|
state.page = Math.min(state.page, Math.max(0, totalPages - 1));
|
||||||
|
renderPagination(document.getElementById(`${key}-pagination`), {
|
||||||
|
...state, totalItems: filtered.length, totalPages
|
||||||
|
}, {
|
||||||
|
onPage: value => { state.page = value; listRenderers[key](); },
|
||||||
|
onSize: value => { state.size = value; state.page = 0; listRenderers[key](); }
|
||||||
|
});
|
||||||
|
return filtered.slice(state.page * state.size, (state.page + 1) * state.size);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Логика переключения табов ---
|
// --- Логика переключения табов ---
|
||||||
const structureTabs = [
|
const structureTabs = [
|
||||||
@@ -105,11 +130,12 @@ export async function initUniversityStructure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDepartments() {
|
function renderDepartments() {
|
||||||
if (!departments || !departments.length) {
|
const visible = listPage('departments', departments || [], d => [d.id, d.departmentName || d.name, d.departmentCode ?? d.code]);
|
||||||
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Кафедры не созданы</td></tr>';
|
if (!visible.length) {
|
||||||
|
deptTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deptTbody.innerHTML = departments.map(d => `
|
deptTbody.innerHTML = visible.map(d => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${d.id}</td>
|
<td>${d.id}</td>
|
||||||
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
<td>${escapeHtml(d.departmentName || d.name)}</td>
|
||||||
@@ -123,11 +149,12 @@ export async function initUniversityStructure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSpecialties() {
|
function renderSpecialties() {
|
||||||
if (!specialties || !specialties.length) {
|
const visible = listPage('specialties', specialties || [], d => [d.id, d.specialityName || d.name, d.specialityCode || d.specialtyCode || d.specialty_code]);
|
||||||
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Специальности не созданы</td></tr>';
|
if (!visible.length) {
|
||||||
|
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ничего не найдено</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
specTbody.innerHTML = specialties.map(s => `
|
specTbody.innerHTML = visible.map(s => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${s.id}</td>
|
<td>${s.id}</td>
|
||||||
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
<td>${escapeHtml(s.specialityName || s.name)}</td>
|
||||||
@@ -428,11 +455,12 @@ export async function initUniversityStructure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAllProfiles() {
|
function renderAllProfiles() {
|
||||||
if (!allProfiles || !allProfiles.length) {
|
const visible = listPage('profiles', allProfiles || [], d => [d.id, d.name, d.description, d.specialityCode, d.specialityName]);
|
||||||
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Профили не созданы</td></tr>';
|
if (!visible.length) {
|
||||||
|
tabProfileTbody.innerHTML = '<tr><td colspan="5" class="loading-row">Ничего не найдено</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tabProfileTbody.innerHTML = allProfiles.map(p => `
|
tabProfileTbody.innerHTML = visible.map(p => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${p.id}</td>
|
<td>${p.id}</td>
|
||||||
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
<td>${escapeHtml(p.specialityCode)} · ${escapeHtml(p.specialityName)}</td>
|
||||||
|
|||||||
@@ -76,6 +76,16 @@
|
|||||||
<h2>Календарные графики</h2>
|
<h2>Календарные графики</h2>
|
||||||
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
<button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="academic-calendars-search">Поиск графиков</label>
|
||||||
|
<input type="search" id="academic-calendars-search" placeholder="Направление, профиль, форма или год">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="academic-calendars-year-filter">Учебный год</label>
|
||||||
|
<select id="academic-calendars-year-filter" data-native-select="true"><option value="">Все учебные годы</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -95,6 +105,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="academic-calendars-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card create-card academic-year-builder-card">
|
<div class="card create-card academic-year-builder-card">
|
||||||
@@ -181,7 +192,7 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-editor-calendar">График</label>
|
<label for="calendar-editor-calendar">График</label>
|
||||||
<select id="calendar-editor-calendar">
|
<select id="calendar-editor-calendar" data-native-select="true">
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +237,7 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-subject-calendar">График</label>
|
<label for="calendar-subject-calendar">График</label>
|
||||||
<select id="calendar-subject-calendar">
|
<select id="calendar-subject-calendar" data-native-select="true">
|
||||||
<option value="">Загрузка...</option>
|
<option value="">Загрузка...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -221,6 +221,10 @@
|
|||||||
<h2>Правила динамического расписания</h2>
|
<h2>Правила динамического расписания</h2>
|
||||||
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
|
<button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-rules-search">Поиск правил</label>
|
||||||
|
<input type="search" id="schedule-rules-search" placeholder="Дисциплина, группа или ID правила">
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="schedule-table">
|
<table id="schedule-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -250,6 +254,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="schedule-rules-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<h2>Кафедры</h2>
|
<h2>Кафедры</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="departments-search">Поиск по названию или коду кафедры</label>
|
||||||
|
<input type="search" id="departments-search" placeholder="Введите название или код">
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="departments-table">
|
<table id="departments-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -47,6 +51,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="departments-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,6 +79,10 @@
|
|||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<h2>Специальности</h2>
|
<h2>Специальности</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="specialties-search">Поиск по названию или коду специальности</label>
|
||||||
|
<input type="search" id="specialties-search" placeholder="Введите название или код">
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="specialties-table">
|
<table id="specialties-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -91,6 +100,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="specialties-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,6 +138,10 @@
|
|||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<h2>Профили обучения</h2>
|
<h2>Профили обучения</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="profiles-search">Поиск по профилю, специальности или описанию</label>
|
||||||
|
<input type="search" id="profiles-search" placeholder="Введите название или код">
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="profiles-table">
|
<table id="profiles-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -146,6 +160,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="profiles-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,3 +52,16 @@ test('метрики и вкладка анализа подключены к SP
|
|||||||
assert.match(script, /magistr\.schedule\.openRuleId/);
|
assert.match(script, /magistr\.schedule\.openRuleId/);
|
||||||
assert.doesNotMatch(script, /quality\/recommendations/);
|
assert.doesNotMatch(script, /quality\/recommendations/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('правила сортируются по суммарному штрафу после фильтрации без изменения исходного анализа', () => {
|
||||||
|
const problems = [
|
||||||
|
{ id: 'малый', type: 'GROUP_GAP', severity: 'HIGH', penalty: 2 },
|
||||||
|
{ id: 'большой', type: 'GROUP_GAP', severity: 'LOW', penalty: 120 },
|
||||||
|
{ id: 'средний', type: 'GROUP_GAP', severity: 'HIGH', penalty: '30' },
|
||||||
|
{ id: 'равный', type: 'GROUP_GAP', severity: 'HIGH', penalty: 30 }
|
||||||
|
];
|
||||||
|
assert.deepEqual(filterQualityProblems(problems).map(item => item.id), ['большой', 'средний', 'равный', 'малый']);
|
||||||
|
assert.deepEqual(filterQualityProblems(problems, 'HIGH', 'GAPS').map(item => item.id), ['средний', 'равный', 'малый']);
|
||||||
|
assert.equal(problems[0].id, 'малый');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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