16 Commits

Author SHA1 Message Date
Zuev
e3b6797713 много всего и тестовые данные 2026-09-06 19:17:06 +03:00
Zuev
a1c4ee4298 Много разных изменений 2026-08-20 00:55:27 +03:00
Zuev
3599d850f6 merge: объединить изменения команды в Redesign 2026-08-15 00:25:58 +03:00
Zuev
52d516dd8f feat: улучшить учебные периоды и просмотр расписаний 2026-08-15 00:20:00 +03:00
dipatrik10
ca0b9352f9 Merge branch 'Redesign' of https://gitea.zuev.company/Zuev/magistr into Redesign 2026-08-14 23:41:59 +03:00
dipatrik10
d384a43aa2 Поправил графики 2026-08-14 23:41:55 +03:00
Zuev
855d191517 . 2026-08-14 22:54:16 +03:00
Zuev
491e373cc3 исправил качество правил и изменение расписания (не черновика) 2026-08-12 01:07:35 +03:00
Zuev
18a97b293a задачи 2 и 8 2026-08-11 18:18:08 +03:00
dipatrik10
519864b962 3 Задача 2026-08-11 01:55:02 +03:00
dipatrik10
5276729a69 1 Задача 2026-08-06 01:10:33 +03:00
Zuev
28268a38c0 р 2026-08-06 00:14:40 +03:00
dipatrik10
8f8ce0240b Идеи 2026-08-05 23:31:48 +03:00
Zuev
840554c223 концепт AI ассистента 2026-08-05 15:37:34 +03:00
Zuev
cc422255a2 исправление хранения календаря 2026-07-31 21:24:11 +03:00
Zuev
87f3d98621 / 2026-07-30 03:18:43 +03:00
223 changed files with 25539 additions and 2046 deletions

View File

@@ -183,7 +183,6 @@ jobs:
deploy-to-k8s:
name: Развернуть проверенные digests
needs: [build-and-push-backend, build-and-push-frontend, security-scan]
if: ${{ gitea.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production

View File

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

File diff suppressed because it is too large Load Diff

View File

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

292
DEVOPS.md
View File

@@ -1,292 +0,0 @@
# РАЗРАБОТКА И ВНЕДРЕНИЕ ОТКАЗОУСТОЙЧИВОЙ МУЛЬТИТЕНАНТНОЙ ИНФРАСТРУКТУРЫ ДЛЯ СИСТЕМЫ УПРАВЛЕНИЯ УНИВЕРСИТЕТСКИМ РАСПИСАНИЕМ «МАГИСТР»
**Диссертационное исследование и отчет о проделанной инженерной работе в качестве DevOps-архитектора проекта**
---
## ВВЕДЕНИЕ
Современные информационные системы для образовательных учреждений требуют строгого соблюдения требований к безопасности, изоляции данных различных организаций (университетов), гибкости масштабирования и высокой наблюдаемости (observability). В рамках разработки проекта **«Магистр»** — системы управления расписанием учебных занятий — передо мной встала задача проектирования и построения инфраструктуры, способной обслуживать десятки университетов в рамках единого прикладного контура при условии жесткой изоляции их баз данных.
В качестве DevOps-инженера проекта я спроектировал и реализовал архитектурное решение, объединяющее технологии аппаратной виртуализации, оркестрации контейнеров, автоматизации конфигурации, распределенного мониторинга и непрерывной интеграции. В данном документе подробно описаны теоретические предпосылки, практическая реализация и архитектурные решения, внедренные мной в продакшн-окружение проекта.
---
## РАЗДЕЛ 1. СИСТЕМНАЯ АРХИТЕКТУРА И КОНЦЕПЦИЯ МУЛЬТИТЕНАНТНОСТИ
### 1.1 Выбор паттерна изоляции данных
При проектировании мультитенантных (multi-tenant) систем классически выделяют три подхода к организации баз данных:
1. **Shared Database & Shared Schema**: Все клиенты используют общие таблицы, записи разделяются по полю `tenant_id`. Подход дешев в обслуживании, но несет колоссальные риски утечки данных из-за ошибок в SQL-запросах приложения и не позволяет разграничивать физический доступ к базам.
2. **Shared Database & Separate Schemas**: Одна СУБД, но разные логические схемы для каждого клиента. Обеспечивает умеренную изоляцию, но сохраняет единую точку отказа и общие аппаратные ресурсы СУБД.
3. **Database-per-Tenant (Выбранный подход)**: Каждый клиент (университет) владеет собственной физически изолированной базой данных, расположенной на выделенном сервере или виртуальной машине.
Для проекта «Магистр» мной был выбран и реализован паттерн **Database-per-Tenant**. Это обусловлено спецификой клиентов (высшие учебные заведения), которые согласно законодательству (например, ФЗ-152 «О персональных данных») обязаны хранить свои данные локально или требовать полной изоляции конфиденциальной информации. Кроме того, данный подход позволяет:
* Исключить влияние высокой нагрузки одного университета (например, в период составления сессий) на доступность системы для других вузов («синдром шумного соседа»).
* Выполнять индивидуальное резервное копирование и обслуживание БД каждого университета без остановки всей платформы.
* Переносить базы данных вузов на их собственные физические серверы в локальных сетях (on-premise), сохраняя при этом работу приложения в центральном облаке.
### 1.2 Архитектурная схема движения трафика
Ниже представлена разработанная мной схема прохождения сетевых запросов и агрегации телеметрии в системе:
```mermaid
graph TD
Client[Пользовательский браузер] -->|HTTPS: tenant.zuev.company| Caddy[Caddy Reverse Proxy]
Caddy -->|HTTP Round-Robin| Ingress[Traefik Ingress Controller]
subgraph K3s Cluster
Ingress -->|Route /*| Frontend[Frontend Pods x2 Apache]
Ingress -->|Route /api/*| Backend[Backend Pods x1-x2 Spring Boot]
Backend -->|K8s API: Get/Patch CM| K8sAPI[Kubernetes API Server]
BackendConfig[ConfigMap: tenants-config] -.->|Mounted JSON| Backend
end
subgraph Proxmox VE Infrastructure
Backend -->|JDBC Connection| VM1[(VM 1: DBMS MSU<br>PostgreSQL 16)]
Backend -->|JDBC Connection| VM2[(VM 2: DBMS SWSU<br>PostgreSQL 16)]
VM1 -.->|Metrics OTLP/gRPC| Collector1[OTel Collector VM1]
VM2 -.->|Metrics OTLP/gRPC| Collector2[OTel Collector VM2]
end
subgraph Observability Hub
Backend -->|Logs & Traces OTLP| SigNoz[SigNoz APM Server]
Collector1 -->|Postgres Metrics| SigNoz
Collector2 -->|Postgres Metrics| SigNoz
end
classDef cluster fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef vm fill:#efebe9,stroke:#4e342e,stroke-width:2px;
classDef monitor fill:#efe8ff,stroke:#512da8,stroke-width:2px;
class K3s Cluster cluster;
class Proxmox VE Infrastructure vm;
class Observability Hub monitor;
```
---
## РАЗДЕЛ 2. АППАРАТНАЯ ВИРТУАЛИЗАЦИЯ НА БАЗЕ PROMVOX VE
Для размещения баз данных клиентов и вспомогательных инфраструктурных служб мной был развернут гипервизор **Proxmox Virtual Environment (PVE)** на выделенных серверных мощностях.
### 2.1 Конфигурация виртуализации и изоляции ресурсов
Каждая база данных университета разворачивается в выделенной виртуальной машине (KVM), что гарантирует жесткую изоляцию на уровне ядра ОС и гипервизора.
* **Сетевой уровень**: В Proxmox настроена виртуальная коммутация (Linux Bridge `vmbr0`). Сетевые интерфейсы виртуальных машин баз данных вынесены в приватную подсеть, изолированную от внешней сети. Доступ к ним имеет только подсеть кластера Kubernetes и управляющая машина администратора.
* **Дисковая подсистема**: Использовано локальное хранилище на базе ZFS с зеркалированием (RAID-10 на SSD-накопителях). Это обеспечивает:
* Высокую скорость операций ввода-вывода (IOPS), критически важную для СУБД при транзакционных нагрузках.
* Механизм мгновенных снимков (snapshots) для безопасного проведения обновлений.
* Аппаратное сжатие данных (LZ4) без потери производительности, снижающее износ накопителей.
* **Резервное копирование**: Интегрирован **Proxmox Backup Server (PBS)**. Каждую ночь выполняются инкрементальные бэкапы виртуальных машин с дедупликацией на уровне блоков данных, что минимизирует нагрузку на сеть и диски, позволяя восстановить любую БД на любой момент времени за последние 30 дней.
---
## РАЗДЕЛ 3. ОРКЕСТРАЦИЯ КОНТЕЙНЕРНОЙ ИНФРАСТРУКТУРЫ В KUBERNETES (K3s)
В качестве платформы оркестрации приложений был выбран **K3s** — сертифицированный дистрибутив Kubernetes от Rancher, оптимизированный под средние и малые нагрузки, обладающий минимальными накладными расходами на RAM/CPU для Control Plane, но сохраняющий полную совместимость с upstream-спецификациями Kubernetes API.
### 3.1 Архитектура манифестов и структура ресурсов
Мной была разработана декларативная структура ресурсов, развернутая в изолированном пространстве имен `magistr` (манифест `namespace.yaml`):
* **Frontend (`frontend.yaml`)**:
* Реализован в виде `Deployment` с 2 репликами для обеспечения высокой доступности (HA) и возможности бесшовного обновления rolling-update.
* В качестве базового образа контейнера применен легковесный веб-сервер Apache HTTPd; версия и manifest digest закреплены в Dockerfile.
* Для балансировки и внутреннего доступа настроен `Service` типа ClusterIP, слушающий порт 80.
* **Backend (`backend.yaml`)**:
* `Deployment` с 1-2 репликами (детали балансировки конфигурации описаны ниже).
* Для сборки образов используется multi-stage сборка Maven (JDK 17); build/runtime-образы одновременно закреплены точным tag и manifest digest.
* Интегрирован Java-агент OpenTelemetry для автоматического инструментирования трассировки и логов.
* Для связи с Ingress настроен ClusterIP-сервис на порту 8080.
* **Ingress (`ingress.yaml`)**:
* В роли Ingress-контроллера выступает встроенный **Traefik** (`kube-system/traefik`).
* Он доступен извне через Klipper Service LoadBalancer (DaemonSet), который прокидывает порты 80/443 (hostPort) на внешние IP адреса нод кластера (`192.168.1.104`, `192.168.1.105`, `192.168.1.106`).
* Мной настроены строгие правила маршрутизации по доменным именам (например, `magistr.zuev.company` и `n8n.zuev.company`). Запросы к API (`/api`) проксируются на бэкенд, а к корню (`/`) — на фронтенд.
### 3.2 Реализация динамической мультитенантности через K8s API
Одним из наиболее сложных этапов проектирования стала организация бесшовного добавления новых университетов без перезапуска бэкенда и изменения исходного кода приложения.
1. **Хранение конфигурации**:
Список подключений к базам данных университетов хранится в JSON-формате (`tenants.json`) внутри Kubernetes `ConfigMap` с именем `tenants-config`.
2. **Проблема Read-Only монтирования**:
По умолчанию Kubernetes монтирует ConfigMap как файловую систему в режиме Read-Only. Это исключало возможность для Java-приложения перезаписывать `tenants.json` при запросах от администратора на добавление нового тенанта.
3. **Решение с использованием `initContainer` и `emptyDir`**:
Я спроектировал схему с временным перезаписываемым томом (`emptyDir`):
* В спецификацию пода backend добавлен `initContainer` на базе образа `busybox`.
* При старте пода `initContainer` монтирует ConfigMap `tenants-config` и копирует файл `tenants.json` во временный раздел `emptyDir` (путь `/config`).
* Основной контейнер `backend` монтирует этот же `emptyDir` в режиме Read-Write.
4. **K8s RBAC и динамическое сохранение**:
Чтобы предоставить бэкенду возможность сохранять изменения конфигурации обратно в кластер, мной были написаны манифесты авторизации (`rbac.yaml`):
* Создан `ServiceAccount` `backend-sa` и назначен поду бэкенда.
* Описана Kubernetes `Role` `backend-configmap-role`, дающая права только на операции `get` и `patch` для ресурса `configmaps` с конкретным именем `tenants-config` (принцип наименьших привилегий).
* Создан `RoleBinding` `backend-configmap-binding`, связывающий сервис-аккаунт с этой ролью.
В коде бэкенда класс `ConfigMapUpdater` через Kubernetes Java Client отправляет PATCH-запрос к API-серверу при добавлении нового тенанта, обновляя ConfigMap в самом кластере.
5. **Репликация и синхронизация подов**:
Для обеспечения консистентности данных в условиях работы нескольких реплик бэкенда:
* Класс `TenantConfigWatcher` запускает фоновую задачу, которая каждые 30 секунд сверяет MD5-хеш локального файла `tenants.json` с версией из ConfigMap.
* При обнаружении расхождения файл перезаписывается, DataSource-инстансы пересоздаются в памяти приложения на лету, исключая рассинхронизацию подов.
* *Примечание*: В ходе тестирования для минимизации задержек и исключения эффекта "мигания" данных на время отладки количество реплик backend было ограничено до 1, с последующим переходом на полноценный StatefulSet/Shared Storage при росте количества нод.
### 3.3 Повышение отказоустойчивости бэкенда (Resiliency)
При запуске бэкенда в облаке возникала проблема: если база данных одного из университетов становилась недоступной (например, из-за сетевого сбоя или регламентных работ), пул подключений HikariCP падал с ошибкой при инициализации бинов, отправляя под бэкенда в циклическую перезагрузку (`CrashLoopBackOff`), что делало недоступной систему для *всех* остальных вузов.
Для предотвращения этого каскадного сбоя я внедрил комплекс защитных механизмов:
* **H2 Fallback**: В сборку (`pom.xml`) добавлена СУБД H2 in-memory. Если при старте ConfigMap пуст, Spring Boot инициализирует пустой JPA-контекст на базе H2, что позволяет приложению успешно пройти фазу запуска.
* **HikariCP Resiliency**: В конфигурации источников данных задано свойство `setInitializationFailTimeout(-1)`. Это заставляет HikariCP игнорировать отсутствие связи с целевой БД при старте приложения, продолжая попытки подключения в фоновом режиме.
* **Явное указание диалекта Hibernate**: Принудительно задан `org.hibernate.dialect.PostgreSQLDialect` в `TenantDataSourceConfig.java`. Это избавило Hibernate от необходимости выполнять тестовый запрос к БД для автоматического определения диалекта на ранних этапах инициализации контекста.
* **Автоматическое создание таблиц (`DataInitializer`)**: Для новых БД вузов полностью автоматизирован процесс наката структуры. При обнаружении новой базы Java-код проверяет наличие таблицы `users` и, в случае ее отсутствия, самостоятельно применяет SQL-скрипт инициализации (`init.sql`), включая создание дефолтных справочников и учетной записи суперадминистратора.
---
## РАЗДЕЛ 4. АВТОМАТИЗАЦИЯ КОНФИГУРАЦИИ СУБД С ПОМОЩЬЮ ANSIBLE
Для развертывания и администрирования баз данных университетов на удаленных виртуальных машинах Proxmox мной был спроектирован и реализован комплексный плейбук **Ansible** с модульной структурой ролей.
### 4.1 Структура и функционал Ansible-ролей
Вся конфигурация целевого сервера разбита на 6 последовательных этапов (плейбук `site.yml`):
1. **`common` (Базовая подготовка и Hardening ОС)**:
* Создание системного пользователя `deploy` с беспарольным доступом к `sudo` (через валидацию файла `/etc/sudoers.d/deploy` утилитой `visudo`).
* Установка системных утилит (`curl`, `git`, `htop`, `chrony` для синхронизации времени по NTP).
* **SSH Hardening**: Изменение стандартного SSH-порта на `2222`, отключение авторизации по паролям, запрет входа для пользователя `root`, ограничение таймаута сессий.
* Адаптация под различные дистрибутивы ОС: написаны отдельные сценарии для семейств Debian/Ubuntu/Astra Linux (пакетный менеджер `apt`), RedHat/РЕД ОС (`dnf`) и ALT Linux (кастомная обертка для `apt-get`).
2. **`docker` (Среда контейнеризации)**:
* Автоматическое добавление официальных GPG-ключей и репозиториев Docker CE.
* Установка Docker Engine, CLI и Compose плагина.
* Включение системной службы `docker` и добавление пользователя `deploy` в группу `docker` для беспарольного запуска контейнеров.
3. **`firewall` (Сетевая безопасность)**:
* Настройка межсетевого экрана UFW (для Debian-based систем) или firewalld (для RedHat и ALT Linux).
* Полная блокировка входящего трафика по умолчанию.
* Разрешение входящих пакетов только на порт SSH (`2222`) и порт PostgreSQL (`5432`). Реализована поддержка ограничения доступа к порту `5432` только для IP-адресов нод Kubernetes-кластера (`firewall_postgresql_allowed_ips`).
4. **`postgresql` (Контейнеризированная СУБД)**:
* Генерация файлов конфигурации `docker-compose.yml` и `.env` на основе шаблонов Jinja2.
* Развертывание PostgreSQL 16 на базе легковесного Alpine-образа.
* Физическое монтирование каталога данных БД с хост-системы (`/opt/magistr/postgres/data`), предотвращающее потерю данных при пересоздании контейнера.
* Ограничение системных ресурсов контейнера (limits: memory: 512M) и тюнинг параметров PostgreSQL (`shared_buffers=256MB`, оптимизация пула соединений, логирование медленных запросов `log_min_duration_statement=1000`).
5. **`backup` (Резервное копирование на уровне БД)**:
* Установка на хост автоматического bash-скрипта бэкапа.
* Настройка задачи `cron` (запуск ежедневно в 03:00).
* Скрипт осуществляет горячий дамп базы через `docker exec pg_dump`, архивирует его с помощью `gzip` и сохраняет в локальный каталог `/opt/magistr/backups/`.
* Реализован алгоритм автоматической ротации: удаление архивных файлов старше заданного количества дней (по умолчанию 14 дней).
6. **`monitoring` (Сбор телеметрии хоста и БД)**:
* Развертывание контейнера OpenTelemetry Collector на целевом сервере БД.
* Конфигурирование коллектора на сбор метрик производительности PostgreSQL и ОС (процессор, память, диски) и их отправку на единый сервер SigNoz по OTLP/HTTP.
### 4.2 Управление секретами: Ansible Vault
Для исключения попадания паролей администраторов БД в систему контроля версий (Git) все чувствительные переменные (например, `vault_postgresql_password`) вынесены в файл `group_vars/databases/vault.yml` и зашифрованы алгоритмом AES-256 с помощью **Ansible Vault**.
Для бесшовной интеграции в CI/CD пароль расшифрования считывается из локального файла `.vault_pass` на управляющей машине, доступ к которому ограничен правами `chmod 600`.
---
## РАЗДЕЛ 5. НЕПРЕРЫВНАЯ ИНТЕГРАЦИЯ И ДОСТАВКА (CI/CD)
В рамках оптимизации внутренних процессов разработки и деплоя мной была развернута и настроена инфраструктура непрерывной интеграции на базе **Gitea** и **Gitea Actions**.
### 5.1 Автоматизация сборки (CI)
В репозитории проекта создан workflow-манифест `.gitea/workflows/docker-build.yaml`. При каждом пуше изменений в ветку `main` запускается конвейер:
1. **Checks**: Backend/frontend-тесты, Compose validation, тест immutable rollback и статическая проверка закрепления артефактов.
2. **Build & Push**: Параллельная сборка и публикация backend/frontend с SHA-tag или release tag; mutable `latest` не создаётся.
3. **Attestations**: BuildKit добавляет к обоим образам SBOM и максимальную provenance-attestation.
4. **Security scan**: Закреплённый digest Trivy блокирует доставку при исправимых уязвимостях `HIGH`/`CRITICAL`.
5. **Deploy**: Только прошедшие gates registry digests передаются в production rollout. Все сторонние Actions закреплены полными commit SHA.
### 5.2 Доставка в кластер (CD)
Для авторизации нод K3s в приватном реестре Gitea мной был создан секрет `gitea-registry` типа `docker-registry` в пространстве имен `magistr`. Этот секрет ассоциирован со спецификациями деплоев через директиву `imagePullSecrets`.
Непосредственно Gitea Actions Runner работает как демон `act_runner` внутри изолированного LXC контейнера (CTID 107). В пайплайне шаг развертывания (`deploy-to-k8s`) динамически генерирует `kubeconfig` из секрета, проверяет checksum закреплённой версии `kubectl` и выполняет атомарное обновление обоих образов без использования тяжеловесных GitOps операторов:
```bash
bash scripts/deploy-images.sh \
gitea.zuev.company/zuev/magistr-backend@sha256:<digest> \
gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>
```
Скрипт принимает только полные `image@sha256:...`, ожидает готовность обоих Deployment и при
ошибке возвращает предыдущую пару digest. Поэтому содержимое релиза не зависит от повторного
разрешения mutable tag.
---
## РАЗДЕЛ 6. ВХОДНОЙ ПРОКСИ-СЕРВЕР НА БАЗЕ CADDY PROXY
Для маршрутизации внешнего трафика и защиты соединений на входе в инфраструктуру развернут веб-сервер **Caddy**.
### 6.1 Преимущества Caddy и управление SSL/TLS
В отличие от классического Nginx, Caddy был выбран благодаря:
* Встроенной интеграции с удостоверяющими центрами Let's Encrypt и ZeroSSL. При добавлении нового поддомена вуза (например, `swsu.zuev.company`) Caddy автоматически запрашивает, валидирует через DNS/HTTP-вызовы и устанавливает TLS-сертификат, а также следит за его продлением.
* Лаконичному синтаксису конфигурации (`Caddyfile`).
* Полноценной поддержке протокола HTTP/3 «из коробки».
### 6.2 Конфигурация балансировки
Внешний Caddy-сервер развернут в изолированном LXC контейнере (CTID 101) и обрабатывает запросы ко всем поддоменам проекта. Он перенаправляет их на ноды кластера K3s, выполняя роль внешнего балансировщика, а также проксирует телеметрию с фронтенда напрямую в сборщик OTel:
```caddyfile
(otel_proxy) {
handle_path /otel/* { reverse_proxy 192.168.1.100:4318 }
}
(k8s_nodes) {
handle {
reverse_proxy 192.168.1.104:80 192.168.1.105:80 192.168.1.106:80 {
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
}
}
}
magistr.zuev.company { import otel_proxy; import k8s_nodes }
n8n.zuev.company { import otel_proxy; import k8s_nodes }
```
Сетевые запросы к приложениям распределяются по 3 физическим нодам K3s. Благодаря сниппетам, добавление нового тенанта в инфраструктуре Caddy требует всего двух строчек конфигурации.
---
## РАЗДЕЛ 7. СКВОЗНОЙ МОНИТОРИНГ И ОБСЕРВАБИЛИТИ (SigNoz + OpenTelemetry)
Для контроля здоровья системы и оперативного выявления аномалий мной была спроектирована и внедрена централизованная система мониторинга на базе APM-платформы **SigNoz** (с хранилищем **ClickHouse**) и стандартов **OpenTelemetry (OTel)**.
Платформа SigNoz вынесена в отдельный LXC контейнер (CTID 100) по адресу `192.168.1.100` и развернута через `docker-compose`. Данные ClickHouse и SQLite метаданные персистентно хранятся в Docker volumes хоста для обеспечения сохранности при рестартах.
### 7.1 Сбор телеметрии Backend
Java-приложение бэкенда запускается с подключением агента OpenTelemetry (`opentelemetry-javaagent.jar`). Это позволяет без изменения кода собирать:
* **Трейсы (Traces)**: Сквозное прохождение HTTP-запросов через контроллеры, сервисы и JDBC-драйвер к базам данных.
* **Метрики (Metrics)**: Метрики JVM (утилизация Heap, активность сборщика мусора GC, состояние потоков), метрики HTTP-запросов (интенсивность, латентность, коды ответов).
* **Логи (Logs)**: Системные логи Logback экспортируются по протоколу OTLP напрямую в SigNoz.
### 7.2 Идентификация тенантов в мониторинге
Для глубокого анализа производительности СУБД конкретных университетов критически важно разделять телеметрию по тенантам. Мной была реализована сквозная маркировка:
* В бэкенде через Java-интерцептор `TenantInterceptor` при каждом запросе идентификатор тенанта помещается в контекст логирования SLF4J MDC (`MDC.put("tenant.id", tenant)`) и одновременно записывается в атрибуты текущего OpenTelemetry спана (`Span.current().setAttribute("tenant.id", tenant)`).
* Благодаря переменной окружения `OTEL_INSTRUMENTATION_LOGBACK_APPENDER_EXPERIMENTAL_CAPTURE_MDC_ATTRIBUTES=tenant.id`, OTel-агент автоматически парсит MDC и прикрепляет его к логам, отправляемым в SigNoz. Это позволяет администраторам фильтровать ошибки и строить графики нагрузки в разрезе каждого университета.
### 7.3 Мониторинг баз данных на хостах
Для сбора детальной статистики с изолированных серверов баз данных:
1. На хостах БД силами Ansible разворачивается OpenTelemetry Collector.
2. В конфигурации коллектора (`otel-collector-config.yml.j2`) описывается ресивер `postgresql`, который подключается к локальной БД и считывает системные метрики (размер таблиц, cache hit ratio, количество транзакций, активные сессии, блокировки).
3. К собираемым метрикам жестко прикрепляются ресурсные атрибуты хоста: `service.name=magistr-db-<tenant_domain>` и `university.name=<University Name>`.
4. Метрики экспортируются в центральный коллектор SigNoz. В результате в интерфейсе SigNoz доступны кастомные дашборды JVM, PostgreSQL и HTTP, позволяющие оперативно локализовать проблемы с производительностью баз данных конкретных вузов.
---
## ЗАКЛЮЧЕНИЕ
Разработанная и внедренная мной DevOps-архитектура для проекта «Магистр» успешно решила задачи изоляции данных, отказоустойчивости и автоматизации администрирования.
### Ключевые результаты проделанной работы:
1. **Изоляция данных**: Реализована физическая изоляция БД университетов на выделенных серверах (VM в Proxmox VE), соответствующая требованиям безопасности и законодательства.
2. **Динамическое масштабирование**: Внедрен механизм динамического добавления тенантов без простоя системы (zero-downtime) за счет связки Spring Boot, K8s RBAC и ConfigMapWatcher.
3. **Отказоустойчивость**: Минимизированы риски каскадных сбоев бэкенда за счет применения in-memory fallback баз данных и оптимизации параметров подключения HikariCP.
4. **Автоматизация**: Создан универсальный Ansible-плейбук для быстрого ввода в эксплуатацию новых серверов БД (время развертывания «под ключ» сокращено до нескольких минут).
5. **Наблюдаемость**: Реализован сквозной мониторинг логов, метрик и трассировок с детализацией по тенантам на базе стека OpenTelemetry + SigNoz.
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».

View File

@@ -1,160 +0,0 @@
# Инструкция запуска Magistr
Ниже — минимальный порядок действий. Подробности по инфраструктуре и секретам находятся в
[`docs/INFRASTRUCTURE.md`](docs/INFRASTRUCTURE.md) и
[`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md).
## 1. Локальный запуск
Нужны Git, Docker Engine, Docker Compose и запущенный Caddy из `../сaddy-proxy/`.
1. В корне проекта создайте локальный файл настроек:
```bash
cp .env.example .env
openssl rand -base64 36 # значение POSTGRES_PASSWORD
openssl rand -base64 48 # значение JWT_SECRET
```
Вставьте полученные значения в `.env`. Файл `.env` не коммитьте.
2. Один раз создайте общую Docker-сеть, затем запустите Caddy и Magistr:
```bash
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
docker compose config --quiet
docker compose up -d --build
docker compose ps
```
3. Откройте `https://localhost` (`http://localhost` перенаправит на HTTPS). Проверьте backend
через Caddy:
```bash
curl -kfsS https://localhost/actuator/health/readiness
```
Если браузер не доверяет локальному сертификату Caddy, на CachyOS/Arch выполните:
```bash
docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-local-root.crt
sudo trust anchor --store /tmp/caddy-local-root.crt
```
До установки проверьте SHA-256 отпечаток командой
`openssl x509 -in /tmp/caddy-local-root.crt -noout -fingerprint -sha256`, затем полностью
перезапустите браузер.
Для первого локального входа: `admin` / `admin`. Эти данные предназначены только для
разработки.
4. Если запуск не удался:
```bash
docker compose logs --tail=200 backend db frontend
```
Полный локальный сброс БД выполняется командой `docker compose down -v`, но она удалит
все локальные данные. Обычная остановка без удаления данных: `docker compose down`.
## 2. Обновление существующего production
Простой push в `main` для первого обновления **недостаточен**. Он выполняет проверки,
собирает и сканирует образы, но job `deploy-to-k8s` запускается только вручную через
`workflow_dispatch`. Новые файлы `../k8s` CI не применяет. Gitea 1.25 игнорирует
`environment` и `concurrency`, поэтому они не используются как production-защита.
1. Подготовьте секреты по [`docs/SECURITY_RUNBOOK.md`](docs/SECURITY_RUNBOOK.md):
- `app-secret`: новый случайный `JWT_SECRET` не короче 32 байт, `POSTGRES_USER`,
`POSTGRES_PASSWORD`;
- `tenants-secret`: новый `tenants.json` с актуальными JDBC URL и ротированными паролями;
- `otel-postgres-secret`: восемь обязательных параметров подключений Collector;
- `gitea-registry`: доступ K3s к registry.
Для `magistr.zuev.company` нужен tenant с `"domain": "magistr"`, для
`n8n.zuev.company` — `"domain": "n8n"`. Секреты не сохраняйте в Git или CI-логах.
2. Выполните локальные проверки, затем отправьте изменения в `main`:
```bash
K8S_DIR=../k8s bash scripts/check-production-secrets.sh
K8S_DIR=../k8s bash scripts/check-artifact-pinning.sh
bash scripts/test-deploy-images.sh
kubectl kustomize ../k8s >/dev/null
```
Дождитесь успешных checks, сборки и Trivy scan. Скопируйте точные backend/frontend
ссылки вида `image@sha256:...` из CI/registry.
3. Объявите техническое окно и временно закройте публичный доступ во внешнем Caddy. Затем
остановите backend:
```bash
kubectl scale deployment/backend -n magistr --replicas=0
```
Полностью пересоздайте **каждую** PostgreSQL tenant-БД, указанную в новом
`tenants.json`. Недостаточно удалить `flyway_schema_history` или отдельные таблицы:
миграции V2–V7 объединены в новую `V1__init.sql`, поэтому старая БД получит checksum
error, а непустая БД без истории может быть ошибочно принята Flyway за baseline.
Пользователь БД должен уметь создавать схему, функции, триггеры и расширения
`pgcrypto`/`btree_gist`.
4. С рабочей станции с актуальным каталогом `../k8s` выполните первый rollout вручную:
```bash
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<digest>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<digest>'
bash ../k8s/deploy.sh apply
```
Скрипт применит ConfigMap, RBAC, Secret mount, probes, OTel и Ingress, поднимет две
реплики backend и дождётся Flyway/readiness. CI deploy после push не ожидает
подтверждения: он будет пропущен. Следующие image-only обновления можно запускать
вручную кнопкой `Run workflow`.
5. Перед открытием публичного доступа проверьте:
```bash
kubectl rollout status deployment/backend -n magistr --timeout=300s
kubectl rollout status deployment/frontend -n magistr --timeout=120s
kubectl get pods -n magistr
kubectl logs -n magistr -l app=backend --tail=200
curl -fsSI https://magistr.zuev.company/
```
Проверьте в браузере login → reload → refresh → logout для каждого tenant-домена.
Refresh-cookie должна иметь `HttpOnly`, `Secure`, `SameSite=Lax`, path `/api/auth`;
access JWT не должен находиться в Local/Session Storage. До снятия технического окна
смените или отключите все демонстрационные учётные записи из `V1__init.sql`.
6. После проверки удалите старый `ConfigMap/tenants-config`, отзовите старые DB/OTel
пароли и верните обычный доступ через Caddy. В дальнейшем image-only изменения можно
разворачивать ручным запуском workflow, но любые изменения `../k8s` по-прежнему нужно
применять отдельно или переносить этот каталог в управляемый CI репозиторий.
### Важные ограничения rollback
- Смена `JWT_SECRET` и сброс БД завершат все старые пользовательские сессии.
- Readiness требует доступности и успешной миграции всех tenant-БД; одна старая или
недоступная БД остановит rollout.
- Автоматический rollback возвращает только два image reference. Он не откатывает БД,
Secret, ConfigMap или RBAC; после применения новой V1 безопаснее исправлять проблему
вперёд либо снова пересоздавать БД под старую версию.
- CI не запускает Testcontainers integration tests: полный PostgreSQL-прогон нужно
повторить вручную, если после зафиксированных 300 успешных тестов код ещё менялся.
- `TRUSTED_PROXY_CIDRS=10.42.0.0/16` соответствует текущей pod-сети K3s. При смене
cluster CIDR его нужно обновить, иначе rate limit увидит неверные клиентские IP.
## 3. Что могу выполнить я
По вашей команде я могу подготовить `.env`, запустить и диагностировать локальный Compose,
проверить/исправить код и манифесты, прогнать тесты и dry-run Kubernetes, а при доступном
`kubectl` — проверить состояние кластера.
Без вас я не могу получить реальные пароли и токены, настроить DNS/сертификаты у внешнего
провайдера или войти в Gitea/Kubernetes, если доступы не предоставлены. Production deploy,
ротацию Secret и удаление БД я не выполняю без отдельного явного разрешения: это меняет
внешнюю систему и может прервать работу либо уничтожить данные.

676
UX_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,676 @@
# План улучшения пользовательского взаимодействия Magistr
## 1. Назначение документа
Этот документ описывает целевое пользовательское взаимодействие для всех ролей Magistr и поэтапный план его улучшения. Главная цель — сделать систему удобной не только на демонстрационных данных, но и в реальной работе университета: при сотнях групп и преподавателей, десятках календарных графиков, большом аудиторном фонде, нескольких версиях расписания и постоянном потоке изменений.
План составлен по текущей реализации frontend, ролевой матрице, API и бизнес-логике проекта. Это экспертный UX-аудит кода и документации, а не замена наблюдению за реальными пользователями. Перед крупными изменениями гипотезы следует проверить на сотрудниках учебного отдела, кафедр, преподавателях и студентах.
## 2. Краткий вывод
В Magistr уже реализована значительная часть предметной логики: ролевой доступ, календарные графики, конструктор и версии расписания, анализ качества, разовые изменения, отсутствия, пожелания преподавателей, нагрузка и отдельные кабинеты конечных пользователей.
Основная UX-проблема — функции организованы преимущественно как набор отдельных форм, таблиц и справочников. Живой пользователь мыслит не сущностями базы данных, а задачами:
- «подготовить новый учебный год»;
- «проверить готовность данных кафедр»;
- «составить и опубликовать расписание»;
- «найти замену отсутствующему преподавателю»;
- «быстро понять, где у меня следующая пара»;
- «узнать, приняли ли мою заявку».
При росте данных длинные селекты, таблицы без серверной пагинации, ручная настройка каждой группы и графика, а также потеря контекста между разделами станут главным ограничением. Поэтому приоритет — не визуальный редизайн сам по себе, а переход к интерфейсу задач, очередей, массовых операций и устойчивого рабочего контекста.
## 3. Принципы целевого интерфейса
1. **Сначала текущая задача, затем справочник.** Главный экран роли должен отвечать на вопрос «что требует моего внимания сегодня?».
2. **Контекст показывается только там, где влияет на результат.** Учебный год, семестр и версия выбираются каскадом внутри профильной вкладки и сохраняются при возврате; общая панель не занимает место в остальных разделах.
3. **Поиск вместо прокрутки.** Любая коллекция более 20–30 элементов должна иметь поиск, фильтры, сортировку и понятный счётчик результатов.
4. **Массовые действия — обязательны.** Повторяющиеся операции над группами, графиками, дисциплинами и заявками нельзя заставлять выполнять по одной записи.
5. **Безопасность изменений.** До сохранения пользователь видит последствия, конфликты и область действия; после сохранения — подтверждение, журнал и возможность отмены там, где это допустимо.
6. **Прогрессивное раскрытие.** Редкие и сложные параметры скрываются до необходимости, а основной сценарий остаётся коротким.
7. **Одинаковые паттерны во всех кабинетах.** Поиск, фильтры, статусы, пустые состояния, подтверждения, боковые панели и сообщения работают одинаково.
8. **Мобильный интерфейс ориентирован на просмотр и оперативные действия.** Большие редакторы могут оставаться desktop-first, но расписание, заявки, статусы и замены должны быть удобны с телефона.
9. **Доступность является критерием готовности.** Полная клавиатурная навигация, видимый фокус, корректные подписи, контраст, понятные ошибки и отсутствие зависимости только от цвета.
## 4. Текущее состояние и основные риски
### Что уже сделано хорошо
- Роли направляются в подходящие кабинеты, а недоступные разделы скрываются.
- Учебный отдел и администратор могут пройти путь от правил до версии, анализа качества и публикации.
- Для точечных изменений есть сравнение «было / станет» и журнал изменений.
- Кафедра имеет собственную рабочую область, а преподаватель — пожелания, отсутствия и заявки.
- Студенческое и преподавательское расписание имеют недельную навигацию и сохраняют часть локальных настроек.
- У календарного графика есть диапазонное заполнение, а не только изменение каждой даты по отдельности.
- Система использует архивирование и сохраняет исторический контекст.
### Критические UX-риски при больших объёмах
| Риск | Как проявится у пользователя | Последствие |
|---|---|---|
| Полная загрузка коллекций | Долгое открытие разделов групп, пользователей, дисциплин и расписания | Ощущение зависания, лишний трафик, тяжёлый DOM |
| Длинные селекты | Поиск нужной группы, преподавателя или графика среди сотен элементов | Ошибочный выбор и потеря времени |
| Нет системной пагинации и сортировки | Таблицы становятся длинными, позиция после действия теряется | Невозможность эффективно обрабатывать реестры |
| Операции по одной записи | Назначение графиков, подготовка групп и привязок повторяется вручную | Большое число кликов и высокий риск пропусков |
| Контекст хранится фрагментарно | Год, семестр, версия и фильтры приходится выбирать повторно | Ошибки работы «не в том семестре» или «не в той версии» |
| Разделы отражают модель данных | Пользователь сам собирает бизнес-процесс из нескольких вкладок | Непонятно, что делать дальше и готов ли процесс |
| Нативные `confirm` и `prompt` | Опасные действия имеют мало контекста, комментарии вводятся в системное окно | Слабая предсказуемость и доступность |
| Нет общего центра уведомлений | Пользователь узнаёт о новых заявках и решениях только после открытия раздела | Задержка согласований и публикаций |
| Нет общей модели несохранённых изменений | Переход может привести к потере сложной настройки | Повторная работа и недоверие к системе |
| Ограниченный экспорт и обмен | Расписание и отчёты сложно передать вне системы | Возврат к скриншотам и ручным таблицам |
## 5. Пользовательские сценарии по ролям
### 5.1. Администратор
#### Реальные цели
- подготовить структуру и справочники университета;
- создать и сопровождать пользователей;
- контролировать качество исходных данных;
- одобрять заявки кафедр на преподавателей;
- помогать устранять ошибки доступа и конфигурации;
- видеть состояние системы, а не вручную обходить все разделы.
#### Типичный сценарий сейчас
Администратор входит на дашборд, затем отдельно открывает пользователей, структуру вуза, группы, дисциплины, аудитории, календарные графики и заявки. Формы создания постоянно видимы над реестрами, даже если большую часть времени пользователь только ищет или проверяет записи.
#### Целевой сценарий
1. На главной странице администратор видит очередь внимания: ожидающие заявки, неполные данные, архивные зависимости, ошибки проверки и недавние действия.
2. Выбирает учебный год внутри мастера подготовки периода.
3. Открывает мастер «Подготовить учебный год» с шагами и прогрессом.
4. Исправляет только отмеченные проблемы через глубокие ссылки на конкретную запись.
5. Выполняет массовое создание или импорт, получает предварительный просмотр и отчёт по строкам.
6. После завершения видит статус готовности по каждому блоку.
#### Улучшения
- заменить постоянные формы создания кнопкой «Добавить», открывающей боковую панель;
- добавить глобальный поиск по пользователям, группам, кафедрам, дисциплинам и аудиториям;
- добавить фильтры по статусу, роли, кафедре и периоду действия;
- показывать историю записи и зависимые объекты до архивирования;
- добавить импорт пользователей, групп, аудиторий и дисциплин с dry-run-проверкой;
- ввести мастер первоначальной настройки тенанта и нового учебного года;
- показывать «здоровье данных»: группы без графика, дисциплины без преподавателей, аудитории без вместимости/оборудования, незакрытые заявки.
### 5.2. Учебный отдел
#### Реальные цели
- подготовить календарную и временную основу семестра;
- убедиться, что кафедры предоставили данные;
- составить расписание без конфликтов;
- сравнить, проверить и опубликовать версию;
- быстро обрабатывать отсутствия, переносы и замены;
- находить свободные аудитории и понимать последствия изменений.
#### Целевой основной путь «Составить и опубликовать расписание»
1. Выбрать учебный год, семестр и версию в локальной панели конструктора расписания.
2. Открыть чек-лист готовности: календарные графики назначены, дисциплины заполнены, преподаватели привязаны, пожелания рассмотрены, временная сетка настроена.
3. Создать черновик из опубликованной версии или с нуля.
4. Работать в конструкторе с поиском, пакетным добавлением и видимой матрицей ресурсов.
5. Получать конфликт сразу в месте редактирования, с предложенными вариантами решения.
6. Запустить анализ качества, перейти из проблемы прямо к правилу, вернуться с сохранёнными фильтрами.
7. Сравнить версию, проверить число изменений и затронутых групп/преподавателей.
8. Опубликовать с причиной и получить подтверждение доставки изменений конечным пользователям.
#### Целевой оперативный путь «Изменение в течение семестра»
1. Открыть единую очередь инцидентов: отсутствия, заявки преподавателей, конфликты и ручные изменения.
2. Увидеть приоритет, срок, автора и затронутые занятия.
3. Выбрать предложенную замену или найти ресурс через контекстный поиск.
4. До подтверждения увидеть «было / станет», новые конфликты и список уведомляемых людей.
5. Применить решение пакетом и получить запись в журнале.
#### Улучшения
- объединить версии, качество и конструктор общим пошаговым контуром, не удаляя отдельные экспертные экраны;
- добавить глобальную панель «Учебный год · Семестр · Версия»;
- ввести автосохранение черновика или явный индикатор «Сохранено / Есть изменения / Ошибка»;
- добавить undo для локально обратимых действий и журнал последних операций;
- сделать массовое назначение правил и слотов нескольким группам;
- добавить боковую инспекцию группы, преподавателя и аудитории без ухода со страницы;
- показывать конфликты и пожелания непосредственно в визуальной сетке;
- добавить командную палитру или быстрый поиск для опытных диспетчеров;
- сохранить представления фильтров: «1 курс ИТ», «вечерние аудитории корпуса Б», «кафедра ВТ».
### 5.3. Кафедра
#### Реальные цели
- поддерживать список дисциплин и преподавателей своей кафедры;
- назначать преподавателей на дисциплины;
- передавать учебному отделу корректные исходные данные;
- контролировать нагрузку;
- согласовывать пожелания и отсутствия;
- отслеживать результат заявок.
#### Проблема текущей компоновки
В одной рабочей области одновременно находятся период, импорт дисциплин, добавление преподавателя, заявка на преподавателя, нагрузка, заявки, дисциплины и комментарии. При повседневной работе это создаёт длинную страницу и смешивает редкие настройки с ежедневными задачами.
#### Целевой сценарий
1. Главная кафедры показывает готовность к семестру и очередь действий.
2. Раздел «Подготовка семестра» группирует дисциплины, преподавателей, привязки и пожелания.
3. Раздел «Нагрузка» показывает отклонения, недогруз и перегруз, а не только список метрик.
4. Раздел «Заявки» объединяет создание преподавателя, отсутствия и изменения занятий со статусами и сроками.
5. После отправки данных кафедра видит, что принято, отклонено или требует уточнения.
#### Улучшения
- разделить рабочую область на «Обзор», «Дисциплины», «Преподаватели и нагрузка», «Заявки»;
- добавить поиск и фильтры по дисциплинам и преподавателям;
- поддержать вставку строк из Excel/буфера и загрузку файла вместо ручного добавления строк импорта;
- добавить массовую привязку преподавателей к дисциплинам;
- показывать полноту данных по каждой дисциплине;
- отображать нагрузку с нормой, отклонением и объяснением расчёта;
- дать кафедре читаемый timeline заявки и явное следующее действие;
- уведомлять о решениях учебного отдела и администратора.
### 5.4. Пользователь просмотра расписаний
#### Реальные цели
- быстро найти расписание группы, преподавателя, аудитории или кафедры;
- переключаться между несколькими результатами;
- распечатать, экспортировать или отправить ссылку;
- понимать, актуально ли расписание и есть ли разовые изменения.
#### Улучшения
- единая поисковая строка с типизированными результатами: «группа», «преподаватель», «аудитория»;
- недавние и избранные расписания;
- URL, содержащий выбранный объект, семестр, дату и режим представления;
- кнопки «Скопировать ссылку», «Печать», «PDF», «ICS»;
- явная отметка «Опубликовано …», легенда замен и отмен;
- режим сравнения двух расписаний для поиска общего свободного времени;
- сохранение фильтров после перезагрузки и возврата на страницу.
### 5.5. Преподаватель
#### Реальные цели
- за несколько секунд увидеть ближайшую пару и аудиторию;
- понять, что изменилось с последнего просмотра;
- сообщить об отсутствии;
- задать доступность и пожелания до составления расписания;
- запросить перенос, аудиторию или отмену и отследить решение.
#### Целевой сценарий
1. После входа преподаватель видит «Сегодня» и карточку следующего занятия.
2. Изменённые, перенесённые и отменённые пары выделены и объяснены текстом.
3. Из карточки занятия доступны контекстные действия: маршрут до аудитории, заявка на изменение, добавление в календарь.
4. Заявка создаётся с уже заполненными данными занятия; система сразу показывает допустимые даты, пары и аудитории.
5. Статус заявки меняется в понятном timeline и сопровождается уведомлением.
#### Улучшения
- сделать «Сегодня / Неделя / Семестр» тремя явными режимами;
- добавить карточку следующего занятия и блок недавних изменений;
- показывать корпус, этаж и оборудование аудитории;
- сохранить черновик длинной заявки при случайном закрытии;
- объяснять разницу между строгой недоступностью и мягким пожеланием до выбора режима;
- добавить массовое заполнение предпочтений: копирование дня, диапазон пар, типовая неделя;
- показывать дедлайн подачи пожеланий и статус согласования;
- добавить экспорт в личный календарь и подписку ICS.
### 5.6. Студент
#### Реальные цели
- один раз выбрать свою группу;
- быстро увидеть занятия сегодня и завтра;
- узнать об отмене, переносе или смене аудитории;
- перейти к нужной неделе без знания чётности;
- поделиться расписанием или добавить его в календарь.
#### Проблема масштаба
Выбор группы обычным списком неудобен при сотнях групп. Пользователь не должен каждый раз разбираться в полном университетском справочнике.
#### Улучшения
- первый запуск: поиск группы по названию с подсказками по специальности, курсу и кафедре;
- после выбора сделать группу постоянным профилем, а смену вынести в отдельное действие;
- стартовый режим «Сегодня», ниже — «Завтра» и ближайшее изменение;
- добавить быстрый переход «Эта неделя / Следующая неделя»;
- показывать статус обновления расписания и понятную легенду изменений;
- добавить shareable URL, печать, PDF и ICS;
- предусмотреть кеш последнего расписания для слабой сети;
- на мобильном устройстве использовать карточки дней и sticky-навигацию, а не уменьшенную широкую таблицу.
## 6. Сквозные процессы между ролями
### 6.1. Подготовка семестра
```text
Администратор настраивает структуру и справочники
↓
Кафедра заполняет дисциплины, преподавателей и пожелания
↓
Учебный отдел проверяет готовность календарей и данных
↓
Учебный отдел создаёт и проверяет черновик
↓
Публикация → преподаватели, студенты и просмотр расписаний
```
Для процесса нужен единый статус готовности с владельцем каждой проблемы. Пользователь должен видеть не просто «ошибка», а «что не готово → кто исправляет → куда перейти».
### 6.2. Отсутствие и замена
```text
Преподаватель сообщает об отсутствии
↓
Кафедра подтверждает факт
↓
Учебный отдел выбирает решения по затронутым занятиям
↓
Расписание изменяется, решение попадает в аудит
↓
Преподаватель и затронутые группы получают уведомление
```
Все роли должны видеть один и тот же номер инцидента, единые статусы и хронологию, но разные доступные действия.
### 6.3. Заявка на изменение занятия
Нужна единая модель статусов: `Черновик → Отправлена → Проверяется → Требует уточнения / Одобрена / Отклонена → Применена`. Технические статусы API можно сохранить, но UI должен переводить их в понятный язык и показывать следующее действие.
## 7. Масштабирование интерфейса
### 7.1. Реестры
Для групп, пользователей, аудиторий, дисциплин, заявок и правил необходимо:
- серверная пагинация или cursor pagination;
- серверные поиск, фильтрация и сортировка;
- размер страницы 25/50/100;
- строка поиска с задержкой 250–400 мс и отменой предыдущего запроса;
- sticky-заголовок таблицы;
- сохранение фильтров в URL;
- выбор строк и массовая панель действий;
- счётчик «Показано N из M»;
- настраиваемые столбцы для сложных реестров;
- виртуализация только там, где пагинация мешает задаче, например в большой визуальной матрице.
### 7.2. Поиск сущностей
Селекты с большими наборами следует заменить асинхронным комбобоксом:
- поиск начинается после 2 символов;
- результат показывает различающие атрибуты;
- группы: название, курс, специальность, форма, кафедра;
- преподаватели: ФИО, должность, кафедры;
- аудитории: название, корпус, этаж, вместимость, оборудование;
- графики: учебный год, специальность, профиль, форма, число курсов;
- выбранное значение остаётся видимым чипом;
- клавиатура и экранный диктор поддерживаются полностью.
### 7.3. Календарные графики
При десятках похожих графиков требуются:
- пользовательское название или короткий alias графика;
- поиск и фасетные фильтры по году, специальности, профилю и форме;
- клонирование графика из прошлого года с предварительным просмотром сдвига дат;
- массовое создание на основе шаблона;
- массовое назначение выбранным совместимым группам;
- индикатор использования: сколько групп назначено;
- сравнение двух графиков и подсветка различий;
- защита от потери несохранённой сетки;
- автосохранение черновика сетки локально или на сервере;
- undo/redo для заполнения диапазона и изменения ячеек;
- проверка до сохранения с переходом к ошибочной дате;
- закреплённые заголовки курса, месяца и легенды при прокрутке.
### 7.4. Конструктор расписания
- master-detail-компоновка: слева фильтруемый список правил, справа редактор выбранного правила;
- создание из шаблона и дублирование правила;
- массовое назначение групп, преподавателя, слотов и диапазонов недель;
- фильтры по заполненности, конфликтам, кафедре, курсу и дисциплине;
- визуальная сетка загружает только выбранный набор групп;
- конфликт объясняется человеческим языком и содержит ссылку на конфликтующее правило;
- перед удалением показывается количество затронутых занятий;
- публикация заблокирована до прохождения обязательных проверок.
## 8. Целевая информационная архитектура
### Администратор
- Обзор и задачи
- Подготовка учебного года
- Пользователи и доступ
- Структура и справочники
- Расписание
- Оперативные изменения
- Отчёты и аудит
- Настройки
### Учебный отдел
- Обзор и очередь
- Подготовка семестра
- Конструктор расписания
- Проверка и публикация
- Просмотр расписаний
- Изменения и замены
- Ресурсы и загруженность
### Кафедра
- Обзор
- Подготовка семестра
- Дисциплины
- Преподаватели и нагрузка
- Расписание кафедры
- Заявки и согласования
Названия меню должны описывать рабочие задачи. Технические сущности остаются внутри экранов и документации, но не обязаны быть верхним уровнем навигации.
## 9. Общие компоненты и паттерны
Необходимо сформировать единый набор компонентов для Vanilla JS:
- асинхронный combobox;
- панель фильтров с чипами активных условий и кнопкой «Сбросить»;
- таблица с серверной пагинацией, сортировкой, выбором строк и массовыми действиями;
- боковая панель просмотра/редактирования;
- диалог подтверждения с описанием последствий;
- toast для краткого результата и inline-сообщение для ошибки поля/раздела;
- skeleton-состояние вместо скачка пустой таблицы;
- стандартизированные empty/error/permission states;
- status badge с текстом и иконкой;
- breadcrumb или ссылка «Назад к результатам» с восстановлением позиции;
- индикатор сохранения и защита несохранённых изменений;
- timeline для заявок и аудита;
- центр уведомлений и счётчики очередей.
Системные `confirm()` и `prompt()` следует постепенно заменить доступными проектными диалогами. Сообщение об успешном действии должно называть объект и результат: «График назначен 18 группам», а не просто «Успешно».
## 10. Состояния интерфейса
Каждый рабочий экран должен проектироваться минимум для следующих состояний:
1. первоначальная загрузка;
2. данные загружены;
3. пустой справочник;
4. фильтр не дал результатов;
5. частичная ошибка;
6. полная ошибка с повтором;
7. нет прав;
8. объект изменён другим пользователем;
9. есть несохранённые изменения;
10. действие выполняется;
11. действие успешно завершено;
12. действие завершено частично, например при массовом импорте.
Для конкурентного редактирования версий, календарей и правил желательно использовать optimistic locking и понятный диалог: кто и когда изменил объект, можно ли обновить данные или сохранить копию.
## 11. Уведомления
Минимально необходимы уведомления внутри приложения:
- новая заявка для ответственной роли;
- решение по заявке;
- приближение дедлайна пожеланий;
- публикация новой версии;
- перенос, отмена или смена аудитории;
- конфликт или ошибка массовой операции.
Уведомление должно вести к конкретному объекту, иметь статус прочтения и не раскрывать данные другой кафедры. Email/web-push можно добавить позднее, используя те же события.
## 12. Доступность и адаптивность
- обеспечить WCAG 2.2 AA как целевой уровень;
- проверить контраст обеих тем;
- добавить `aria-sort` таблицам и объявление количества результатов;
- реализовать корректные combobox/listbox-роли для поиска;
- возвращать фокус после закрытия модального окна;
- закрывать диалоги по `Escape`, удерживать фокус внутри открытого диалога;
- не использовать цвет как единственный признак конфликта, статуса или чётности;
- увеличить интерактивные зоны минимум до 44×44 px на мобильных экранах;
- для расписаний на телефоне показывать один день/список занятий, а не горизонтально сжатую таблицу;
- уважать `prefers-reduced-motion`;
- сделать печатные стили для расписаний и отчётов.
## 13. Приоритетный roadmap
### Этап 0. Проверка гипотез и базовые метрики — 1–2 недели
- провести 5–7 интервью с учебным отделом, 3–5 с кафедрами, 5 с преподавателями и 5 со студентами;
- наблюдать выполнение реальных задач без подсказок;
- замерить время, число кликов, ошибки и точки возврата;
- зафиксировать объёмы: группы, графики, правила, преподаватели, аудитории, заявки;
- построить карту основных процессов и согласовать владельцев статусов.
**Результат:** подтверждённый список top-10 проблем и исходные значения метрик.
### Этап 1. Быстрые улучшения frontend — 2–4 недели
- сохранять в URL и восстанавливать фильтры, вкладки, семестр и выбранный объект;
- добавить поиск, сортировку и sticky-заголовки в текущие таблицы;
- заменить опасные `confirm/prompt` проектными диалогами;
- унифицировать загрузку, пустые состояния и ошибки;
- добавить защиту несохранённых изменений в календарь и конструктор;
- улучшить первый выбор группы студента;
- добавить режим «Сегодня» преподавателю и студенту;
- добавить ссылки «Скопировать» и печатные стили просмотра расписания.
**Результат:** заметное сокращение ежедневного трения без крупной переделки backend.
### Этап 2. Масштабные реестры и поиск — 4–8 недель
- добавить API пагинации, поиска, сортировки и фасетных фильтров;
- внедрить общий компонент серверной таблицы;
- внедрить асинхронный combobox;
- добавить массовые операции для групп, графиков и привязок;
- добавить предварительный просмотр и отчёт массовых операций;
- обеспечить стабильные deep links на сущности.
**Результат:** интерфейс остаётся работоспособным на полном объёме данных университета.
### Этап 3. Интерфейс сквозных задач — 6–10 недель
- локальные каскады учебного года, семестра и версии в профильных вкладках;
- чек-лист готовности семестра;
- ролевые дашборды с очередями действий;
- единая очередь инцидентов и согласований;
- timeline заявок;
- связанный путь «конструктор → качество → сравнение → публикация»;
- центр уведомлений.
**Результат:** пользователь проходит бизнес-процесс, не собирая его вручную из вкладок.
### Этап 4. Продвинутые инструменты — после стабилизации
- шаблоны и клонирование календарных графиков;
- импорт из Excel с сопоставлением колонок;
- сравнение расписаний и поиск общего свободного времени;
- экспорт PDF/Excel/ICS и календарная подписка;
- оптимистические блокировки и совместное редактирование;
- командная палитра и горячие клавиши для опытных пользователей;
- персональные сохранённые представления.
## 14. Приоритеты по модели RICE без числовой фиксации
| Инициатива | Охват | Влияние | Сложность | Приоритет |
|---|---|---|---|---|
| Серверный поиск и пагинация | Все внутренние роли | Очень высокое | Высокая | P0 |
| Локальные каскады года/семестра/версии | Администратор, учебный отдел, кафедра | Очень высокое | Средняя | P0 |
| Защита несохранённых изменений | Учебный отдел | Очень высокое | Средняя | P0 |
| Асинхронные комбобоксы | Все роли | Высокое | Средняя | P0 |
| URL-состояние и глубокие ссылки | Все роли | Высокое | Низкая/средняя | P0 |
| Массовое назначение графиков группам | Администратор, учебный отдел | Очень высокое | Средняя/высокая | P0 |
| Ролевые очереди задач | Внутренние роли | Высокое | Средняя | P1 |
| Единый контур публикации | Учебный отдел | Высокое | Средняя | P1 |
| Уведомления | Все роли | Высокое | Высокая | P1 |
| Импорт с предварительной проверкой | Администратор, кафедра | Высокое | Высокая | P1 |
| «Сегодня» и ближайшая пара | Преподаватель, студент | Высокое | Низкая | P1 |
| PDF/ICS/ссылки | Просмотр, преподаватель, студент | Среднее | Средняя | P2 |
| Командная палитра | Опытные внутренние пользователи | Среднее | Средняя | P2 |
## 15. Изменения backend, необходимые для полноценного UX
Frontend-улучшений недостаточно для больших объёмов. Понадобятся:
- единый контракт пагинации и сортировки для реестров;
- полнотекстовый или индексированный поиск по нормализованным полям;
- batch-endpoints с атомарным и частичным режимами;
- dry-run для импорта, назначения и публикации;
- endpoint готовности семестра с кодами проблем и deep-link metadata;
- endpoint глобального поиска в рамках прав пользователя;
- события и хранилище уведомлений;
- optimistic locking/version field для редактируемых сущностей;
- агрегированные endpoints для дашбордов, чтобы не собирать их множеством клиентских запросов;
- экспортные задания для тяжёлых PDF/Excel;
- аудит массовых операций с автором, причиной и набором затронутых объектов.
Все контракты должны сохранять мультитенантную и ролевую изоляцию. Сервер обязан повторно применять ограничения роли независимо от фильтров frontend.
## 16. UX-метрики
### Операционные
- медианное время создания и назначения календарного графика;
- время поиска конкретной группы, преподавателя и аудитории;
- время от регистрации отсутствия до применения всех решений;
- время от создания черновика до публикации;
- число ручных действий на 10 групп или 10 правил;
- процент массовых операций с частичными ошибками.
### Качественные
- success rate ключевого сценария без помощи;
- число возвратов и повторных сохранений;
- доля ошибок «не тот семестр / не та версия / не та кафедра»;
- доля пользователей, понимающих следующий шаг;
- SUS или UMUX-Lite по каждой роли;
- CSAT после обработки заявки или публикации.
### Технические UX-метрики
- p75 времени появления полезного содержимого;
- p75 ответа поиска;
- размер DOM больших таблиц и сеток;
- частота ошибок API по экрану и действию;
- доля прерванных загрузок и повторных запросов;
- Core Web Vitals для публичных/конечных кабинетов.
Целевые значения следует зафиксировать после этапа 0. Без исходных измерений произвольные проценты улучшения будут недостоверны.
## 17. Критерии готовности ключевых улучшений
### Реестр больших данных готов, если
- 10 000 записей не загружаются целиком в браузер;
- поиск возвращает результат за приемлемое для проекта целевое время;
- фильтры и страница восстанавливаются после возврата;
- массовая операция показывает область действия до подтверждения;
- частичные ошибки перечислены по объектам и доступны для выгрузки;
- управление возможно с клавиатуры.
### Календарный график готов, если
- пользователь находит нужный график без просмотра полного списка;
- может создать его из шаблона и назначить нескольким совместимым группам;
- видит несохранённые изменения;
- может отменить последнее редактирование;
- ошибка указывает точный курс и дату;
- уход со страницы не приводит к молчаливой потере работы.
### Публикация готова, если
- текущий семестр и версия видны постоянно;
- до публикации показаны ошибки, предупреждения и diff;
- указано число затронутых групп, преподавателей и занятий;
- причина обязательна и попадает в аудит;
- пользователь получает однозначное подтверждение результата;
- конечные роли видят актуальность и изменения.
## 18. План пользовательского тестирования
Для каждого прототипа проводить короткие сценарные тесты:
| Роль | Проверочное задание |
|---|---|
| Администратор | Создать 20 групп импортом, исправить ошибки и назначить графики |
| Учебный отдел | Создать черновик, устранить конфликт, проверить качество и опубликовать |
| Учебный отдел | Обработать отсутствие с несколькими затронутыми занятиями |
| Кафедра | Добавить дисциплины и преподавателей, проверить готовность семестра |
| Просмотр | Найти расписание аудитории и отправить ссылку коллеге |
| Преподаватель | Указать недоступность и подать заявку на перенос |
| Студент | Выбрать группу и найти аудиторию следующей пары |
Фиксировать время, ошибки, вопросы пользователя, неверные ожидания и субъективную уверенность. Тестировать минимум на реальных объёмах, а не на 3–5 элементах.
## 19. Что не следует делать первым
- полностью менять визуальный стиль без исправления поиска, масштаба и процессов;
- объединять все роли в один универсальный экран;
- скрывать сложность только дополнительными модальными окнами;
- переносить всю фильтрацию на клиент;
- добавлять массовые действия без preview, отчёта и аудита;
- полагаться только на цвет для статусов;
- автоматически применять пожелания преподавателей без явного решения ответственной роли;
- внедрять drag-and-drop как единственный способ редактирования расписания.
## 20. Рекомендуемый первый пакет реализации
Первый практический пакет должен дать максимальный эффект без полной перестройки системы:
1. локальные каскады «Учебный год / Семестр / Версия» только в профильных вкладках;
2. URL-состояние для вкладок и фильтров;
3. асинхронный поиск групп, преподавателей, аудиторий и графиков;
4. серверная пагинация для групп, пользователей, дисциплин и заявок;
5. массовое назначение календарного графика выбранным группам с dry-run;
6. предупреждение о несохранённых изменениях календаря и конструктора;
7. проектные диалоги вместо `confirm/prompt`;
8. «Сегодня» и «Следующая пара» в кабинетах преподавателя и студента;
9. единые состояния загрузки, отсутствия данных и ошибки;
10. базовые события аналитики для измерения результата.
После этого пакета следует переходить к чек-листу готовности семестра, ролевым очередям и единому процессу публикации.
### 20.1. Статус реализации первого пакета — 19 августа 2026
| № | Статус | Что реализовано |
|---|---|---|
| 1 | Выполнено, решение уточнено | Общая панель контекста удалена. В `schedule`, `schedule-versions` и `schedule-quality` используются локальные каскады учебного года, семестра и версии; в `schedule-view` — года и семестра, поскольку экран показывает только опубликованное расписание. |
| 2 | Выполнено | Вкладки, фильтры, сортировка, размер и номер страницы сохраняются в query string. Просмотр расписания сохраняет локальные год и семестр; преподаватель — вкладку и неделю, студент — группу и неделю. |
| 3 | Выполнено | Добавлены серверные endpoints и общий `AsyncCombobox` для групп, преподавателей, аудиторий и совместимых календарных графиков. Первый выбор группы студента и выбор преподавателя при регистрации отсутствия больше не загружают полный справочник. |
| 4 | Выполнено | Группы, пользователи, дисциплины и заявки преподавателей используют серверные поиск, фильтры, сортировку и пагинацию 25/50/100 с общим `PageResponse`. Для страницы дисциплин загружаются только её привязки преподавателей, а подгруппы запрашиваются только для открытой или выбранной в конструкторе группы. |
| 5 | Отложено по решению заказчика | Массовое назначение одного календарного графика нескольким совместимым группам и dry-run в этот пакет не входят. Текущее одиночное назначение сохранено. |
| 6 | Выполнено | Календарная сетка, привязки дисциплин и конструктор правил отслеживают несохранённые изменения, показывают индикатор на кнопке и предупреждают при уходе, смене графика или версии. |
| 7 | Выполнено | Нативные `confirm`, `prompt` и `alert` в административных и settings-модулях заменены единым доступным проектным диалогом. |
| 8 | Выполнено | В кабинетах преподавателя и студента появились блок «Сегодня» и карточка «Следующая пара» с поиском занятия в горизонте 14 дней. |
| 9 | Выполнено | Для серверных реестров внедрены общие loading/empty/error-состояния с повтором; для ближайших занятий применяется единый компонент состояния. |
| 10 | Выполнено | Добавлены прикладные UX-события: смена раздела, загрузка реестра/расписания, выбор группы, сохранение правила, сетки и привязок. В production события преобразуются в OpenTelemetry spans. |
Дополнительно закрыты три согласованных требования масштабирования: серверные
поиск/фильтрация/сортировка/пагинация реестров, асинхронные селекты больших справочников и
восстановление фильтров/страницы из URL. Ролевое ограничение кафедры повторно применяется
backend и не зависит от переданного frontend-фильтра.
Проверка реализации: `npm run check` выполняет синтаксический контроль новых модулей и все
18 frontend-наборов; отдельный тест закрепляет выбор сегодняшних занятий, текущей и следующей
пары. Production-сборка frontend и 339 backend-тестов через полный `mvn test` в Java 17
контейнере проходят успешно. Массовое назначение из пункта 5 остаётся единственным
сознательно незавершённым пунктом этого пакета.
## 21. Источники анализа в проекте
- `docs/BUSINESS_LOGIC.md` — роли и бизнес-процессы;
- `docs/FRONTEND.md` — маршруты и текущее поведение экранов;
- `docs/API.md` — возможности и ограничения API;
- `frontend/admin/js/role-capabilities.js` — фактическая матрица вкладок;
- `frontend/admin/views/` и `frontend/admin/js/views/` — административные сценарии;
- `frontend/teacher/` — кабинет преподавателя;
- `frontend/student/` — кабинет студента.

55
UX_SITE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,55 @@
# Чек-лист проверки UX-изменений на сайте
## Перед проверкой
1. Запустите проект: `docker compose up -d --build`.
2. Откройте [http://localhost:80](http://localhost:80) или production-сайт.
3. Для полной проверки нужны пользователи с ролями `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `TEACHER` и `STUDENT`.
4. Пагинацию можно полноценно проверить, если в справочнике больше 25 записей.
## Что проверить
| № | Роль и куда зайти | Что нажать | Что должно быть видно |
|---|---|---|---|
| 1 | `ADMIN` → **Дашборд**, **Группы обучения** или **Пользователи** | Просто откройте вкладки | Под заголовком нет общей панели **КОНТЕКСТ**; она не занимает место в разделах, которым учебный период не нужен |
| 2 | `ADMIN` → **Группы обучения** | Введите текст в **Поиск**, выберите форму обучения и сортировку, затем обновите страницу | Фильтры остаются выбранными, а в адресной строке появляются параметры `groupQuery`, `groupEducationForm`, `groupSort`, `groupPage` |
| 3 | `ADMIN` → **Группы обучения**, **Пользователи**, **Дисциплины**, **Заявки** | Используйте поиск, фильтры, сортировку, **Назад / Далее** и список **На странице: 25 / 50 / 100** | Таблица обновляется без полной загрузки всех записей; показан диапазон вида «Показано 1–25 из …» |
| 4 | `ADMIN` или `EDUCATION_OFFICE` → **Просмотр расписаний** | Откройте вкладку, не нажимая на поле **Группа**; затем раскройте **Дополнительные фильтры**, выберите **Учебный год** и **Семестр** и нажмите **Показать** | При открытии список групп свёрнут; семестры ограничиваются выбранным годом, значения сохраняются в URL; экран показывает опубликованное расписание |
| 5 | `ADMIN` → **Группы обучения** → у группы кнопка **Календарь** | Выберите учебный год и начните печатать название графика | Совместимые календарные графики ищутся асинхронно; весь справочник заранее не загружается. Сохранять назначение для проверки не обязательно |
| 6 | `ADMIN` или `EDUCATION_OFFICE` → **Конструктор правил** | В верхней карточке по очереди выберите **Учебный год**, **Семестр**, **Версию расписания**; затем раскройте **Группы** и найдите преподавателя или аудиторию | Каждый селект ограничивает следующий; загружаются версии только выбранного семестра, а большие справочники ищутся через поисковые списки |
| 7 | `ADMIN` или `EDUCATION_OFFICE` → **Запросы преподавателей** → блок **Зарегистрировать отсутствие** | Начните вводить ФИО преподавателя из любой кафедры | Поиск находит преподавателя, а список результатов отображается поверх блока **Реестр инцидентов**, не прячется за ним |
| 8 | `ADMIN` → **Календарный график** → **Сетки** | Выберите график → **Загрузить сетку** → измените ячейку или примените **Заполнить диапазон** → нажмите другую вкладку слева | Кнопка меняется на **Сохранить сетку •**, затем появляется диалог **Несохранённые изменения** с действиями **Остаться** и **Покинуть без сохранения** |
| 9 | `ADMIN` → **Конструктор правил** | Сначала сразу перейдите в другой раздел, затем вернитесь, измените любое поле правила и снова попробуйте уйти | Без изменений переход выполняется без предупреждения; после ручного изменения появляется диалог **Несохранённые изменения** |
| 10 | `ADMIN` → **Группы обучения** | У активной группы нажмите **Архив**, затем в диалоге нажмите **Отмена** | Появляется проектный диалог **Архивировать группу?**, а не системное окно браузера; данные после отмены не меняются |
| 11 | `TEACHER` → **Расписание** | Просто откройте кабинет; затем нажмите **Сегодня** | Над недельным расписанием видны карточки **Сегодня** и **Следующая пара**; кнопка возвращает к текущей неделе |
| 12 | `STUDENT` → страница расписания | Начните вводить название группы, выберите её и нажмите **Сегодня** | Группа ищется асинхронно; появляются карточки **Сегодня** и **Следующая пара**; выбранная группа сохраняется после перезагрузки |
| 13 | `TEACHER` → **Мои пожелания** или **Заявки на изменение** | Переключите вкладку и обновите страницу | Открытая вкладка восстанавливается из URL; выбранная неделя также сохраняется |
| 14 | Любой серверный реестр, например `ADMIN` → **Дисциплины** | Введите заведомо отсутствующую строку, например `___нет_такой_записи___` | Появляется понятное пустое состояние **Дисциплины не найдены**, а не пустая таблица |
| 15 | `ADMIN` или `EDUCATION_OFFICE` → **Версии расписания**, затем **Анализ качества** | На обеих вкладках смените **Учебный год** и **Семестр**; в анализе дополнительно выберите **Версию** | В «Версиях» обновляются карточки публикации/черновиков выбранного семестра, в «Анализе качества» — список доступных версий |
## Дополнительные проверки
### Ошибка и повторная загрузка
1. Откройте DevTools → **Network** → включите **Offline**.
2. Обновите реестр групп, пользователей, дисциплин или заявок.
3. Должно появиться сообщение об ошибке и кнопка **Повторить**.
4. Верните **Online** и нажмите **Повторить** — данные должны загрузиться.
### UX-аналитика
До выполнения действий вставьте в консоль браузера:
```javascript
document.addEventListener('magistr:ux-event', event => console.log(event.detail));
```
После смены вкладки, фильтра или загрузки расписания в консоли должны появляться UX-события. На production они также преобразуются в OpenTelemetry spans.
### Ролевое ограничение кафедры
Войдите как `DEPARTMENT` → **Запросы преподавателей** и начните искать преподавателя в блоке регистрации отсутствия. В результатах должны быть только преподаватели текущей кафедры. Во вкладке **Просмотр расписаний** доступны локальные селекты учебного года и семестра, но не черновики версий.
## Что пока не проверяем
Массовое назначение одного календарного графика нескольким группам намеренно не реализовано. Проверяется только существующее одиночное назначение через **Группы обучения → Календарь**.

View File

@@ -21,20 +21,20 @@ public class AcademicCalendarAdminController {
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final AcademicPeriodService academicPeriodService;
public AcademicCalendarAdminController(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
ScheduleGeneratorService scheduleGeneratorService,
AcademicPeriodService academicPeriodService) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.academicPeriodService = academicPeriodService;
}
@@ -128,7 +128,7 @@ public class AcademicCalendarAdminController {
if (!activityTypeRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
if (calendarDayRepository.existsByActivityTypeId(id)) {
if (calendarPeriodRepository.existsByActivityTypeId(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Код активности используется в календарных графиках"));
}
activityTypeRepository.deleteById(id);

View File

@@ -7,16 +7,21 @@ import com.magistr.app.repository.*;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
import com.magistr.app.service.ScheduleGeneratorService;
import com.magistr.app.utils.AcademicCalendarTitleBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.ArrayList;
import java.util.Locale;
import java.util.stream.Collectors;
@RestController
@@ -25,11 +30,7 @@ import java.util.stream.Collectors;
public class AcademicCalendarController {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
private final SpecialtyProfileRepository profileRepository;
private final EducationFormRepository educationFormRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final SubjectRepository subjectRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicCalendarGridService calendarGridService;
@@ -37,22 +38,14 @@ public class AcademicCalendarController {
private final AcademicStructureService academicStructureService;
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
SpecialtyProfileRepository profileRepository,
EducationFormRepository educationFormRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
SubjectRepository subjectRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicCalendarGridService calendarGridService,
ScheduleGeneratorService scheduleGeneratorService,
AcademicStructureService academicStructureService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
this.profileRepository = profileRepository;
this.educationFormRepository = educationFormRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.subjectRepository = subjectRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.calendarGridService = calendarGridService;
@@ -69,6 +62,46 @@ public class AcademicCalendarController {
.toList();
}
@GetMapping("/page")
@Transactional(readOnly = true)
public PageResponse<AcademicCalendarDto> getCalendarsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long academicYearId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
return PageResponse.from(calendarRepository.searchOptions(
query.trim().toLowerCase(Locale.ROOT), academicYearId, null, null, null,
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
Sort.by("academicYear.startDate").descending().and(Sort.by("title", "id"))))
.map(this::toCalendarDto));
}
@GetMapping("/options")
public List<AcademicCalendarDto> searchCalendarOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long academicYearId,
@RequestParam(required = false) Long specialtyId,
@RequestParam(required = false) Long profileId,
@RequestParam(required = false) Long studyFormId,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
List<AcademicCalendarDto> options = new ArrayList<>(calendarRepository.searchOptions(
normalizedQuery,
academicYearId,
specialtyId,
profileId,
studyFormId,
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("title").ascending())
).stream()
.map(this::toCalendarDto)
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.id()))) {
calendarRepository.findByIdWithDetails(selectedId).map(this::toCalendarDto).ifPresent(options::add);
}
return options;
}
@GetMapping("/{id}")
public ResponseEntity<?> getCalendar(@PathVariable Long id) {
return calendarRepository.findByIdWithDetails(id)
@@ -79,10 +112,7 @@ public class AcademicCalendarController {
@PostMapping
public ResponseEntity<?> createCalendar(@RequestBody AcademicCalendarDto request) {
try {
AcademicCalendar calendar = new AcademicCalendar();
applyCalendar(calendar, request);
scheduleGeneratorService.clearCache();
return ResponseEntity.ok(toCalendarDto(calendarRepository.save(calendar)));
return ResponseEntity.ok(toCalendarDto(academicStructureService.createCalendar(request)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
}
@@ -108,8 +138,10 @@ public class AcademicCalendarController {
if (!calendarRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(calendarDayRepository.findByCalendarIdWithActivity(id).stream()
.map(this::toGridDayDto)
return ResponseEntity.ok(calendarPeriodRepository.findByCalendarIdWithActivity(id).stream()
.flatMap(period -> period.getStartDate()
.datesUntil(period.getEndDate().plusDays(1))
.map(date -> toGridDayDto(period, date)))
.toList());
}
@@ -149,41 +181,6 @@ public class AcademicCalendarController {
}
}
private void applyCalendar(AcademicCalendar calendar, AcademicCalendarDto request) {
if (request == null) {
throw new IllegalArgumentException("Передайте данные календарного графика");
}
if (request.courseCount() == null || request.courseCount() <= 0) {
throw new IllegalArgumentException("Количество курсов должно быть больше нуля");
}
if (request.courseCount() > 8) {
throw new IllegalArgumentException("Количество курсов не может быть больше 8");
}
if (request.academicYearId() == null || request.specialtyId() == null
|| request.specialtyProfileId() == null || request.studyFormId() == null) {
throw new IllegalArgumentException("Учебный год, специальность, профиль и форма обучения обязательны");
}
AcademicYear year = academicYearRepository.findById(request.academicYearId())
.orElseThrow(() -> new IllegalArgumentException("Учебный год не найден"));
Speciality speciality = specialtiesRepository.findById(request.specialtyId())
.orElseThrow(() -> new IllegalArgumentException("Специальность не найдена"));
SpecialtyProfile profile = profileRepository.findById(request.specialtyProfileId())
.orElseThrow(() -> new IllegalArgumentException("Профиль обучения не найден"));
if (!profile.getSpeciality().getId().equals(speciality.getId())) {
throw new IllegalArgumentException("Профиль не относится к выбранной специальности");
}
EducationForm studyForm = educationFormRepository.findById(request.studyFormId())
.orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена"));
calendar.setTitle(AcademicCalendarTitleBuilder.build(year, speciality, profile, studyForm));
calendar.setAcademicYear(year);
calendar.setSpeciality(speciality);
calendar.setSpecialtyProfile(profile);
calendar.setStudyForm(studyForm);
calendar.setCourseCount(request.courseCount());
}
private void replaceCalendarSubjects(AcademicCalendar calendar, List<AcademicCalendarSubjectDto> rows) {
if (rows.size() > 1000) {
throw new IllegalArgumentException("Слишком много дисциплин для одного графика");
@@ -254,15 +251,19 @@ public class AcademicCalendarController {
);
}
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarDay day) {
AcademicCalendarActivityType activityType = day.getActivityType();
private AcademicCalendarGridDayDto toGridDayDto(AcademicCalendarPeriod period, LocalDate date) {
AcademicCalendarActivityType activityType = period.getActivityType();
AcademicYear academicYear = period.getAcademicCalendar().getAcademicYear();
long daysFromYearStart = ChronoUnit.DAYS.between(academicYear.getStartDate(), date);
int daysBeforeYearStartInFirstWeek = academicYear.getStartDate().getDayOfWeek().getValue() - 1;
int weekNumber = Math.toIntExact((daysBeforeYearStartInFirstWeek + daysFromYearStart) / 7 + 1);
return new AcademicCalendarGridDayDto(
day.getId(),
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate(),
day.getWeekNumber(),
day.getDayOfWeek(),
period.getId(),
period.getAcademicCalendar().getId(),
period.getCourseNumber(),
date,
weekNumber,
date.getDayOfWeek().getValue(),
activityType.getId(),
activityType.getCode(),
activityType.getName(),

View File

@@ -10,11 +10,15 @@ import com.magistr.app.model.Role;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.EquipmentRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Locale;
@RestController
@RequestMapping("/api/classrooms")
@@ -40,6 +44,26 @@ public class ClassroomController {
.toList();
}
@GetMapping("/options")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<ClassroomResponse> searchClassroomOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
List<ClassroomResponse> options = new ArrayList<>(classroomRepository.searchActiveOptions(
normalizedQuery,
LifecycleEntity.STATUS_ARCHIVED,
PageRequest.of(0, Math.max(1, Math.min(limit, 50)), Sort.by("name").ascending())
).stream()
.map(this::mapToResponse)
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
classroomRepository.findById(selectedId).map(this::mapToResponse).ifPresent(options::add);
}
return options;
}
@PostMapping
public ResponseEntity<?> createClassroom(@RequestBody ClassroomRequest request) {
if (request.getName() == null || request.getName().isBlank()) {

View File

@@ -34,9 +34,8 @@ final class DatabaseConstraintViolationMapper {
badRequest("chk_semesters_type", "Недопустимый тип семестра"),
badRequest("chk_semesters_dates", "Дата окончания семестра раньше даты начала"),
badRequest("chk_academic_calendars_course_count", "Количество курсов должно быть от 1 до 8"),
badRequest("chk_calendar_days_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_days_week_positive", "Номер недели должен быть положительным"),
badRequest("chk_calendar_days_day", "День недели должен быть от 1 до 7"),
badRequest("chk_calendar_periods_course_positive", "Номер курса должен быть положительным"),
badRequest("chk_calendar_periods_dates", "Дата окончания периода раньше даты начала"),
badRequest("chk_academic_calendar_subjects_semester_positive", "Номер семестра должен быть положительным"),
badRequest("chk_student_groups_group_size_positive", "Численность группы должна быть больше нуля"),
badRequest("chk_student_groups_year_start_positive", "Год начала обучения должен быть больше нуля"),
@@ -61,8 +60,10 @@ final class DatabaseConstraintViolationMapper {
conflict("uq_time_slot_scopes_weekday", "Сетка времени для этого дня недели уже существует"),
conflict("uq_time_slots_scope_order", "Пара с таким номером уже существует в выбранной сетке"),
conflict("uq_semesters_year_type", "Семестр такого типа уже существует в учебном году"),
conflict("uq_schedule_versions_number", "Версия с таким номером уже существует в семестре"),
conflict("uq_schedule_versions_published_semester", "Для семестра уже опубликована другая версия расписания"),
conflict("uq_academic_calendar_title", "Календарный график с таким названием уже существует"),
conflict("uq_calendar_days", "День календарного графика уже существует"),
conflict("ex_calendar_periods_no_overlap", "Периоды активности одного курса пересекаются"),
conflict("uq_academic_calendar_subject", "Дисциплина уже добавлена в этот семестр графика"),
conflict("uq_group_calendar_year", "Группе уже назначен календарный график на учебный год"),
conflict("uq_schedule_rule_slots_exact_payload", "Такой слот правила расписания уже существует"),

View File

@@ -11,6 +11,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.LinkedHashMap;
@@ -51,6 +52,19 @@ public class GlobalExceptionHandler {
return error(HttpStatus.NOT_FOUND, message, request);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleResponseStatus(
ResponseStatusException exception,
HttpServletRequest request
) {
HttpStatus status = HttpStatus.resolve(exception.getStatusCode().value());
HttpStatus resolvedStatus = status == null ? HttpStatus.INTERNAL_SERVER_ERROR : status;
String message = exception.getReason() == null || exception.getReason().isBlank()
? "Операция недоступна"
: exception.getReason();
return error(resolvedStatus, message, request);
}
@ExceptionHandler({
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
@@ -100,6 +114,8 @@ public class GlobalExceptionHandler {
case BAD_REQUEST -> "Некорректный запрос";
case CONFLICT -> "Конфликт";
case NOT_FOUND -> "Не найдено";
case FORBIDDEN -> "Доступ запрещён";
case UNAUTHORIZED -> "Требуется вход";
case INTERNAL_SERVER_ERROR -> "Внутренняя ошибка сервера";
default -> status.name();
};

View File

@@ -1,10 +1,13 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.AcademicCalendarSubjectDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.dto.GroupResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.AcademicCalendarSubject;
import com.magistr.app.model.Role;
import com.magistr.app.model.Speciality;
@@ -24,12 +27,19 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -98,6 +108,118 @@ public class GroupController {
}
}
@GetMapping("/page")
public PageResponse<GroupResponse> getGroupsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) List<Long> educationFormId,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Integer yearStartStudy,
@RequestParam(defaultValue = "true") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(defaultValue = "asc") String direction) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
Specification<StudentGroup> specification = groupSpecification(
query, educationFormId, effectiveDepartmentId, yearStartStudy, includeArchived
);
Page<StudentGroup> groups = groupRepository.findAll(
specification,
PageRequest.of(normalizePage(page), normalizeSize(size), groupSort(sort, direction))
);
return PageResponse.from(groups.map(this::mapToResponse));
}
@GetMapping("/options")
public List<GroupResponse> searchGroupOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
List<GroupResponse> options = new ArrayList<>(groupRepository.findAll(
groupSpecification(query, null, effectiveDepartmentId, null, false),
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("name").ascending())
).stream()
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.map(this::mapToResponse)
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
groupRepository.findById(selectedId)
.filter(group -> effectiveDepartmentId == null
|| Objects.equals(group.getDepartmentId(), effectiveDepartmentId))
.filter(group -> groupLifecycleService.isAvailableForSelection(group, businessTime.today()))
.map(this::mapToResponse)
.ifPresent(options::add);
}
return options;
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: requestedDepartmentId;
}
private Specification<StudentGroup> groupSpecification(String query,
List<Long> educationFormIds,
Long departmentId,
Integer yearStartStudy,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (educationFormIds != null && !educationFormIds.isEmpty()) {
predicates.add(root.get("educationForm").get("id").in(educationFormIds));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
if (yearStartStudy != null) {
predicates.add(builder.equal(root.get("yearStartStudy"), yearStartStudy));
}
String normalizedQuery = normalizeQuery(query);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("name")), pattern),
builder.like(builder.lower(root.get("speciality").get("specialityCode")), pattern),
builder.like(builder.lower(root.get("speciality").get("specialityName")), pattern),
builder.like(builder.lower(root.get("specialtyProfile").get("name")), pattern),
builder.like(builder.lower(root.get("educationForm").get("name")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort groupSort(String field, String direction) {
String property = switch (field) {
case "yearStartStudy" -> "yearStartStudy";
case "groupSize" -> "groupSize";
case "status" -> "status";
default -> "name";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private int normalizePage(int page) {
return Math.max(page, 0);
}
private int normalizeSize(int size) {
return Math.max(1, Math.min(size, 100));
}
private String normalizeQuery(String query) {
return query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
}
@GetMapping("/{departmentId}")
public ResponseEntity<?> getGroupsByDepartmentId(@PathVariable Long departmentId) {
logger.info("Получен запрос на получение списка групп для кафедры с ID - {}", departmentId);

View File

@@ -0,0 +1,44 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
import com.magistr.app.dto.ScheduleQualityRecommendationsDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleQualityService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/edu-office/schedule/quality")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleQualityController {
private final ScheduleQualityService scheduleQualityService;
public ScheduleQualityController(ScheduleQualityService scheduleQualityService) {
this.scheduleQualityService = scheduleQualityService;
}
@GetMapping
public ScheduleQualityAnalysisDto analyze(
@RequestParam Long semesterId,
@RequestParam(name = "versionId", required = false) Long versionId
) {
return scheduleQualityService.analyze(semesterId, versionId);
}
@GetMapping("/recommendations")
public ScheduleQualityRecommendationsDto recommendations(
@RequestParam Long semesterId,
@RequestParam(name = "versionId", required = false) Long versionId,
@RequestParam Long scheduleRuleSlotId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate lessonDate
) {
return scheduleQualityService.recommendations(semesterId, versionId, scheduleRuleSlotId, lessonDate);
}
}

View File

@@ -2,6 +2,7 @@ package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleRuleConflictException;
import com.magistr.app.service.ScheduleRuleService;
@@ -34,8 +35,18 @@ public class ScheduleRuleAdminController {
@GetMapping
public List<ScheduleRuleDto> getAll(@RequestParam(name = "semesterId", required = false) Long semesterId,
@RequestParam(name = "groupId", required = false) Long groupId) {
return scheduleRuleService.getAll(semesterId, groupId);
@RequestParam(name = "groupId", required = false) Long groupId,
@RequestParam(name = "versionId", required = false) Long 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}")
@@ -46,6 +57,7 @@ public class ScheduleRuleAdminController {
@PostMapping
public ResponseEntity<?> create(@RequestBody ScheduleRuleDto request) {
try {
requireScheduleVersion(request);
return ResponseEntity.ok(scheduleRuleService.create(request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
@@ -55,6 +67,7 @@ public class ScheduleRuleAdminController {
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody ScheduleRuleDto request) {
try {
requireScheduleVersion(request);
return ResponseEntity.ok(scheduleRuleService.update(id, request));
} catch (ScheduleRuleConflictException exception) {
return conflictResponse(exception);
@@ -77,4 +90,10 @@ public class ScheduleRuleAdminController {
body.put("conflictReasons", exception.getConflictReasons());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
private void requireScheduleVersion(ScheduleRuleDto request) {
if (request == null || request.scheduleVersionId() == null) {
throw new IllegalArgumentException("Выберите версию расписания");
}
}
}

View File

@@ -0,0 +1,82 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ScheduleVersionActionDto;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.dto.ScheduleVersionDiffDto;
import com.magistr.app.dto.ScheduleVersionDto;
import com.magistr.app.dto.ScheduleVersionHistoryDto;
import com.magistr.app.dto.ScheduleVersionValidationDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.ScheduleVersionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/edu-office/schedule/versions")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE})
public class ScheduleVersionController {
private final ScheduleVersionService service;
public ScheduleVersionController(ScheduleVersionService service) {
this.service = service;
}
@GetMapping
public List<ScheduleVersionDto> getVersions(@RequestParam("semesterId") Long semesterId) {
return service.getVersions(semesterId);
}
@GetMapping("/{id}")
public ScheduleVersionDto getVersion(@PathVariable("id") Long id) {
return service.getVersion(id);
}
@PostMapping
public ResponseEntity<ScheduleVersionDto> createDraft(@RequestBody ScheduleVersionCreateDto request) {
return ResponseEntity.ok(service.createDraft(request));
}
@GetMapping("/{id}/validate")
public ScheduleVersionValidationDto validate(@PathVariable("id") Long id) {
return service.validate(id);
}
@GetMapping("/{id}/diff")
public ScheduleVersionDiffDto diff(
@PathVariable("id") Long id,
@RequestParam(name = "baseVersionId", required = false) Long baseVersionId
) {
return service.diff(id, baseVersionId);
}
@PostMapping("/{id}/publish")
public ScheduleVersionDto publish(
@PathVariable("id") Long id,
@RequestBody ScheduleVersionActionDto request
) {
return service.publish(id, request == null ? null : request.reason());
}
@PostMapping("/{id}/restore")
public ScheduleVersionDto restore(
@PathVariable("id") Long id,
@RequestBody ScheduleVersionActionDto request
) {
return service.restore(id, request == null ? null : request.reason());
}
@GetMapping("/history")
public List<ScheduleVersionHistoryDto> history(@RequestParam("semesterId") Long semesterId) {
return service.getHistory(semesterId);
}
}

View File

@@ -12,10 +12,13 @@ import com.magistr.app.service.ScheduleGeneratorService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.STUDENT})
public class SubgroupController {
@@ -36,8 +39,16 @@ public class SubgroupController {
}
@GetMapping("/api/subgroups")
public List<SubgroupDto> getAll() {
return subgroupRepository.findAllWithGroupsOrderByGroupNameAndName().stream()
public List<SubgroupDto> getAll(@RequestParam(required = false) List<Long> groupId) {
if (groupId != null && groupId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить подгруппы не более чем для 100 групп");
}
List<Subgroup> selected = groupId == null
? subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()
: groupId.isEmpty()
? List.of()
: subgroupRepository.findByStudentGroupIdInOrderByGroupNameAndName(groupId.stream().distinct().toList());
return selected.stream()
.map(this::toDto)
.toList();
}

View File

@@ -1,8 +1,10 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.CreateSubjectRequest;
import com.magistr.app.dto.SubjectResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.Subject;
@@ -11,9 +13,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -53,6 +60,74 @@ public class SubjectController {
}
}
@GetMapping("/page")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public PageResponse<SubjectResponse> getSubjectsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "name") String sort,
@RequestParam(defaultValue = "asc") String direction) {
Long effectiveDepartmentId = effectiveDepartmentId(departmentId);
var subjects = subjectRepository.findAll(
subjectSpecification(query, effectiveDepartmentId, includeArchived),
PageRequest.of(
Math.max(page, 0),
Math.max(1, Math.min(size, 100)),
subjectSort(sort, direction)
)
);
return PageResponse.from(subjects.map(this::toResponse));
}
private Long effectiveDepartmentId(Long requestedDepartmentId) {
var currentUser = AuthContext.getCurrentUser();
return currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: requestedDepartmentId;
}
private Specification<Subject> subjectSpecification(String query,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("name")), pattern),
builder.like(builder.lower(root.get("code")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort subjectSort(String field, String direction) {
String property = switch (field) {
case "code" -> "code";
case "departmentId" -> "departmentId";
case "status" -> "status";
default -> "name";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private SubjectResponse toResponse(Subject subject) {
return new SubjectResponse(subject.getId(), subject.getName(), subject.getCode(), subject.getDepartmentId());
}
@GetMapping("/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getSubjectsByDepartmentId(@PathVariable Long departmentId) {

View File

@@ -0,0 +1,68 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherAbsenceDto;
import com.magistr.app.dto.TeacherAbsenceRequest;
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
import com.magistr.app.dto.TeacherAbsenceReviewRequest;
import com.magistr.app.dto.TeacherAbsenceWizardDto;
import com.magistr.app.model.Role;
import com.magistr.app.service.TeacherAbsenceService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/teacher-absences")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
public class TeacherAbsenceController {
private final TeacherAbsenceService absenceService;
public TeacherAbsenceController(TeacherAbsenceService absenceService) {
this.absenceService = absenceService;
}
@GetMapping
public List<TeacherAbsenceDto> getAll(@RequestParam(required = false) String status) {
return absenceService.getAll(status);
}
@PostMapping
public TeacherAbsenceDto create(@RequestBody TeacherAbsenceRequest request) {
return absenceService.create(request);
}
@PostMapping("/{id}/review")
public TeacherAbsenceDto review(
@PathVariable Long id,
@RequestBody TeacherAbsenceReviewRequest request
) {
return absenceService.review(id, request);
}
@GetMapping("/{id}/wizard")
public TeacherAbsenceWizardDto getWizard(@PathVariable Long id) {
return absenceService.getWizard(id);
}
@PostMapping("/{id}/resolve")
public TeacherAbsenceWizardDto resolve(
@PathVariable Long id,
@RequestBody TeacherAbsenceResolutionRequest request
) {
return absenceService.resolve(id, request);
}
@DeleteMapping("/{id}")
public TeacherAbsenceDto cancel(@PathVariable Long id) {
return absenceService.cancel(id);
}
}

View File

@@ -0,0 +1,64 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherChangeCandidatesDto;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.dto.TeacherChangeRequestPayload;
import com.magistr.app.dto.TeacherChangeReviewRequest;
import com.magistr.app.model.Role;
import com.magistr.app.service.TeacherChangeRequestService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/teacher-change-requests")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
public class TeacherChangeRequestController {
private final TeacherChangeRequestService requestService;
public TeacherChangeRequestController(TeacherChangeRequestService requestService) {
this.requestService = requestService;
}
@GetMapping
public List<TeacherChangeRequestDto> getAll(@RequestParam(required = false) String status) {
return requestService.getAll(status);
}
@GetMapping("/candidates")
public TeacherChangeCandidatesDto getCandidates(
@RequestParam Long baseRuleSlotId,
@RequestParam LocalDate lessonDate,
@RequestParam(required = false) LocalDate targetDate
) {
return requestService.getCandidates(baseRuleSlotId, lessonDate, targetDate);
}
@PostMapping
public TeacherChangeRequestDto create(@RequestBody TeacherChangeRequestPayload request) {
return requestService.create(request);
}
@PostMapping("/{id}/review")
public TeacherChangeRequestDto review(
@PathVariable Long id,
@RequestBody TeacherChangeReviewRequest request
) {
return requestService.review(id, request);
}
@DeleteMapping("/{id}")
public TeacherChangeRequestDto cancel(@PathVariable Long id) {
return requestService.cancel(id);
}
}

View File

@@ -5,6 +5,7 @@ import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.ReviewTeacherCreationRequest;
import com.magistr.app.dto.TeacherCreationRequestResponse;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.*;
import com.magistr.app.repository.DepartmentRepository;
import com.magistr.app.repository.TeacherCreationRequestRepository;
@@ -12,11 +13,16 @@ import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.UserRepository;
import com.magistr.app.service.BusinessTimeService;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -55,6 +61,63 @@ public class TeacherCreationRequestController {
.toList();
}
@GetMapping("/page")
public PageResponse<TeacherCreationRequestResponse> getRequestsPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) TeacherCreationRequestStatus status,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String direction) {
var requests = teacherCreationRequestRepository.findAll(
requestSpecification(query, status, departmentId),
PageRequest.of(
Math.max(page, 0),
Math.max(1, Math.min(size, 100)),
requestSort(sort, direction)
)
);
return PageResponse.from(requests.map(this::toResponse));
}
private Specification<TeacherCreationRequest> requestSpecification(String query,
TeacherCreationRequestStatus status,
Long departmentId) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (status != null) {
predicates.add(builder.equal(root.get("status"), status));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("department").get("id"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("username")), pattern),
builder.like(builder.lower(root.get("fullName")), pattern),
builder.like(builder.lower(root.get("jobTitle")), pattern),
builder.like(builder.lower(root.get("department").get("departmentName")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort requestSort(String field, String direction) {
String property = switch (field) {
case "fullName" -> "fullName";
case "username" -> "username";
case "status" -> "status";
default -> "createdAt";
};
return "asc".equalsIgnoreCase(direction)
? Sort.by(property).ascending()
: Sort.by(property).descending();
}
@PostMapping("/{id}/approve")
@Transactional
public ResponseEntity<?> approve(@PathVariable Long id, @RequestBody ReviewTeacherCreationRequest review) {

View File

@@ -0,0 +1,62 @@
package com.magistr.app.controller;
import com.magistr.app.config.auth.RequireRoles;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.dto.TeacherPreferenceMetaDto;
import com.magistr.app.dto.TeacherPreferenceRequest;
import com.magistr.app.dto.TeacherPreferenceReviewRequest;
import com.magistr.app.model.Role;
import com.magistr.app.service.TeacherPreferenceService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/teacher-preferences")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.TEACHER})
public class TeacherPreferenceController {
private final TeacherPreferenceService preferenceService;
public TeacherPreferenceController(TeacherPreferenceService preferenceService) {
this.preferenceService = preferenceService;
}
@GetMapping("/meta")
public TeacherPreferenceMetaDto getMeta() {
return preferenceService.getMeta();
}
@GetMapping
public List<TeacherPreferenceDto> getAll(
@RequestParam(required = false) Long semesterId,
@RequestParam(required = false) String status
) {
return preferenceService.getAll(semesterId, status);
}
@PostMapping
public TeacherPreferenceDto create(@RequestBody TeacherPreferenceRequest request) {
return preferenceService.create(request);
}
@PostMapping("/{id}/review")
public TeacherPreferenceDto review(
@PathVariable Long id,
@RequestBody TeacherPreferenceReviewRequest request
) {
return preferenceService.review(id, request);
}
@DeleteMapping("/{id}")
public TeacherPreferenceDto cancel(@PathVariable Long id) {
return preferenceService.cancel(id);
}
}

View File

@@ -16,10 +16,13 @@ import com.magistr.app.service.BusinessTimeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController
@RequestMapping("/api/teacher-subjects")
@RequireRoles({Role.ADMIN, Role.DEPARTMENT})
@@ -53,15 +56,18 @@ public class TeacherSubjectController {
}
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
public List<TeacherSubjectResponse> getAll(@RequestParam(required = false) List<Long> subjectId) {
if (subjectId != null && subjectId.size() > 100) {
throw new ResponseStatusException(BAD_REQUEST, "За один запрос можно получить привязки не более чем для 100 дисциплин");
}
List<TeacherSubject> assignments = subjectId == null
? teacherSubjectRepository.findAll()
: subjectId.isEmpty()
? List.of()
: teacherSubjectRepository.findBySubjectIdInWithDetails(subjectId.stream().distinct().toList());
return assignments.stream()
.filter(ts -> canManage(ts.getUser(), ts.getSubject()))
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
ts.getSubjectId(),
ts.getSubject().getName()
))
.map(this::toResponse)
.toList();
}
@@ -136,4 +142,14 @@ public class TeacherSubjectController {
)
&& departmentId.equals(subject.getDepartmentId());
}
private TeacherSubjectResponse toResponse(TeacherSubject teacherSubject) {
return new TeacherSubjectResponse(
teacherSubject.getUserId(),
teacherSubject.getUser().getUsername(),
teacherSubject.getUser().getFullName(),
teacherSubject.getSubjectId(),
teacherSubject.getSubject().getName()
);
}
}

View File

@@ -6,6 +6,7 @@ import com.magistr.app.dto.CreateUserRequest;
import com.magistr.app.dto.DepartmentTransferRequest;
import com.magistr.app.dto.TeacherDepartmentAssignmentDto;
import com.magistr.app.dto.UserResponse;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.model.Department;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
@@ -22,11 +23,16 @@ import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@@ -80,6 +86,23 @@ public class UserController {
return response;
}
@GetMapping("/page")
public PageResponse<UserResponse> getUsersPage(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Role role,
@RequestParam(required = false) Long departmentId,
@RequestParam(defaultValue = "true") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
@RequestParam(defaultValue = "fullName") String sort,
@RequestParam(defaultValue = "asc") String direction) {
var users = userRepository.findAll(
userSpecification(query, role, departmentId, includeArchived),
PageRequest.of(normalizePage(page), normalizeSize(size), userSort(sort, direction))
);
return PageResponse.from(users.map(this::toUserResponse));
}
@GetMapping("/teachers")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> getTeachers() {
@@ -91,6 +114,92 @@ public class UserController {
return response;
}
@GetMapping("/teachers/options")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public List<UserResponse> searchTeacherOptions(
@RequestParam(defaultValue = "") String query,
@RequestParam(required = false) Long departmentId,
@RequestParam(required = false) Long selectedId,
@RequestParam(defaultValue = "20") int limit) {
var currentUser = AuthContext.getCurrentUser();
Long effectiveDepartmentId = currentUser != null && currentUser.role() == Role.DEPARTMENT
? currentUser.departmentId()
: departmentId;
LocalDate today = businessTime.today();
List<UserResponse> options = new ArrayList<>(userRepository.searchTeacherOptions(
query == null ? "" : query.trim().toLowerCase(Locale.ROOT),
Role.TEACHER,
LifecycleEntity.STATUS_ARCHIVED,
effectiveDepartmentId,
today,
PageRequest.of(0, Math.min(normalizeSize(limit), 50), Sort.by("fullName").ascending())
).stream()
.map(user -> effectiveDepartmentId == null
? toUserResponse(user)
: toUserResponse(user, effectiveDepartmentId))
.toList());
if (selectedId != null && options.stream().noneMatch(item -> selectedId.equals(item.getId()))) {
userRepository.findById(selectedId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(user -> !user.isArchivedRecord())
.filter(user -> effectiveDepartmentId == null
|| teacherDepartmentService.hasAssignmentAtDate(user.getId(), effectiveDepartmentId, today))
.map(user -> effectiveDepartmentId == null
? toUserResponse(user)
: toUserResponse(user, effectiveDepartmentId))
.ifPresent(options::add);
}
return options;
}
private Specification<User> userSpecification(String query,
Role role,
Long departmentId,
boolean includeArchived) {
return (root, criteriaQuery, builder) -> {
List<jakarta.persistence.criteria.Predicate> predicates = new ArrayList<>();
if (!includeArchived) {
predicates.add(builder.notEqual(root.get("status"), LifecycleEntity.STATUS_ARCHIVED));
}
if (role != null) {
predicates.add(builder.equal(root.get("role"), role));
}
if (departmentId != null) {
predicates.add(builder.equal(root.get("departmentId"), departmentId));
}
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(Locale.ROOT);
if (!normalizedQuery.isEmpty()) {
String pattern = "%" + normalizedQuery + "%";
predicates.add(builder.or(
builder.like(builder.lower(root.get("username")), pattern),
builder.like(builder.lower(root.get("fullName")), pattern),
builder.like(builder.lower(root.get("jobTitle")), pattern)
));
}
return builder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new));
};
}
private Sort userSort(String field, String direction) {
String property = switch (field) {
case "username" -> "username";
case "role" -> "role";
case "status" -> "status";
default -> "fullName";
};
return "desc".equalsIgnoreCase(direction)
? Sort.by(property).descending()
: Sort.by(property).ascending();
}
private int normalizePage(int page) {
return Math.max(page, 0);
}
private int normalizeSize(int size) {
return Math.max(1, Math.min(size, 100));
}
@GetMapping("/teachers/{departmentId}")
@RequireRoles({Role.ADMIN, Role.EDUCATION_OFFICE, Role.DEPARTMENT, Role.SCHEDULE_VIEWER})
public ResponseEntity<?> getTeachersByDepartmentId(@PathVariable Long departmentId){

View File

@@ -53,7 +53,7 @@ public class WorkloadController {
List<RenderedLessonDto> lessons = scheduleQueryService.searchForAggregation(
effectiveDepartmentId, null, startDate, endDate
);
return summarizeTeacherWorkload(lessons, startDate, endDate);
return summarizeTeacherWorkload(lessons, startDate, endDate, effectiveDepartmentId);
}
@GetMapping("/classrooms")
@@ -98,15 +98,19 @@ public class WorkloadController {
private List<WorkloadSummaryDto> summarizeTeacherWorkload(List<RenderedLessonDto> lessons,
LocalDate startDate,
LocalDate endDate) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(lessons, startDate, endDate);
LocalDate endDate,
Long scopedDepartmentId) {
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher = scopedDepartmentId == null
? assignmentsByTeacher(lessons, startDate, endDate)
: Map.of();
Map<TeacherDepartmentKey, List<RenderedLessonDto>> grouped = lessons.stream()
.filter(lesson -> lesson.teacherId() != null)
.collect(Collectors.groupingBy(
lesson -> new TeacherDepartmentKey(
lesson.teacherId(),
departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
scopedDepartmentId != null
? scopedDepartmentId
: departmentIdAtLesson(lesson, assignmentsByTeacher).orElse(null)
),
LinkedHashMap::new,
Collectors.toList()

View File

@@ -0,0 +1,27 @@
package com.magistr.app.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageResponse<T>(
List<T> items,
int page,
int size,
long totalItems,
int totalPages,
boolean first,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}

View File

@@ -0,0 +1,56 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
import com.magistr.app.model.ScheduleVersionStatus;
public record ScheduleQualityAnalysisDto(
Long semesterId,
String semesterLabel,
LocalDate startDate,
LocalDate endDate,
int score,
int rawPenalty,
int lessonCount,
int totalProblemCount,
boolean problemsTruncated,
List<Metric> metrics,
List<Problem> problems,
Long scheduleVersionId,
Integer scheduleVersionNumber,
String scheduleVersionName,
ScheduleVersionStatus scheduleVersionStatus
) {
public record Metric(
String code,
String label,
double value,
String unit,
int penalty,
String explanation
) {
}
public record Problem(
String id,
String type,
String severity,
String title,
String description,
int penalty,
Long scheduleRuleId,
int affectedLessonCount,
List<String> problemTypes,
List<String> issueLabels,
Long scheduleRuleSlotId,
LocalDate lessonDate,
Integer timeSlotOrder,
String subjectName,
Long entityId,
String entityName,
Integer requiredCapacity,
Integer classroomCapacity,
boolean optimizable
) {
}
}

View File

@@ -0,0 +1,26 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record ScheduleQualityRecommendationsDto(
Long scheduleRuleSlotId,
LocalDate lessonDate,
String subjectName,
int baselineScore,
List<Candidate> candidates
) {
public record Candidate(
String id,
String action,
String label,
String description,
int scoreDelta,
int penaltyDelta,
int resultingScore,
List<String> improvements,
List<String> tradeoffs,
ScheduleOverrideDto override
) {
}
}

View File

@@ -19,6 +19,7 @@ public record ScheduleRuleDto(
Integer practiceStartWeek,
List<Long> groupIds,
List<String> groupNames,
List<ScheduleRuleSlotDto> slots
List<ScheduleRuleSlotDto> slots,
Long scheduleVersionId
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record ScheduleVersionActionDto(String reason) {
}

View File

@@ -0,0 +1,8 @@
package com.magistr.app.dto;
public record ScheduleVersionCreateDto(
Long semesterId,
String name,
Long basedOnVersionId
) {
}

View File

@@ -0,0 +1,30 @@
package com.magistr.app.dto;
import java.util.List;
public record ScheduleVersionDiffDto(
Long baseVersionId,
String baseVersionLabel,
Long targetVersionId,
String targetVersionLabel,
int addedRuleCount,
int removedRuleCount,
int changedRuleCount,
int unchangedRuleCount,
int addedLessonCount,
int removedLessonCount,
int changedLessonCount,
List<RuleChange> ruleChanges
) {
public record RuleChange(
String type,
Long versionGroupId,
Long beforeRuleId,
Long afterRuleId,
String subjectName,
List<String> groupNames,
String beforeSummary,
String afterSummary
) {
}
}

View File

@@ -0,0 +1,27 @@
package com.magistr.app.dto;
import com.magistr.app.model.ScheduleVersionStatus;
import java.time.Instant;
public record ScheduleVersionDto(
Long id,
Long semesterId,
String semesterLabel,
Integer versionNumber,
String name,
ScheduleVersionStatus status,
Long basedOnVersionId,
Long restoredFromVersionId,
String changeReason,
Long createdById,
String createdByName,
Instant createdAt,
Long publishedById,
String publishedByName,
Instant publishedAt,
Instant archivedAt,
long ruleCount,
long slotCount
) {
}

View File

@@ -0,0 +1,16 @@
package com.magistr.app.dto;
import java.time.Instant;
public record ScheduleVersionHistoryDto(
Long id,
Long versionId,
Integer versionNumber,
String versionName,
String action,
Long actorId,
String actorName,
String reason,
Instant createdAt
) {
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.dto;
import java.util.List;
public record ScheduleVersionValidationDto(
Long versionId,
boolean valid,
long ruleCount,
long slotCount,
List<String> issues
) {
}

View File

@@ -0,0 +1,23 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
public record TeacherAbsenceDto(
Long id,
Long teacherId,
String teacherName,
Long teacherDepartmentId,
LocalDate startDate,
LocalDate endDate,
String reason,
String status,
Long requestedBy,
Long reviewedBy,
String reviewComment,
Instant reviewedAt,
Instant createdAt,
Instant updatedAt,
long decisionCount
) {
}

View File

@@ -0,0 +1,11 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherAbsenceRequest(
Long teacherId,
LocalDate startDate,
LocalDate endDate,
String reason
) {
}

View File

@@ -0,0 +1,20 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record TeacherAbsenceResolutionRequest(
List<Decision> decisions
) {
public record Decision(
Long baseRuleSlotId,
LocalDate lessonDate,
String resolution,
LocalDate targetLessonDate,
Long newTimeSlotId,
Long newClassroomId,
Long newTeacherId,
String comment
) {
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.dto;
public record TeacherAbsenceReviewRequest(
Boolean approved,
String comment
) {
}

View File

@@ -0,0 +1,59 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
public record TeacherAbsenceWizardDto(
TeacherAbsenceDto absence,
List<AffectedLesson> lessons,
List<DecisionLog> journal
) {
public record Candidate(Long id, String name, String description) {
}
public record MoveCandidate(
LocalDate targetDate,
Long timeSlotId,
Integer orderNumber,
LocalTime startTime,
LocalTime endTime,
String description
) {
}
public record AffectedLesson(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalTime startTime,
LocalTime endTime,
Long subjectId,
String subjectName,
Long lessonTypeId,
String lessonTypeName,
Long classroomId,
String classroomName,
List<String> groupNames,
Long existingOverrideId,
String decisionStatus,
List<Candidate> teacherCandidates,
List<MoveCandidate> moveCandidates,
List<Candidate> classroomCandidates,
boolean cancellationAvailable
) {
}
public record DecisionLog(
Long id,
Long baseRuleSlotId,
LocalDate lessonDate,
String resolution,
String status,
Long scheduleOverrideId,
String comment,
Long decidedBy,
Instant decidedAt
) {
}
}

View File

@@ -0,0 +1,23 @@
package com.magistr.app.dto;
import java.time.LocalDate;
import java.util.List;
public record TeacherChangeCandidatesDto(
RenderedLessonDto lesson,
Long semesterId,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
List<LocalDate> availableDates,
LocalDate checkedDate,
List<CandidateOption> timeSlots,
List<CandidateOption> classrooms
) {
public record CandidateOption(
Long id,
String label,
boolean available,
String conflictMessage
) {
}
}

View File

@@ -0,0 +1,50 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
public record TeacherChangeRequestDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
Long baseRuleSlotId,
LocalDate lessonDate,
String subjectName,
String lessonTypeName,
List<String> groupNames,
Long sourceTimeSlotId,
Integer sourceTimeSlotOrder,
LocalTime sourceStartTime,
LocalTime sourceEndTime,
Long sourceClassroomId,
String sourceClassroomName,
String requestType,
LocalDate targetLessonDate,
Long requestedTimeSlotId,
Integer requestedTimeSlotOrder,
LocalTime requestedStartTime,
LocalTime requestedEndTime,
Long requestedClassroomId,
String requestedClassroomName,
String reason,
String status,
Long reviewedBy,
String reviewComment,
Instant reviewedAt,
Long appliedOverrideId,
Instant createdAt,
Instant updatedAt,
List<HistoryItem> history
) {
public record HistoryItem(
Long id,
String status,
String comment,
Long actorId,
Instant createdAt
) {
}
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherChangeRequestPayload(
Long baseRuleSlotId,
LocalDate lessonDate,
String requestType,
LocalDate targetLessonDate,
Long requestedTimeSlotId,
Long requestedClassroomId,
String reason
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
}

View File

@@ -0,0 +1,34 @@
package com.magistr.app.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
public record TeacherPreferenceDto(
Long id,
Long teacherId,
String teacherName,
Long departmentId,
Long semesterId,
String academicYearTitle,
String semesterType,
LocalDate semesterStartDate,
LocalDate semesterEndDate,
String preferenceType,
Integer dayOfWeek,
String dayName,
Long timeSlotId,
Integer timeSlotOrder,
LocalTime startTime,
LocalTime endTime,
LocalDate preferenceDate,
String comment,
String status,
Long requestedBy,
Long reviewedBy,
String reviewComment,
Instant reviewedAt,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -0,0 +1,9 @@
package com.magistr.app.dto;
import java.util.List;
public record TeacherPreferenceMetaDto(
List<SemesterDto> semesters,
List<TimeSlotDto> timeSlots
) {
}

View File

@@ -0,0 +1,14 @@
package com.magistr.app.dto;
import java.time.LocalDate;
public record TeacherPreferenceRequest(
Long teacherId,
Long semesterId,
String preferenceType,
Integer dayOfWeek,
Long timeSlotId,
LocalDate preferenceDate,
String comment
) {
}

View File

@@ -0,0 +1,4 @@
package com.magistr.app.dto;
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
}

View File

@@ -4,12 +4,14 @@ public class TeacherSubjectResponse {
private Long userId;
private String username;
private String fullName;
private Long subjectId;
private String subjectName;
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
public TeacherSubjectResponse(Long userId, String username, String fullName, Long subjectId, String subjectName) {
this.userId = userId;
this.username = username;
this.fullName = fullName;
this.subjectId = subjectId;
this.subjectName = subjectName;
}
@@ -22,6 +24,10 @@ public class TeacherSubjectResponse {
return username;
}
public String getFullName() {
return fullName;
}
public Long getSubjectId() {
return subjectId;
}

View File

@@ -5,8 +5,8 @@ import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "academic_calendar_days")
public class AcademicCalendarDay {
@Table(name = "academic_calendar_periods")
public class AcademicCalendarPeriod {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -19,14 +19,11 @@ public class AcademicCalendarDay {
@Column(name = "course_number", nullable = false)
private Integer courseNumber;
@Column(nullable = false)
private LocalDate date;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "week_number", nullable = false)
private Integer weekNumber;
@Column(name = "day_of_week", nullable = false)
private Integer dayOfWeek;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@ManyToOne(optional = false)
@JoinColumn(name = "activity_type_id", nullable = false)
@@ -56,28 +53,20 @@ public class AcademicCalendarDay {
this.courseNumber = courseNumber;
}
public LocalDate getDate() {
return date;
public LocalDate getStartDate() {
return startDate;
}
public void setDate(LocalDate date) {
this.date = date;
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public Integer getWeekNumber() {
return weekNumber;
public LocalDate getEndDate() {
return endDate;
}
public void setWeekNumber(Integer weekNumber) {
this.weekNumber = weekNumber;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
public AcademicCalendarActivityType getActivityType() {

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
@@ -23,6 +24,23 @@ public class ScheduleRule {
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@ManyToOne(optional = false)
@JoinColumn(name = "schedule_version_id", nullable = false)
private ScheduleVersion scheduleVersion;
@Column(name = "version_group_id")
private Long versionGroupId;
@Column(name = "change_reason")
private String changeReason;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false, length = 20)
private String status = LifecycleEntity.STATUS_ACTIVE;
@@ -85,6 +103,42 @@ public class ScheduleRule {
this.semester = semester;
}
public ScheduleVersion getScheduleVersion() {
return scheduleVersion;
}
public void setScheduleVersion(ScheduleVersion scheduleVersion) {
this.scheduleVersion = scheduleVersion;
}
public Long getVersionGroupId() {
return versionGroupId;
}
public void setVersionGroupId(Long versionGroupId) {
this.versionGroupId = versionGroupId;
}
public String getChangeReason() {
return changeReason;
}
public void setChangeReason(String changeReason) {
this.changeReason = changeReason;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public Instant getCreatedAt() {
return createdAt;
}
public String getStatus() {
return status;
}

View File

@@ -0,0 +1,166 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "schedule_versions")
public class ScheduleVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(name = "version_number", nullable = false)
private Integer versionNumber;
@Column(nullable = false, length = 160)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ScheduleVersionStatus status = ScheduleVersionStatus.DRAFT;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "based_on_version_id")
private ScheduleVersion basedOnVersion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "restored_from_version_id")
private ScheduleVersion restoredFromVersion;
@Column(name = "change_reason")
private String changeReason;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "published_by")
private User publishedBy;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "archived_at")
private Instant archivedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Semester getSemester() {
return semester;
}
public void setSemester(Semester semester) {
this.semester = semester;
}
public Integer getVersionNumber() {
return versionNumber;
}
public void setVersionNumber(Integer versionNumber) {
this.versionNumber = versionNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ScheduleVersionStatus getStatus() {
return status;
}
public void setStatus(ScheduleVersionStatus status) {
this.status = status;
}
public ScheduleVersion getBasedOnVersion() {
return basedOnVersion;
}
public void setBasedOnVersion(ScheduleVersion basedOnVersion) {
this.basedOnVersion = basedOnVersion;
}
public ScheduleVersion getRestoredFromVersion() {
return restoredFromVersion;
}
public void setRestoredFromVersion(ScheduleVersion restoredFromVersion) {
this.restoredFromVersion = restoredFromVersion;
}
public String getChangeReason() {
return changeReason;
}
public void setChangeReason(String changeReason) {
this.changeReason = changeReason;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public Instant getCreatedAt() {
return createdAt;
}
public User getPublishedBy() {
return publishedBy;
}
public void setPublishedBy(User publishedBy) {
this.publishedBy = publishedBy;
}
public Instant getPublishedAt() {
return publishedAt;
}
public void setPublishedAt(Instant publishedAt) {
this.publishedAt = publishedAt;
}
public Instant getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(Instant archivedAt) {
this.archivedAt = archivedAt;
}
}

View File

@@ -0,0 +1,79 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "schedule_version_history")
public class ScheduleVersionHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "version_id", nullable = false)
private ScheduleVersion version;
@Column(nullable = false, length = 30)
private String action;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "actor_id")
private User actor;
@Column
private String reason;
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
public Long getId() {
return id;
}
public ScheduleVersion getVersion() {
return version;
}
public void setVersion(ScheduleVersion version) {
this.version = version;
}
public String getAction() {
return action;
}
public void setAction(String action) {
this.action = action;
}
public User getActor() {
return actor;
}
public void setActor(User actor) {
this.actor = actor;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,7 @@
package com.magistr.app.model;
public enum ScheduleVersionStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}

View File

@@ -0,0 +1,141 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_absences")
public class TeacherAbsence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@Column(nullable = false, columnDefinition = "TEXT")
private String reason;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "reviewed_at")
private Instant reviewedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() {
return id;
}
public User getTeacher() {
return teacher;
}
public void setTeacher(User teacher) {
this.teacher = teacher;
}
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Long getRequestedBy() {
return requestedBy;
}
public void setRequestedBy(Long requestedBy) {
this.requestedBy = requestedBy;
}
public Long getReviewedBy() {
return reviewedBy;
}
public void setReviewedBy(Long reviewedBy) {
this.reviewedBy = reviewedBy;
}
public String getReviewComment() {
return reviewComment;
}
public void setReviewComment(String reviewComment) {
this.reviewComment = reviewComment;
}
public Instant getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(Instant reviewedAt) {
this.reviewedAt = reviewedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,121 @@
package com.magistr.app.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_absence_decisions")
public class TeacherAbsenceDecision {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "absence_id", nullable = false)
private TeacherAbsence absence;
@ManyToOne(optional = false)
@JoinColumn(name = "base_rule_slot_id", nullable = false)
private ScheduleRuleSlot baseRuleSlot;
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(nullable = false, length = 30)
private String resolution;
@Column(name = "decision_status", nullable = false, length = 20)
private String decisionStatus;
@ManyToOne
@JoinColumn(name = "schedule_override_id")
private ScheduleOverride scheduleOverride;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "decided_by")
private Long decidedBy;
@Column(name = "decided_at", nullable = false)
private Instant decidedAt;
public Long getId() {
return id;
}
public TeacherAbsence getAbsence() {
return absence;
}
public void setAbsence(TeacherAbsence absence) {
this.absence = absence;
}
public ScheduleRuleSlot getBaseRuleSlot() {
return baseRuleSlot;
}
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) {
this.baseRuleSlot = baseRuleSlot;
}
public LocalDate getLessonDate() {
return lessonDate;
}
public void setLessonDate(LocalDate lessonDate) {
this.lessonDate = lessonDate;
}
public String getResolution() {
return resolution;
}
public void setResolution(String resolution) {
this.resolution = resolution;
}
public String getDecisionStatus() {
return decisionStatus;
}
public void setDecisionStatus(String decisionStatus) {
this.decisionStatus = decisionStatus;
}
public ScheduleOverride getScheduleOverride() {
return scheduleOverride;
}
public void setScheduleOverride(ScheduleOverride scheduleOverride) {
this.scheduleOverride = scheduleOverride;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Long getDecidedBy() {
return decidedBy;
}
public void setDecidedBy(Long decidedBy) {
this.decidedBy = decidedBy;
}
public Instant getDecidedAt() {
return decidedAt;
}
public void setDecidedAt(Instant decidedAt) {
this.decidedAt = decidedAt;
}
}

View File

@@ -0,0 +1,104 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_change_requests")
public class TeacherChangeRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "base_rule_slot_id", nullable = false)
private ScheduleRuleSlot baseRuleSlot;
@Column(name = "lesson_date", nullable = false)
private LocalDate lessonDate;
@Column(name = "request_type", nullable = false, length = 30)
private String requestType;
@Column(name = "target_lesson_date")
private LocalDate targetLessonDate;
@ManyToOne
@JoinColumn(name = "requested_time_slot_id")
private TimeSlot requestedTimeSlot;
@ManyToOne
@JoinColumn(name = "requested_classroom_id")
private Classroom requestedClassroom;
@Column(nullable = false, columnDefinition = "TEXT")
private String reason;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "reviewed_at")
private Instant reviewedAt;
@ManyToOne
@JoinColumn(name = "applied_override_id")
private ScheduleOverride appliedOverride;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() { return id; }
public User getTeacher() { return teacher; }
public void setTeacher(User teacher) { this.teacher = teacher; }
public ScheduleRuleSlot getBaseRuleSlot() { return baseRuleSlot; }
public void setBaseRuleSlot(ScheduleRuleSlot baseRuleSlot) { this.baseRuleSlot = baseRuleSlot; }
public LocalDate getLessonDate() { return lessonDate; }
public void setLessonDate(LocalDate lessonDate) { this.lessonDate = lessonDate; }
public String getRequestType() { return requestType; }
public void setRequestType(String requestType) { this.requestType = requestType; }
public LocalDate getTargetLessonDate() { return targetLessonDate; }
public void setTargetLessonDate(LocalDate targetLessonDate) { this.targetLessonDate = targetLessonDate; }
public TimeSlot getRequestedTimeSlot() { return requestedTimeSlot; }
public void setRequestedTimeSlot(TimeSlot requestedTimeSlot) { this.requestedTimeSlot = requestedTimeSlot; }
public Classroom getRequestedClassroom() { return requestedClassroom; }
public void setRequestedClassroom(Classroom requestedClassroom) { this.requestedClassroom = requestedClassroom; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }
public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
public ScheduleOverride getAppliedOverride() { return appliedOverride; }
public void setAppliedOverride(ScheduleOverride appliedOverride) { this.appliedOverride = appliedOverride; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,49 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "teacher_change_request_history")
public class TeacherChangeRequestHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "request_id", nullable = false)
private TeacherChangeRequest request;
@Column(nullable = false, length = 20)
private String status;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(name = "actor_id")
private Long actorId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public Long getId() { return id; }
public TeacherChangeRequest getRequest() { return request; }
public void setRequest(TeacherChangeRequest request) { this.request = request; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public Long getActorId() { return actorId; }
public void setActorId(Long actorId) { this.actorId = actorId; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,97 @@
package com.magistr.app.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
@Entity
@Table(name = "teacher_preferences")
public class TeacherPreference {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "teacher_id", nullable = false)
private User teacher;
@ManyToOne(optional = false)
@JoinColumn(name = "semester_id", nullable = false)
private Semester semester;
@Column(name = "preference_type", nullable = false, length = 30)
private String preferenceType;
@Column(name = "day_of_week")
private Integer dayOfWeek;
@ManyToOne
@JoinColumn(name = "time_slot_id")
private TimeSlot timeSlot;
@Column(name = "preference_date")
private LocalDate preferenceDate;
@Column(columnDefinition = "TEXT")
private String comment;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "requested_by")
private Long requestedBy;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "reviewed_at")
private Instant reviewedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() { return id; }
public User getTeacher() { return teacher; }
public void setTeacher(User teacher) { this.teacher = teacher; }
public Semester getSemester() { return semester; }
public void setSemester(Semester semester) { this.semester = semester; }
public String getPreferenceType() { return preferenceType; }
public void setPreferenceType(String preferenceType) { this.preferenceType = preferenceType; }
public Integer getDayOfWeek() { return dayOfWeek; }
public void setDayOfWeek(Integer dayOfWeek) { this.dayOfWeek = dayOfWeek; }
public TimeSlot getTimeSlot() { return timeSlot; }
public void setTimeSlot(TimeSlot timeSlot) { this.timeSlot = timeSlot; }
public LocalDate getPreferenceDate() { return preferenceDate; }
public void setPreferenceDate(LocalDate preferenceDate) { this.preferenceDate = preferenceDate; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getRequestedBy() { return requestedBy; }
public void setRequestedBy(Long requestedBy) { this.requestedBy = requestedBy; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public String getReviewComment() { return reviewComment; }
public void setReviewComment(String reviewComment) { this.reviewComment = reviewComment; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -1,71 +0,0 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendarDay;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface AcademicCalendarDayRepository extends JpaRepository<AcademicCalendarDay, Long> {
@Query("""
select day
from AcademicCalendarDay day
join fetch day.activityType
where day.academicCalendar.id = :calendarId
order by day.courseNumber asc, day.date asc
""")
List<AcademicCalendarDay> findByCalendarIdWithActivity(@Param("calendarId") Long calendarId);
@Query("""
select day
from AcademicCalendarDay day
join fetch day.activityType
where day.academicCalendar.id = :calendarId
and day.courseNumber = :courseNumber
and day.date = :date
""")
Optional<AcademicCalendarDay> findByCalendarIdAndCourseNumberAndDate(
@Param("calendarId") Long calendarId,
@Param("courseNumber") Integer courseNumber,
@Param("date") LocalDate date
);
@Query("""
select day
from AcademicCalendarDay day
join fetch day.academicCalendar
join fetch day.activityType
where day.academicCalendar.id in :calendarIds
and day.date between :startDate and :endDate
order by day.academicCalendar.id, day.courseNumber, day.date
""")
List<AcademicCalendarDay> findForScheduleBatch(
@Param("calendarIds") Collection<Long> calendarIds,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
boolean existsByActivityTypeId(Long activityTypeId);
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
@Query("""
select case when count(day) > 0 then true else false end
from AcademicCalendarDay day
where day.academicCalendar.id = :calendarId
and (day.date < :startDate or day.date > :endDate)
""")
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Modifying
@Query("delete from AcademicCalendarDay day where day.academicCalendar.id = :calendarId")
void deleteByCalendarId(@Param("calendarId") Long calendarId);
}

View File

@@ -0,0 +1,77 @@
package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendarPeriod;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface AcademicCalendarPeriodRepository extends JpaRepository<AcademicCalendarPeriod, Long> {
@Query("""
select period
from AcademicCalendarPeriod period
join fetch period.academicCalendar calendar
join fetch calendar.academicYear
join fetch period.activityType
where period.academicCalendar.id = :calendarId
order by period.courseNumber asc, period.startDate asc
""")
List<AcademicCalendarPeriod> findByCalendarIdWithActivity(@Param("calendarId") Long calendarId);
@Query("""
select period
from AcademicCalendarPeriod period
join fetch period.activityType
where period.academicCalendar.id = :calendarId
and period.courseNumber = :courseNumber
and period.startDate <= :date
and period.endDate >= :date
""")
Optional<AcademicCalendarPeriod> findByCalendarIdAndCourseNumberAndDate(
@Param("calendarId") Long calendarId,
@Param("courseNumber") Integer courseNumber,
@Param("date") LocalDate date
);
@Query("""
select period
from AcademicCalendarPeriod period
join fetch period.academicCalendar
join fetch period.activityType
where period.academicCalendar.id in :calendarIds
and period.courseNumber = :courseNumber
and period.endDate >= :startDate
and period.startDate <= :endDate
order by period.academicCalendar.id, period.startDate
""")
List<AcademicCalendarPeriod> findForScheduleBatch(
@Param("calendarIds") Collection<Long> calendarIds,
@Param("courseNumber") Integer courseNumber,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
boolean existsByActivityTypeId(Long activityTypeId);
boolean existsByAcademicCalendarIdAndCourseNumberGreaterThan(Long calendarId, Integer courseNumber);
@Query("""
select case when count(period) > 0 then true else false end
from AcademicCalendarPeriod period
where period.academicCalendar.id = :calendarId
and (period.startDate < :startDate or period.endDate > :endDate)
""")
boolean existsOutsideDateRange(@Param("calendarId") Long calendarId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Modifying
@Query("delete from AcademicCalendarPeriod period where period.academicCalendar.id = :calendarId")
void deleteByCalendarId(@Param("calendarId") Long calendarId);
}

View File

@@ -3,14 +3,17 @@ package com.magistr.app.repository;
import com.magistr.app.model.AcademicCalendar;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long> {
public interface AcademicCalendarRepository extends JpaRepository<AcademicCalendar, Long>, JpaSpecificationExecutor<AcademicCalendar> {
long countByStudyFormId(Long studyFormId);
@@ -54,4 +57,49 @@ public interface AcademicCalendarRepository extends JpaRepository<AcademicCalend
where calendar.id = :id
""")
Optional<AcademicCalendar> findByIdWithDetailsForUpdate(@Param("id") Long id);
@Query(value = """
select calendar
from AcademicCalendar calendar
join fetch calendar.academicYear academicYear
join fetch calendar.speciality speciality
join fetch calendar.specialtyProfile specialtyProfile
join fetch calendar.studyForm studyForm
where (:query = ''
or lower(calendar.title) like concat('%', :query, '%')
or lower(academicYear.title) like concat('%', :query, '%')
or lower(speciality.specialityCode) like concat('%', :query, '%')
or lower(speciality.specialityName) like concat('%', :query, '%')
or lower(specialtyProfile.name) like concat('%', :query, '%')
or lower(studyForm.name) like concat('%', :query, '%'))
and (:academicYearId is null or academicYear.id = :academicYearId)
and (:specialtyId is null or speciality.id = :specialtyId)
and (:profileId is null or specialtyProfile.id = :profileId)
and (:studyFormId is null or studyForm.id = :studyFormId)
""",
countQuery = """
select count(calendar)
from AcademicCalendar calendar
join calendar.academicYear academicYear
join calendar.speciality speciality
join calendar.specialtyProfile specialtyProfile
join calendar.studyForm studyForm
where (:query = ''
or lower(calendar.title) like concat('%', :query, '%')
or lower(academicYear.title) like concat('%', :query, '%')
or lower(speciality.specialityCode) like concat('%', :query, '%')
or lower(speciality.specialityName) like concat('%', :query, '%')
or lower(specialtyProfile.name) like concat('%', :query, '%')
or lower(studyForm.name) like concat('%', :query, '%'))
and (:academicYearId is null or academicYear.id = :academicYearId)
and (:specialtyId is null or speciality.id = :specialtyId)
and (:profileId is null or specialtyProfile.id = :profileId)
and (:studyFormId is null or studyForm.id = :studyFormId)
""")
Page<AcademicCalendar> searchOptions(@Param("query") String query,
@Param("academicYearId") Long academicYearId,
@Param("specialtyId") Long specialtyId,
@Param("profileId") Long profileId,
@Param("studyFormId") Long studyFormId,
Pageable pageable);
}

View File

@@ -2,12 +2,29 @@ package com.magistr.app.repository;
import com.magistr.app.model.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
import java.util.List;
public interface ClassroomRepository extends JpaRepository<Classroom, Long> {
public interface ClassroomRepository extends JpaRepository<Classroom, Long>, JpaSpecificationExecutor<Classroom> {
Optional<Classroom> findByName(String name);
List<Classroom> findByStatusNot(String status);
@Query("""
select classroom
from Classroom classroom
where classroom.status <> :archivedStatus
and (:query = ''
or lower(classroom.name) like concat('%', :query, '%')
or lower(coalesce(classroom.building, '')) like concat('%', :query, '%'))
""")
Page<Classroom> searchActiveOptions(@Param("query") String query,
@Param("archivedStatus") String archivedStatus,
Pageable pageable);
}

View File

@@ -3,6 +3,7 @@ package com.magistr.app.repository;
import com.magistr.app.model.StudentGroup;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -10,7 +11,7 @@ import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
public interface GroupRepository extends JpaRepository<StudentGroup, Long>, JpaSpecificationExecutor<StudentGroup> {
List<StudentGroup> findByEducationFormId(Long educationFormId);

View File

@@ -19,12 +19,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
join rule.scheduleVersion version
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
where (o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate)
and version.status = 'PUBLISHED'
""")
List<ScheduleOverride> findByLessonDateBetweenWithDetails(
@Param("startDate") LocalDate startDate,
@@ -38,6 +40,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.scheduleVersion version
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
@@ -48,6 +51,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where version.status = 'PUBLISHED'
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findAllWithRegistryDetails();
@@ -57,6 +61,7 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
join fetch o.baseRuleSlot baseSlot
join fetch baseSlot.scheduleRule rule
join fetch rule.scheduleVersion version
join fetch rule.subject
left join fetch rule.groups
join fetch rule.semester
@@ -67,8 +72,9 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate
where (o.lessonDate between :startDate and :endDate
or o.targetLessonDate between :startDate and :endDate)
and version.status = 'PUBLISHED'
order by o.lessonDate, o.id
""")
List<ScheduleOverride> findForRegistry(
@@ -81,12 +87,14 @@ public interface ScheduleOverrideRepository extends JpaRepository<ScheduleOverri
from ScheduleOverride o
left join fetch o.baseRuleSlot baseSlot
left join fetch baseSlot.scheduleRule rule
join rule.scheduleVersion version
left join fetch rule.semester
left join fetch o.newTimeSlot
left join fetch o.newClassroom
left join fetch o.newTeacher
where o.lessonDate in :dates
or o.targetLessonDate in :dates
where (o.lessonDate in :dates
or o.targetLessonDate in :dates)
and version.status = 'PUBLISHED'
""")
List<ScheduleOverride> findAffectingDatesWithDetails(@Param("dates") Collection<LocalDate> dates);

View File

@@ -3,6 +3,8 @@ package com.magistr.app.repository;
import com.magistr.app.model.ScheduleRule;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -13,13 +15,32 @@ import java.util.Collection;
public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long> {
// Сначала ограничиваем ID в БД, затем загружаем коллекции только выбранной страницы.
@Query("""
select r.id from ScheduleRule r
join r.scheduleVersion version
join r.subject subject
where r.status <> 'ARCHIVED'
and version.id = :versionId
and (:query = '' or str(r.id) = :query
or lower(subject.name) like concat('%', :query, '%')
or exists (select g.id from ScheduleRule matchingRule join matchingRule.groups g
where matchingRule.id = r.id and lower(g.name) like concat('%', :query, '%')))
order by r.id desc
""")
Page<Long> searchPageIds(
@Param("versionId") Long versionId, @Param("query") String query,
Pageable pageable);
@Query("""
select distinct r
from ScheduleRule r
join r.groups g
join r.scheduleVersion version
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
@@ -30,6 +51,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where g.id = :groupId
and sem.id = :semesterId
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdAndSemesterId(
@@ -41,6 +63,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.slots teacherSlot
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -54,6 +77,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id = :semesterId
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterId(
@@ -65,6 +89,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.groups matchingGroup
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -78,6 +103,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where matchingGroup.id in :groupIds
and sem.id in :semesterIds
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByGroupIdsAndSemesterIds(
@@ -89,6 +115,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
select distinct r
from ScheduleRule r
join r.slots teacherSlot
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -102,6 +129,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.lessonType
where teacherSlot.teacher.id = :teacherId
and sem.id in :semesterIds
and version.status = 'PUBLISHED'
and r.status <> 'ARCHIVED'
""")
List<ScheduleRule> findByTeacherIdAndSemesterIds(
@@ -112,6 +140,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Query("""
select distinct r
from ScheduleRule r
join r.scheduleVersion version
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
@@ -124,6 +153,7 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
left join fetch slots.classroom
left join fetch slots.lessonType
where r.status <> 'ARCHIVED'
and version.status = 'PUBLISHED'
order by r.id desc
""")
List<ScheduleRule> findAllWithDetails();
@@ -156,12 +186,38 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
@Param("excludeRuleId") Long excludeRuleId
);
@Query("""
select distinct rule
from ScheduleRule rule
left join fetch rule.subject
left join fetch rule.semester semester
left join fetch semester.academicYear
left join fetch rule.scheduleVersion version
left join fetch rule.groups groups
left join fetch rule.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 version.id = :versionId
and rule.status <> 'ARCHIVED'
and (:excludeRuleId is null or rule.id <> :excludeRuleId)
order by rule.id
""")
List<ScheduleRule> findActiveByVersionIdWithDetails(
@Param("versionId") Long versionId,
@Param("excludeRuleId") Long excludeRuleId
);
@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
@@ -173,4 +229,23 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
where r.id = :id
""")
Optional<ScheduleRule> findByIdWithDetails(@Param("id") Long id);
@Query("""
select distinct r
from ScheduleRule r
left join fetch r.subject
left join fetch r.semester sem
left join fetch sem.academicYear
left join fetch r.scheduleVersion
left join fetch r.groups groups
left join fetch r.slots slots
left join fetch slots.timeSlot
left join fetch slots.subgroups slotSubgroups
left join fetch slotSubgroups.studentGroup
left join fetch slots.teacher
left join fetch slots.classroom
left join fetch slots.lessonType
where r.id in :ids
""")
List<ScheduleRule> findPageWithDetails(@Param("ids") Collection<Long> ids);
}

View File

@@ -5,10 +5,23 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
public interface ScheduleRuleSlotRepository extends JpaRepository<ScheduleRuleSlot, Long> {
@Query("select case when count(slot) > 0 then true else false end from ScheduleRuleSlot slot join slot.subgroups subgroup where subgroup.id = :subgroupId")
boolean existsBySubgroupId(@Param("subgroupId") Long subgroupId);
boolean existsByTimeSlotId(Long timeSlotId);
@Query(value = """
SELECT EXISTS (
SELECT 1 FROM schedule_overrides WHERE base_rule_slot_id IN (:slotIds)
UNION ALL
SELECT 1 FROM teacher_change_requests WHERE base_rule_slot_id IN (:slotIds)
UNION ALL
SELECT 1 FROM teacher_absence_decisions WHERE base_rule_slot_id IN (:slotIds)
)
""", nativeQuery = true)
boolean hasProtectedReferences(@Param("slotIds") Collection<Long> slotIds);
}

View File

@@ -0,0 +1,21 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleVersionHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ScheduleVersionHistoryRepository extends JpaRepository<ScheduleVersionHistory, Long> {
@Query("""
select history
from ScheduleVersionHistory history
left join fetch history.version version
left join fetch history.actor
where version.semester.id = :semesterId
order by history.createdAt desc, history.id desc
""")
List<ScheduleVersionHistory> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
}

View File

@@ -0,0 +1,55 @@
package com.magistr.app.repository;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface ScheduleVersionRepository extends JpaRepository<ScheduleVersion, Long> {
@Query("""
select version
from ScheduleVersion version
left join fetch version.semester semester
left join fetch semester.academicYear
left join fetch version.createdBy
left join fetch version.publishedBy
left join fetch version.basedOnVersion
left join fetch version.restoredFromVersion
where semester.id = :semesterId
order by version.versionNumber desc
""")
List<ScheduleVersion> findBySemesterIdWithDetails(@Param("semesterId") Long semesterId);
@Query("""
select version
from ScheduleVersion version
left join fetch version.semester semester
left join fetch semester.academicYear
left join fetch version.createdBy
left join fetch version.publishedBy
left join fetch version.basedOnVersion
left join fetch version.restoredFromVersion
where version.id = :id
""")
Optional<ScheduleVersion> findByIdWithDetails(@Param("id") Long id);
Optional<ScheduleVersion> findFirstBySemesterIdAndStatus(Long semesterId, ScheduleVersionStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select version from ScheduleVersion version where version.id = :id")
Optional<ScheduleVersion> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select version from ScheduleVersion version where version.semester.id = :semesterId order by version.id")
List<ScheduleVersion> findBySemesterIdForUpdate(@Param("semesterId") Long semesterId);
@Query("select coalesce(max(version.versionNumber), 0) from ScheduleVersion version where version.semester.id = :semesterId")
int findMaxVersionNumber(@Param("semesterId") Long semesterId);
}

View File

@@ -35,6 +35,16 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
""")
List<Subgroup> findByStudentGroupIdOrderByNameAsc(@Param("groupId") Long groupId);
@Query("""
select subgroup
from Subgroup subgroup
join fetch subgroup.studentGroup studentGroup
where studentGroup.id in :groupIds
and subgroup.status <> 'ARCHIVED'
order by studentGroup.name, subgroup.name
""")
List<Subgroup> findByStudentGroupIdInOrderByGroupNameAndName(@Param("groupIds") List<Long> groupIds);
@Query("""
select subgroup
from Subgroup subgroup

View File

@@ -2,11 +2,12 @@ package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
public interface SubjectRepository extends JpaRepository<Subject, Long>, JpaSpecificationExecutor<Subject> {
Optional<Subject> findByNameIgnoreCase(String name);
List<Subject> findByDepartmentId(Long departmentId);

View File

@@ -0,0 +1,24 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherAbsenceDecision;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface TeacherAbsenceDecisionRepository extends JpaRepository<TeacherAbsenceDecision, Long> {
long countByAbsenceId(Long absenceId);
@Query("""
select decision
from TeacherAbsenceDecision decision
join fetch decision.baseRuleSlot baseSlot
left join fetch decision.scheduleOverride
where decision.absence.id = :absenceId
order by decision.lessonDate, decision.id
""")
List<TeacherAbsenceDecision> findByAbsenceIdWithDetails(@Param("absenceId") Long absenceId);
}

View File

@@ -0,0 +1,66 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherAbsence;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface TeacherAbsenceRepository extends JpaRepository<TeacherAbsence, Long> {
@Query("""
select absence
from TeacherAbsence absence
join fetch absence.teacher
order by absence.startDate desc, absence.id desc
""")
List<TeacherAbsence> findAllWithTeacher();
@Query("""
select absence
from TeacherAbsence absence
join fetch absence.teacher
where absence.id = :id
""")
Optional<TeacherAbsence> findByIdWithTeacher(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select absence
from TeacherAbsence absence
join fetch absence.teacher
where absence.id = :id
""")
Optional<TeacherAbsence> findByIdForUpdate(@Param("id") Long id);
@Query("""
select count(absence) > 0
from TeacherAbsence absence
where absence.teacher.id = :teacherId
and absence.status in :statuses
and absence.startDate <= :endDate
and absence.endDate >= :startDate
""")
boolean existsOverlapping(
@Param("teacherId") Long teacherId,
@Param("statuses") Collection<String> statuses,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
@Query("""
select count(absence) > 0
from TeacherAbsence absence
where absence.teacher.id = :teacherId
and absence.status in ('APPROVED', 'RESOLVED')
and absence.startDate <= :date
and absence.endDate >= :date
""")
boolean isTeacherAbsentOn(@Param("teacherId") Long teacherId, @Param("date") LocalDate date);
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherChangeRequestHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherChangeRequestHistoryRepository extends JpaRepository<TeacherChangeRequestHistory, Long> {
List<TeacherChangeRequestHistory> findByRequestIdOrderByCreatedAtAscIdAsc(Long requestId);
}

View File

@@ -0,0 +1,54 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherChangeRequest;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface TeacherChangeRequestRepository extends JpaRepository<TeacherChangeRequest, Long> {
@Query("""
select request
from TeacherChangeRequest request
join fetch request.teacher
join fetch request.baseRuleSlot slot
join fetch slot.scheduleRule rule
join fetch rule.subject
join fetch slot.timeSlot
join fetch slot.classroom
left join fetch request.requestedTimeSlot
left join fetch request.requestedClassroom
left join fetch request.appliedOverride
order by request.createdAt desc, request.id desc
""")
List<TeacherChangeRequest> findAllWithDetails();
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select request
from TeacherChangeRequest request
join fetch request.teacher
join fetch request.baseRuleSlot slot
join fetch slot.scheduleRule rule
join fetch rule.subject
join fetch slot.timeSlot
join fetch slot.classroom
left join fetch request.requestedTimeSlot
left join fetch request.requestedClassroom
left join fetch request.appliedOverride
where request.id = :id
""")
Optional<TeacherChangeRequest> findByIdForUpdate(@Param("id") Long id);
boolean existsByBaseRuleSlotIdAndLessonDateAndStatus(
Long baseRuleSlotId,
LocalDate lessonDate,
String status
);
}

View File

@@ -3,10 +3,11 @@ package com.magistr.app.repository;
import com.magistr.app.model.TeacherCreationRequest;
import com.magistr.app.model.TeacherCreationRequestStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long> {
public interface TeacherCreationRequestRepository extends JpaRepository<TeacherCreationRequest, Long>, JpaSpecificationExecutor<TeacherCreationRequest> {
List<TeacherCreationRequest> findByDepartment_IdOrderByCreatedAtDesc(Long departmentId);

View File

@@ -0,0 +1,104 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherPreference;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface TeacherPreferenceRepository extends JpaRepository<TeacherPreference, Long> {
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
order by preference.createdAt desc, preference.id desc
""")
List<TeacherPreference> findAllWithDetails();
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.id = :id
""")
Optional<TeacherPreference> findByIdWithDetails(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
join fetch semester.academicYear
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.id = :id
""")
Optional<TeacherPreference> findByIdForUpdate(@Param("id") Long id);
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester semester
left join fetch preference.timeSlot slot
where preference.teacher.id = :teacherId
and preference.semester.id = :semesterId
and preference.status = 'APPROVED'
order by preference.preferenceType, preference.preferenceDate, preference.dayOfWeek, slot.orderNumber
""")
List<TeacherPreference> findApprovedForTeacherAndSemester(
@Param("teacherId") Long teacherId,
@Param("semesterId") Long semesterId
);
@Query("""
select preference
from TeacherPreference preference
join fetch preference.teacher
join fetch preference.semester
left join fetch preference.timeSlot slot
left join fetch slot.timeSlotScope
where preference.semester.id = :semesterId
and preference.status = 'APPROVED'
order by preference.teacher.id, preference.preferenceType,
preference.preferenceDate, preference.dayOfWeek, slot.orderNumber
""")
List<TeacherPreference> findApprovedForSemester(@Param("semesterId") Long semesterId);
@Query("""
select count(preference) > 0
from TeacherPreference preference
where preference.teacher.id = :teacherId
and preference.semester.id = :semesterId
and preference.preferenceType = :preferenceType
and preference.status in :statuses
and ((preference.dayOfWeek is null and :dayOfWeek is null) or preference.dayOfWeek = :dayOfWeek)
and ((preference.timeSlot is null and :timeSlotId is null) or preference.timeSlot.id = :timeSlotId)
and ((preference.preferenceDate is null and :preferenceDate is null) or preference.preferenceDate = :preferenceDate)
""")
boolean existsDuplicate(
@Param("teacherId") Long teacherId,
@Param("semesterId") Long semesterId,
@Param("preferenceType") String preferenceType,
@Param("dayOfWeek") Integer dayOfWeek,
@Param("timeSlotId") Long timeSlotId,
@Param("preferenceDate") LocalDate preferenceDate,
@Param("statuses") Collection<String> statuses
);
}

View File

@@ -3,10 +3,48 @@ package com.magistr.app.repository;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId);
@Query("""
select teacherSubject
from TeacherSubject teacherSubject
join fetch teacherSubject.user
join fetch teacherSubject.subject
where teacherSubject.subjectId in :subjectIds
order by teacherSubject.subject.name, teacherSubject.user.fullName
""")
List<TeacherSubject> findBySubjectIdInWithDetails(@Param("subjectIds") List<Long> subjectIds);
@Query(value = """
select subject_link.user_id
from teacher_subjects subject_link
where subject_link.subject_id = :subjectId
and (
not exists (
select 1
from teacher_lesson_types configured_type
where configured_type.user_id = subject_link.user_id
and configured_type.subject_id = subject_link.subject_id
)
or exists (
select 1
from teacher_lesson_types supported_type
where supported_type.user_id = subject_link.user_id
and supported_type.subject_id = subject_link.subject_id
and supported_type.lesson_type_id = :lessonTypeId
)
)
order by subject_link.user_id
""", nativeQuery = true)
List<Long> findQualifiedTeacherIds(
@Param("subjectId") Long subjectId,
@Param("lessonTypeId") Long lessonTypeId
);
}

View File

@@ -4,14 +4,18 @@ import com.magistr.app.model.Role;
import com.magistr.app.model.User;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
Optional<User> findByUsername(String username);
@@ -25,6 +29,49 @@ public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByRoleAndDepartmentIdAndStatusNot(Role role, Long departmentId, String status);
@Query(value = """
select user
from User user
where user.role = :role
and user.status <> :archivedStatus
and (:query = ''
or lower(user.username) like concat('%', :query, '%')
or lower(user.fullName) like concat('%', :query, '%')
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
and (:departmentId is null or exists (
select assignment.id
from TeacherDepartmentAssignment assignment
where assignment.teacher.id = user.id
and assignment.department.id = :departmentId
and assignment.validFrom <= :date
and (assignment.validTo is null or assignment.validTo >= :date)
))
""",
countQuery = """
select count(user)
from User user
where user.role = :role
and user.status <> :archivedStatus
and (:query = ''
or lower(user.username) like concat('%', :query, '%')
or lower(user.fullName) like concat('%', :query, '%')
or lower(coalesce(user.jobTitle, '')) like concat('%', :query, '%'))
and (:departmentId is null or exists (
select assignment.id
from TeacherDepartmentAssignment assignment
where assignment.teacher.id = user.id
and assignment.department.id = :departmentId
and assignment.validFrom <= :date
and (assignment.validTo is null or assignment.validTo >= :date)
))
""")
Page<User> searchTeacherOptions(@Param("query") String query,
@Param("role") Role role,
@Param("archivedStatus") String archivedStatus,
@Param("departmentId") Long departmentId,
@Param("date") LocalDate date,
Pageable pageable);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from User user where user.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);

View File

@@ -3,10 +3,10 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -16,25 +16,27 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
@Service
public class AcademicCalendarGridService {
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicCalendarGridService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
}
@@ -47,18 +49,18 @@ public class AcademicCalendarGridService {
throw new IllegalArgumentException("Передайте хотя бы одну ячейку графика");
}
List<AcademicCalendarDay> replacement = buildReplacement(calendar, rows);
List<AcademicCalendarPeriod> replacement = buildReplacement(calendar, rows);
calendarDayRepository.deleteByCalendarId(id);
calendarDayRepository.flush();
calendarDayRepository.saveAll(replacement);
calendarDayRepository.flush();
calendarPeriodRepository.deleteByCalendarId(id);
calendarPeriodRepository.flush();
calendarPeriodRepository.saveAll(replacement);
calendarPeriodRepository.flush();
clearScheduleCacheAfterCommit();
}
private List<AcademicCalendarDay> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) {
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size());
private List<AcademicCalendarPeriod> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) {
List<ValidatedGridDay> validatedDays = new ArrayList<>(rows.size());
Set<GridBusinessKey> uniqueRows = new HashSet<>();
for (AcademicCalendarGridDayDto row : rows) {
@@ -75,19 +77,42 @@ public class AcademicCalendarGridService {
if (!uniqueRows.add(businessKey)) {
throw new IllegalArgumentException("Для курса и даты можно указать только одну активность");
}
validatedDays.add(new ValidatedGridDay(
row.courseNumber(),
row.date(),
resolveActivityType(row)
));
}
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(row.courseNumber());
day.setDate(row.date());
day.setWeekNumber(row.weekNumber());
day.setDayOfWeek(row.dayOfWeek());
day.setActivityType(resolveActivityType(row));
replacement.add(day);
validatedDays.sort(Comparator
.comparing(ValidatedGridDay::courseNumber)
.thenComparing(ValidatedGridDay::date));
List<AcademicCalendarPeriod> replacement = new ArrayList<>();
AcademicCalendarPeriod currentPeriod = null;
for (ValidatedGridDay day : validatedDays) {
if (canExtend(currentPeriod, day)) {
currentPeriod.setEndDate(day.date());
continue;
}
currentPeriod = new AcademicCalendarPeriod();
currentPeriod.setAcademicCalendar(calendar);
currentPeriod.setCourseNumber(day.courseNumber());
currentPeriod.setStartDate(day.date());
currentPeriod.setEndDate(day.date());
currentPeriod.setActivityType(day.activityType());
replacement.add(currentPeriod);
}
return replacement;
}
private boolean canExtend(AcademicCalendarPeriod period, ValidatedGridDay day) {
return period != null
&& period.getCourseNumber().equals(day.courseNumber())
&& period.getEndDate().plusDays(1).equals(day.date())
&& Objects.equals(period.getActivityType().getId(), day.activityType().getId());
}
private void validateRequiredFields(AcademicCalendarGridDayDto row) {
if (row.courseNumber() == null
|| row.date() == null
@@ -155,4 +180,9 @@ public class AcademicCalendarGridService {
private record GridBusinessKey(Integer courseNumber, LocalDate date) {
}
private record ValidatedGridDay(Integer courseNumber,
LocalDate date,
AcademicCalendarActivityType activityType) {
}
}

View File

@@ -1,7 +1,7 @@
package com.magistr.app.service;
import com.magistr.app.model.*;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.StudentGroupCalendarAssignmentRepository;
import org.springframework.beans.factory.annotation.Value;
@@ -17,16 +17,16 @@ import java.util.stream.Collectors;
public class AcademicDateService {
private final SemesterRepository semesterRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final StudentGroupCalendarAssignmentRepository assignmentRepository;
private final boolean evenWeekIsUpper;
public AcademicDateService(SemesterRepository semesterRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
StudentGroupCalendarAssignmentRepository assignmentRepository,
@Value("${app.schedule.even-week-is-upper:false}") boolean evenWeekIsUpper) {
this.semesterRepository = semesterRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.assignmentRepository = assignmentRepository;
this.evenWeekIsUpper = evenWeekIsUpper;
}
@@ -77,20 +77,54 @@ public class AcademicDateService {
List<StudentGroupCalendarAssignment> assignments = groupIds.isEmpty() || academicYearIds.isEmpty()
? List.of()
: assignmentRepository.findForScheduleBatch(groupIds, academicYearIds);
Set<Long> calendarIds = assignments.stream()
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.filter(Objects::nonNull)
.map(AcademicCalendar::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<AcademicCalendarDay> calendarDays = calendarIds.isEmpty()
|| snapshotStart == null
|| endDate == null
|| endDate.isBefore(snapshotStart)
? List.of()
: calendarDayRepository.findForScheduleBatch(calendarIds, snapshotStart, endDate);
List<AcademicCalendarPeriod> calendarPeriods = loadCalendarPeriods(
assignments,
snapshotStart,
endDate
);
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarDays, snapshotStart, endDate);
return new ScheduleSnapshot(semesterSnapshot, assignments, calendarPeriods, snapshotStart, endDate);
}
private List<AcademicCalendarPeriod> loadCalendarPeriods(
Collection<StudentGroupCalendarAssignment> assignments,
LocalDate startDate,
LocalDate endDate) {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
return List.of();
}
Map<Integer, Set<Long>> calendarIdsByCourse = new TreeMap<>();
for (StudentGroupCalendarAssignment assignment : assignments) {
if (assignment == null
|| assignment.getStudentGroup() == null
|| assignment.getAcademicYear() == null
|| assignment.getAcademicYear().getStartDate() == null
|| assignment.getAcademicCalendar() == null
|| assignment.getAcademicCalendar().getId() == null) {
continue;
}
int courseNumber = getCourseNumber(
assignment.getStudentGroup(),
assignment.getAcademicYear().getStartDate()
);
if (courseNumber < 1) {
continue;
}
calendarIdsByCourse.computeIfAbsent(courseNumber, ignored -> new LinkedHashSet<>())
.add(assignment.getAcademicCalendar().getId());
}
List<AcademicCalendarPeriod> result = new ArrayList<>();
calendarIdsByCourse.forEach((courseNumber, calendarIds) ->
result.addAll(calendarPeriodRepository.findForScheduleBatch(
calendarIds,
courseNumber,
startDate,
endDate
))
);
return result;
}
public int getWeekNumber(Semester semester, LocalDate date) {
@@ -126,12 +160,12 @@ public class AcademicDateService {
}
return assignmentRepository.findForSchedule(group.getId(), semester.getAcademicYear().getId())
.flatMap(assignment -> calendarDayRepository.findByCalendarIdAndCourseNumberAndDate(
.flatMap(assignment -> calendarPeriodRepository.findByCalendarIdAndCourseNumberAndDate(
assignment.getAcademicCalendar().getId(),
courseNumber,
date
))
.map(AcademicCalendarDay::getActivityType);
.map(AcademicCalendarPeriod::getActivityType);
}
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
@@ -151,13 +185,13 @@ public class AcademicDateService {
private final Map<LocalDate, Semester> semesterByDate;
private final Map<GroupAcademicYearKey, StudentGroupCalendarAssignment> assignmentByGroupAndYear;
private final Map<CalendarDayKey, AcademicCalendarActivityType> activityByCalendarCourseAndDate;
private final Map<CalendarCourseKey, List<AcademicCalendarPeriod>> periodsByCalendarAndCourse;
private final LocalDate startDate;
private final LocalDate endDate;
private ScheduleSnapshot(Collection<Semester> semesters,
Collection<StudentGroupCalendarAssignment> assignments,
Collection<AcademicCalendarDay> calendarDays,
Collection<AcademicCalendarPeriod> calendarPeriods,
LocalDate startDate,
LocalDate endDate) {
this.startDate = startDate;
@@ -171,14 +205,18 @@ public class AcademicDateService {
),
Function.identity()
));
this.activityByCalendarCourseAndDate = calendarDays.stream()
.collect(Collectors.toUnmodifiableMap(
day -> new CalendarDayKey(
day.getAcademicCalendar().getId(),
day.getCourseNumber(),
day.getDate()
this.periodsByCalendarAndCourse = calendarPeriods.stream()
.collect(Collectors.groupingBy(
period -> new CalendarCourseKey(
period.getAcademicCalendar().getId(),
period.getCourseNumber()
),
AcademicCalendarDay::getActivityType
Collectors.collectingAndThen(
Collectors.toList(),
periods -> periods.stream()
.sorted(Comparator.comparing(AcademicCalendarPeriod::getStartDate))
.toList()
)
));
}
@@ -200,9 +238,30 @@ public class AcademicDateService {
return findAssignment(group, semester)
.map(StudentGroupCalendarAssignment::getAcademicCalendar)
.map(AcademicCalendar::getId)
.map(calendarId -> activityByCalendarCourseAndDate.get(
new CalendarDayKey(calendarId, courseNumber, date)
));
.flatMap(calendarId -> findActivityType(calendarId, courseNumber, date));
}
private Optional<AcademicCalendarActivityType> findActivityType(Long calendarId,
int courseNumber,
LocalDate date) {
List<AcademicCalendarPeriod> periods = periodsByCalendarAndCourse.getOrDefault(
new CalendarCourseKey(calendarId, courseNumber),
List.of()
);
int left = 0;
int right = periods.size() - 1;
while (left <= right) {
int middle = (left + right) >>> 1;
AcademicCalendarPeriod period = periods.get(middle);
if (date.isBefore(period.getStartDate())) {
right = middle - 1;
} else if (date.isAfter(period.getEndDate())) {
left = middle + 1;
} else {
return Optional.of(period.getActivityType());
}
}
return Optional.empty();
}
public boolean isTheoryDay(StudentGroup group, Semester semester, LocalDate date) {
@@ -268,6 +327,6 @@ public class AcademicDateService {
private record GroupAcademicYearKey(Long groupId, Long academicYearId) {
}
private record CalendarDayKey(Long calendarId, Integer courseNumber, LocalDate date) {
private record CalendarCourseKey(Long calendarId, Integer courseNumber) {
}
}

View File

@@ -3,8 +3,14 @@ package com.magistr.app.service;
import com.magistr.app.dto.AcademicYearDto;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@@ -12,73 +18,92 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
@Service
public class AcademicPeriodService {
private static final String YEAR_CONFLICT_MESSAGE =
"Название или период учебного года конфликтует с существующим учебным годом";
"Учебный год или его период конфликтует с существующим учебным годом";
private static final String SEMESTER_CONFLICT_MESSAGE =
"Тип или период семестра конфликтует с существующим семестром учебного года";
private final AcademicYearRepository academicYearRepository;
private final SemesterRepository semesterRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private final ScheduleVersionRepository scheduleVersionRepository;
private final ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
public AcademicPeriodService(AcademicYearRepository academicYearRepository,
SemesterRepository semesterRepository,
ScheduleGeneratorService scheduleGeneratorService) {
ScheduleGeneratorService scheduleGeneratorService,
ScheduleVersionRepository scheduleVersionRepository,
ScheduleVersionHistoryRepository scheduleVersionHistoryRepository) {
this.academicYearRepository = academicYearRepository;
this.semesterRepository = semesterRepository;
this.scheduleGeneratorService = scheduleGeneratorService;
this.scheduleVersionRepository = scheduleVersionRepository;
this.scheduleVersionHistoryRepository = scheduleVersionHistoryRepository;
}
@Transactional
public AcademicYear createYear(AcademicYearDto request) {
validateYear(request);
String title = request.title().trim();
YearPeriod period = resolveYearPeriod(request);
List<SemesterDto> semesterRequests = validateRequestedSemesters(
request.semesters(), period.startDate(), period.endDate(), true
);
String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitle(title)
|| academicYearRepository.existsOverlapping(request.startDate(), request.endDate())) {
|| academicYearRepository.existsOverlapping(period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
AcademicYear year = new AcademicYear();
applyYear(year, title, request.startDate(), request.endDate());
applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year);
for (SemesterDto semesterRequest : semesterRequests) {
Semester semester = new Semester();
semester.setAcademicYear(saved);
applySemester(semester, semesterRequest);
Semester savedSemester = saveSemester(semester);
createInitialPublishedSchedule(savedSemester);
}
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicYear updateYear(Long id, AcademicYearDto request) {
validateYear(request);
YearPeriod period = resolveYearPeriod(request);
AcademicYear year = lockYear(id);
String title = request.title().trim();
String title = academicYearTitle(period.startDate(), period.endDate());
if (academicYearRepository.existsByTitleAndIdNot(title, id)
|| academicYearRepository.existsOverlappingExcluding(
id, request.startDate(), request.endDate())) {
id, period.startDate(), period.endDate())) {
throw new ScheduleConflictException(YEAR_CONFLICT_MESSAGE);
}
boolean semesterOutsideNewRange = semesterRepository
.findByAcademicYearIdOrderByStartDateAsc(id)
.stream()
.anyMatch(semester -> !contains(
request.startDate(),
request.endDate(),
semester.getStartDate(),
semester.getEndDate()
));
if (semesterOutsideNewRange) {
throw new IllegalArgumentException(
"Новые границы учебного года не включают все его семестры"
);
}
applyYear(year, title, request.startDate(), request.endDate());
List<Semester> existingSemesters = semesterRepository.findByAcademicYearIdOrderByStartDateAsc(id);
List<SemesterDto> requestedSemesters = request.semesters() == null
? List.of()
: request.semesters();
List<SemesterDto> effectiveSemesterRequests = mergeSemesterRequests(
existingSemesters, requestedSemesters, period.startDate(), period.endDate()
);
applyYear(year, title, period.startDate(), period.endDate());
AcademicYear saved = saveYear(year);
synchronizeSemesters(saved, existingSemesters, effectiveSemesterRequests, requestedSemesters);
clearScheduleCacheAfterCommit();
return saved;
}
@@ -102,6 +127,7 @@ public class AcademicPeriodService {
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
createInitialPublishedSchedule(saved);
clearScheduleCacheAfterCommit();
return saved;
}
@@ -126,19 +152,15 @@ public class AcademicPeriodService {
return saved;
}
private void validateYear(AcademicYearDto request) {
private YearPeriod resolveYearPeriod(AcademicYearDto request) {
if (request == null) {
throw new IllegalArgumentException("Данные учебного года обязательны");
}
if (request.title() == null || request.title().isBlank()) {
throw new IllegalArgumentException("Название учебного года обязательно");
if (request.startDate() == null) {
throw new IllegalArgumentException("Дата начала учебного года обязательна");
}
validateDateRange(
request.startDate(),
request.endDate(),
"Даты начала и окончания учебного года обязательны",
"Дата окончания учебного года не может быть раньше даты начала"
);
LocalDate endDate = LocalDate.of(request.startDate().getYear() + 1, 6, 30);
return new YearPeriod(request.startDate(), endDate);
}
private void validateSemester(SemesterDto request) {
@@ -176,6 +198,110 @@ public class AcademicPeriodService {
}
}
private List<SemesterDto> validateRequestedSemesters(List<SemesterDto> requests,
LocalDate yearStart,
LocalDate yearEnd,
boolean required) {
List<SemesterDto> semesters = requests == null ? List.of() : new ArrayList<>(requests);
if (required && semesters.isEmpty()) {
throw new IllegalArgumentException("Учебный год нельзя создать без семестров");
}
Set<SemesterType> types = new HashSet<>();
Set<Long> ids = new HashSet<>();
for (SemesterDto semester : semesters) {
validateSemester(semester);
if (!types.add(semester.semesterType())) {
throw new ScheduleConflictException("Типы семестров учебного года не должны повторяться");
}
if (semester.id() != null && !ids.add(semester.id())) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
if (!contains(yearStart, yearEnd, semester.startDate(), semester.endDate())) {
throw new IllegalArgumentException("Семестр должен полностью находиться в границах учебного года");
}
}
List<SemesterDto> sorted = new ArrayList<>(semesters);
sorted.sort(Comparator.comparing(SemesterDto::startDate));
for (int index = 1; index < sorted.size(); index++) {
SemesterDto previous = sorted.get(index - 1);
SemesterDto current = sorted.get(index);
if (!previous.endDate().isBefore(current.startDate())) {
throw new ScheduleConflictException(SEMESTER_CONFLICT_MESSAGE);
}
}
return sorted;
}
private List<SemesterDto> mergeSemesterRequests(List<Semester> existingSemesters,
List<SemesterDto> requestedSemesters,
LocalDate yearStart,
LocalDate yearEnd) {
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
Map<Long, SemesterDto> requestedById = new HashMap<>();
List<SemesterDto> newRequests = new ArrayList<>();
for (SemesterDto request : requestedSemesters) {
if (request == null) {
throw new IllegalArgumentException("Данные семестра обязательны");
}
if (request.id() == null) {
newRequests.add(request);
continue;
}
if (!existingById.containsKey(request.id())) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
if (requestedById.put(request.id(), request) != null) {
throw new IllegalArgumentException("Один семестр нельзя передать несколько раз");
}
}
List<SemesterDto> effective = new ArrayList<>();
for (Semester semester : existingSemesters) {
effective.add(requestedById.getOrDefault(semester.getId(), toSemesterRequest(semester)));
}
effective.addAll(newRequests);
return validateRequestedSemesters(effective, yearStart, yearEnd, true);
}
private void synchronizeSemesters(AcademicYear year,
List<Semester> existingSemesters,
List<SemesterDto> effectiveRequests,
List<SemesterDto> requestedSemesters) {
if (effectiveRequests.isEmpty() || requestedSemesters.isEmpty()) {
return;
}
Map<Long, Semester> existingById = new HashMap<>();
existingSemesters.forEach(semester -> existingById.put(semester.getId(), semester));
for (SemesterDto request : requestedSemesters) {
Semester semester = request.id() == null ? new Semester() : existingById.get(request.id());
if (semester == null) {
throw new IllegalArgumentException("Семестр не относится к редактируемому учебному году");
}
boolean created = semester.getId() == null;
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
if (created) {
createInitialPublishedSchedule(saved);
}
}
}
private SemesterDto toSemesterRequest(Semester semester) {
return new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
);
}
private boolean contains(LocalDate outerStart,
LocalDate outerEnd,
LocalDate innerStart,
@@ -213,6 +339,10 @@ public class AcademicPeriodService {
year.setEndDate(endDate);
}
private String academicYearTitle(LocalDate startDate, LocalDate endDate) {
return startDate.getYear() + "-" + endDate.getYear();
}
private void applySemester(Semester semester, SemesterDto request) {
semester.setSemesterType(request.semesterType());
semester.setStartDate(request.startDate());
@@ -235,6 +365,28 @@ public class AcademicPeriodService {
}
}
private void createInitialPublishedSchedule(Semester semester) {
Instant now = Instant.now();
ScheduleVersion version = new ScheduleVersion();
version.setSemester(semester);
version.setVersionNumber(1);
version.setName("Основное расписание");
version.setStatus(ScheduleVersionStatus.PUBLISHED);
version.setPublishedAt(now);
ScheduleVersion saved = scheduleVersionRepository.saveAndFlush(version);
saveVersionHistory(saved, "CREATED", "Основное расписание создано вместе с семестром");
saveVersionHistory(saved, "PUBLISHED", "Начальная публикация расписания");
}
private void saveVersionHistory(ScheduleVersion version, String action, String reason) {
ScheduleVersionHistory history = new ScheduleVersionHistory();
history.setVersion(version);
history.setAction(action);
history.setReason(reason);
scheduleVersionHistoryRepository.save(history);
}
private void clearScheduleCacheAfterCommit() {
if (!TransactionSynchronizationManager.isSynchronizationActive()
|| !TransactionSynchronizationManager.isActualTransactionActive()) {
@@ -248,4 +400,7 @@ public class AcademicPeriodService {
}
});
}
private record YearPeriod(LocalDate startDate, LocalDate endDate) {
}
}

View File

@@ -4,13 +4,16 @@ import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.CreateGroupRequest;
import com.magistr.app.dto.GroupCalendarAssignmentDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.StudentGroupCalendarAssignment;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarActivityTypeRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
@@ -28,14 +31,18 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
@Service
public class AcademicStructureService {
private static final String THEORY_ACTIVITY_CODE = "Т";
private final AcademicCalendarRepository calendarRepository;
private final AcademicCalendarDayRepository calendarDayRepository;
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
private final AcademicCalendarActivityTypeRepository activityTypeRepository;
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
private final AcademicYearRepository academicYearRepository;
private final SpecialtiesRepository specialtiesRepository;
@@ -47,7 +54,8 @@ public class AcademicStructureService {
private final ScheduleGeneratorService scheduleGeneratorService;
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
AcademicCalendarDayRepository calendarDayRepository,
AcademicCalendarPeriodRepository calendarPeriodRepository,
AcademicCalendarActivityTypeRepository activityTypeRepository,
AcademicCalendarSubjectRepository calendarSubjectRepository,
AcademicYearRepository academicYearRepository,
SpecialtiesRepository specialtiesRepository,
@@ -58,7 +66,8 @@ public class AcademicStructureService {
SubgroupRepository subgroupRepository,
ScheduleGeneratorService scheduleGeneratorService) {
this.calendarRepository = calendarRepository;
this.calendarDayRepository = calendarDayRepository;
this.calendarPeriodRepository = calendarPeriodRepository;
this.activityTypeRepository = activityTypeRepository;
this.calendarSubjectRepository = calendarSubjectRepository;
this.academicYearRepository = academicYearRepository;
this.specialtiesRepository = specialtiesRepository;
@@ -82,6 +91,41 @@ public class AcademicStructureService {
return saved;
}
@Transactional
public AcademicCalendar createCalendar(AcademicCalendarDto request) {
validateCalendarRequest(request);
CalendarDimensions dimensions = resolveCalendarDimensions(request);
AcademicCalendarActivityType theoryActivity = activityTypeRepository.findByCode(THEORY_ACTIVITY_CODE)
.filter(activity -> Boolean.TRUE.equals(activity.getAllowSchedule()))
.orElseThrow(() -> new IllegalArgumentException(
"Код активности «Т», разрешающий обычные занятия, не найден"
));
AcademicCalendar calendar = new AcademicCalendar();
calendar.setTitle(AcademicCalendarTitleBuilder.build(
dimensions.academicYear(),
dimensions.speciality(),
dimensions.profile(),
dimensions.studyForm()
));
applyCalendarDimensions(calendar, dimensions, request.courseCount());
AcademicCalendar saved = saveCalendar(calendar);
List<AcademicCalendarPeriod> defaultPeriods = new ArrayList<>(request.courseCount());
for (int courseNumber = 1; courseNumber <= request.courseCount(); courseNumber++) {
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
period.setAcademicCalendar(saved);
period.setCourseNumber(courseNumber);
period.setStartDate(dimensions.academicYear().getStartDate());
period.setEndDate(dimensions.academicYear().getEndDate());
period.setActivityType(theoryActivity);
defaultPeriods.add(period);
}
calendarPeriodRepository.saveAll(defaultPeriods);
clearScheduleCacheAfterCommit();
return saved;
}
@Transactional
public AcademicCalendar updateCalendar(Long id, AcademicCalendarDto request) {
validateCalendarRequest(request);
@@ -106,7 +150,7 @@ public class AcademicStructureService {
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
);
}
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
if (calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
id, request.courseCount())) {
throw new ScheduleConflictException(
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
@@ -118,7 +162,7 @@ public class AcademicStructureService {
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
);
}
if (calendarDayRepository.existsOutsideDateRange(
if (calendarPeriodRepository.existsOutsideDateRange(
id,
candidate.academicYear().getStartDate(),
candidate.academicYear().getEndDate())) {

View File

@@ -24,7 +24,7 @@ public class ScheduleGeneratorService {
private static final Logger logger = LoggerFactory.getLogger(ScheduleGeneratorService.class);
private static final int ACADEMIC_HOURS_PER_SLOT = 2;
private static final long MAX_RANGE_DAYS = 120;
private static final long MAX_RANGE_DAYS = 200;
private static final String APPLY_MODE_WEEKDAY = "WEEKDAY";
private final ScheduleRuleRepository scheduleRuleRepository;
@@ -95,6 +95,30 @@ public class ScheduleGeneratorService {
return buildScheduleForGroupsInternal(groupSnapshot, startDate, endDate);
}
public List<RenderedLessonDto> buildScheduleForVersion(Long versionId,
LocalDate startDate,
LocalDate endDate) {
validateRange(startDate, endDate);
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
List<ScheduleRule> rules = scheduleRuleRepository.findActiveByVersionIdWithDetails(versionId, null);
List<StudentGroup> groups = rules.stream()
.flatMap(rule -> rule.getGroups().stream())
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(
StudentGroup::getId,
group -> group,
(first, ignored) -> first,
LinkedHashMap::new
))
.values()
.stream()
.toList();
List<Semester> semesters = academicDateService.findSemesters(startDate, endDate);
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate) {
@@ -113,6 +137,24 @@ public class ScheduleGeneratorService {
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<ScheduleRule> rules = scheduleRuleRepository.findByGroupIdsAndSemesterIds(groupIds, semesterIds);
return buildScheduleForGroupsInternal(groups, startDate, endDate, rules, semesters);
}
private List<RenderedLessonDto> buildScheduleForGroupsInternal(List<StudentGroup> groups,
LocalDate startDate,
LocalDate endDate,
List<ScheduleRule> rules,
List<Semester> semesters) {
if (groups.isEmpty()) {
return List.of();
}
if (semesters.isEmpty()) {
return List.of();
}
Set<Long> groupIds = groups.stream()
.map(StudentGroup::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
AcademicDateService.ScheduleSnapshot dateSnapshot = academicDateService
.createScheduleSnapshot(groups, semesters, endDate);
TimeSlotSnapshot timeSlotSnapshot = loadTimeSlotSnapshot(startDate, endDate, rules);
@@ -286,7 +328,7 @@ public class ScheduleGeneratorService {
}
long inclusiveDays = java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
}
}

View File

@@ -42,6 +42,8 @@ import java.util.stream.Collectors;
@Service
public class ScheduleOverrideService {
private static final long MAX_RANGE_DAYS = 200;
private static final Set<String> ACTIONS = Set.of("MOVE", "CANCEL", "REPLACE");
private static final Set<String> LESSON_FORMATS = Set.of("Очно", "Онлайн");
@@ -56,6 +58,7 @@ public class ScheduleOverrideService {
private final PostgreSqlAdvisoryLock advisoryLock;
private final AcademicDateService academicDateService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
@Autowired
public ScheduleOverrideService(ScheduleOverrideRepository scheduleOverrideRepository,
@@ -106,6 +109,11 @@ public class ScheduleOverrideService {
);
}
@Autowired(required = false)
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
}
@Transactional(readOnly = true)
public List<ScheduleOverrideDto> getAll(LocalDate startDate, LocalDate endDate) {
if ((startDate == null) != (endDate == null)) {
@@ -184,6 +192,43 @@ public class ScheduleOverrideService {
return validateAndPersist(null, command);
}
/**
* Проверяет кандидат мастера замены тем же механизмом, что и обычное точечное изменение,
* но не блокирует строки и не сохраняет результат.
*/
@Transactional(readOnly = true)
public void validateCandidate(ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
if (scheduleOverrideRepository
.findByBaseRuleSlotIdAndLessonDate(command.baseRuleSlotId(), command.lessonDate())
.isPresent()) {
throw new ScheduleConflictException(
"Для выбранной базовой пары и даты уже существует точечное изменение"
);
}
List<RenderedLessonDto> baseDay = scheduleQueryService.buildBaseDayForAllGroups(command.lessonDate());
List<RenderedLessonDto> sourceOccurrences = baseDay.stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId()))
.filter(lesson -> Objects.equals(lesson.date(), command.lessonDate()))
.toList();
if (sourceOccurrences.isEmpty()) {
throw new IllegalArgumentException(
"На выбранную дату базовая пара не формируется по правилам расписания"
);
}
ResolvedReferences references = resolveReferences(command, sourceOccurrences);
validateTargetDate(command, references.baseRuleSlot(), sourceOccurrences);
if (!"CANCEL".equals(command.action())) {
validateEffectiveResult(
null,
command,
buildCandidate(command, references),
baseDay,
sourceOccurrences
);
}
}
@Transactional
public ScheduleOverrideDto update(Long id, ScheduleOverrideDto request) {
OverrideCommand command = validateAndNormalize(request);
@@ -294,6 +339,7 @@ public class ScheduleOverrideService {
.map(EffectiveSourceTuple::from)
.collect(Collectors.toSet());
validateActionSpecificChange(command.action(), sourceOccurrences, candidateOccurrences);
validateTeacherAvailability(candidate, candidateOccurrences);
if (sourceTuples.equals(candidateTuples)) {
throw new IllegalArgumentException(
"Точечное изменение должно фактически менять время, преподавателя, аудиторию или формат пары"
@@ -308,6 +354,27 @@ public class ScheduleOverrideService {
validateResourceConflicts(candidateOccurrences, otherOccurrences, subgroupToGroup);
}
private void validateTeacherAvailability(
ScheduleOverride candidate,
List<RenderedLessonDto> candidateOccurrences
) {
if (teacherAvailabilityPolicyService == null
|| candidate.getBaseRuleSlot() == null
|| candidate.getBaseRuleSlot().getScheduleRule() == null
|| candidate.getBaseRuleSlot().getScheduleRule().getSemester() == null) {
return;
}
Long semesterId = candidate.getBaseRuleSlot().getScheduleRule().getSemester().getId();
for (RenderedLessonDto occurrence : candidateOccurrences) {
teacherAvailabilityPolicyService.assertLessonAvailable(
occurrence.teacherId(),
semesterId,
occurrence.date(),
occurrence.timeSlotId()
);
}
}
private boolean isCandidateOccurrence(RenderedLessonDto lesson, OverrideCommand command) {
return Objects.equals(lesson.scheduleRuleSlotId(), command.baseRuleSlotId())
&& (Objects.equals(lesson.originalLessonDate(), command.lessonDate())
@@ -641,8 +708,8 @@ public class ScheduleOverrideService {
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания не может быть раньше даты начала");
}
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > 120) {
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 120 дней");
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон точечных изменений не может превышать 200 дней");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import org.springframework.beans.factory.annotation.Autowired;
@@ -17,32 +18,45 @@ import java.util.stream.Collectors;
public class ScheduleQueryService {
private static final int MAX_GROUPS_WITHOUT_SCOPE = 50;
private static final long MAX_RANGE_DAYS = 120;
private static final long MAX_RANGE_DAYS = 200;
private final ScheduleGeneratorService scheduleGeneratorService;
private final GroupRepository groupRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final StudentGroupLifecycleService groupLifecycleService;
private final AcademicDateService academicDateService;
private final TeacherDepartmentService teacherDepartmentService;
@Autowired
public ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
AcademicDateService academicDateService,
TeacherDepartmentService teacherDepartmentService) {
this.scheduleGeneratorService = scheduleGeneratorService;
this.groupRepository = groupRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.groupLifecycleService = groupLifecycleService;
this.academicDateService = academicDateService;
this.teacherDepartmentService = teacherDepartmentService;
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService,
AcademicDateService academicDateService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, academicDateService, null);
}
ScheduleQueryService(ScheduleGeneratorService scheduleGeneratorService,
GroupRepository groupRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
StudentGroupLifecycleService groupLifecycleService) {
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository, groupLifecycleService, null);
this(scheduleGeneratorService, groupRepository, scheduleOverrideRepository,
groupLifecycleService, null, null);
}
/**
@@ -192,10 +206,19 @@ public class ScheduleQueryService {
.findByLessonDateBetweenWithDetails(startDate, endDate);
generatedLessons = new ArrayList<>(generatedLessons);
addOverrideSourceOccurrences(generatedLessons, overrideSnapshot, scopedGroups);
List<RenderedLessonDto> lessons = applyOverrides(generatedLessons, null, overrideSnapshot).stream()
List<RenderedLessonDto> effectiveLessons = applyOverrides(generatedLessons, null, overrideSnapshot);
Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher =
assignmentsByTeacher(effectiveLessons, departmentId, startDate, endDate);
List<RenderedLessonDto> lessons = effectiveLessons.stream()
.filter(lesson -> !lesson.date().isBefore(startDate) && !lesson.date().isAfter(endDate))
.filter(lesson -> teacherId == null || Objects.equals(lesson.teacherId(), teacherId))
.filter(lesson -> classroomId == null || Objects.equals(lesson.classroomId(), classroomId))
.filter(lesson -> departmentId == null || teacherDepartmentService
.hasDepartmentAssignmentAtDate(
assignmentsByTeacher.getOrDefault(lesson.teacherId(), List.of()),
departmentId,
lesson.date()
))
.filter(lesson -> subjectId == null || Objects.equals(lesson.subjectId(), subjectId))
.filter(lesson -> lessonTypeId == null || Objects.equals(lesson.lessonTypeId(), lessonTypeId))
.filter(lesson -> timeSlotId == null || Objects.equals(lesson.timeSlotId(), timeSlotId))
@@ -204,6 +227,29 @@ public class ScheduleQueryService {
return deduplicate(lessons);
}
private Map<Long, List<TeacherDepartmentAssignment>> assignmentsByTeacher(
List<RenderedLessonDto> lessons,
Long departmentId,
LocalDate startDate,
LocalDate endDate
) {
if (departmentId == null) {
return Map.of();
}
if (teacherDepartmentService == null) {
throw new IllegalStateException("Сервис кафедральных связей преподавателей недоступен");
}
Set<Long> teacherIds = lessons.stream()
.map(RenderedLessonDto::teacherId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
return teacherDepartmentService.findAssignmentsForTeachersBetween(
teacherIds,
startDate,
endDate
);
}
private void addOverrideSourceOccurrences(
List<RenderedLessonDto> generatedLessons,
Collection<ScheduleOverride> overrideSnapshot,
@@ -380,7 +426,7 @@ public class ScheduleQueryService {
}
long inclusiveDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (inclusiveDays > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Диапазон расписания не может превышать 120 дней");
throw new IllegalArgumentException("Диапазон расписания не может превышать 200 дней");
}
}
@@ -395,12 +441,10 @@ public class ScheduleQueryService {
.map(List::of)
.orElse(List.of());
}
List<StudentGroup> groups;
if (departmentId != null) {
groups = groupRepository.findByDepartmentId(departmentId);
} else {
groups = groupRepository.findAll();
}
// Кафедральный scope определяется по преподавателю на дату занятия,
// а не по выпускающей кафедре учебной группы. Фильтрация выполняется
// после генерации и применения точечных изменений.
List<StudentGroup> groups = groupRepository.findAll();
groups = groups.stream()
.filter(group -> groupLifecycleService.mayHaveScheduleInRange(group, startDate, endDate))
.toList();

View File

@@ -1,7 +1,9 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
import com.magistr.app.dto.PageResponse;
import com.magistr.app.dto.ScheduleRuleSlotDto;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LessonType;
import com.magistr.app.model.LifecycleEntity;
@@ -10,6 +12,8 @@ import com.magistr.app.model.ScheduleLessonCategory;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
@@ -20,11 +24,15 @@ import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.LessonTypesRepository;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +48,7 @@ import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
@@ -70,6 +79,9 @@ public class ScheduleRuleService {
private final LessonTypesRepository lessonTypesRepository;
private final SubgroupRepository subgroupRepository;
private final ScheduleGeneratorService scheduleGeneratorService;
private TeacherAvailabilityPolicyService teacherAvailabilityPolicyService;
private ScheduleVersionRepository scheduleVersionRepository;
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
public ScheduleRuleService(ScheduleRuleRepository scheduleRuleRepository,
SubjectRepository subjectRepository,
@@ -93,9 +105,46 @@ public class ScheduleRuleService {
this.scheduleGeneratorService = scheduleGeneratorService;
}
@Autowired(required = false)
void setTeacherAvailabilityPolicyService(TeacherAvailabilityPolicyService teacherAvailabilityPolicyService) {
this.teacherAvailabilityPolicyService = teacherAvailabilityPolicyService;
}
@Autowired(required = false)
void setScheduleVersionRepository(ScheduleVersionRepository scheduleVersionRepository) {
this.scheduleVersionRepository = scheduleVersionRepository;
}
@Autowired(required = false)
void setScheduleRuleSlotRepository(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)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId) {
return scheduleRuleRepository.findAllWithDetails().stream()
return getAll(semesterId, groupId, null);
}
@Transactional(readOnly = true)
public List<ScheduleRuleDto> getAll(Long semesterId, Long groupId, Long scheduleVersionId) {
List<ScheduleRule> source = scheduleVersionId == null
? scheduleRuleRepository.findAllWithDetails()
: scheduleRuleRepository.findActiveByVersionIdWithDetails(scheduleVersionId, null);
return source.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> semesterId == null || Objects.equals(rule.getSemester().getId(), semesterId))
.filter(rule -> groupId == null || rule.getGroups().stream()
@@ -120,11 +169,17 @@ public class ScheduleRuleService {
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
ScheduleRule candidate = buildCandidate(request, semester);
ScheduleVersion version = resolveEditableVersion(request.scheduleVersionId(), semester.getId());
ScheduleRule candidate = buildCandidate(request, semester, version);
validateCandidate(candidate);
validateAgainstSavedRules(candidate, null);
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(candidate);
if (saved.getVersionGroupId() == null && saved.getId() != null) {
saved.setVersionGroupId(saved.getId());
saved = scheduleRuleRepository.saveAndFlush(saved);
}
scheduleGeneratorService.clearCache();
return toDto(saved);
}
@@ -137,6 +192,7 @@ public class ScheduleRuleService {
// определён по актуальному состоянию и два update одного правила не расходятся.
ScheduleRule managedRule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureEditableRule(managedRule);
Long oldSemesterId = managedRule.getSemester().getId();
Map<Long, Semester> lockedSemesters = lockSemestersInStableOrder(
@@ -147,11 +203,22 @@ public class ScheduleRuleService {
throw new IllegalArgumentException("Семестр не найден");
}
ScheduleRule candidate = buildCandidate(request, targetSemester);
if (managedRule.getScheduleVersion() != null
&& request.scheduleVersionId() != null
&& !Objects.equals(managedRule.getScheduleVersion().getId(), request.scheduleVersionId())) {
throw new IllegalArgumentException("Нельзя переносить правило между версиями расписания");
}
if (managedRule.getScheduleVersion() != null
&& !Objects.equals(managedRule.getScheduleVersion().getSemester().getId(), targetSemester.getId())) {
throw new IllegalArgumentException("Нельзя переносить правило в другой семестр внутри версии");
}
ScheduleRule candidate = buildCandidate(request, targetSemester, managedRule.getScheduleVersion());
candidate.setVersionGroupId(managedRule.getVersionGroupId());
validateCandidate(candidate);
validateAgainstSavedRules(candidate, managedRule.getId());
applyCandidate(managedRule, candidate);
applyCandidate(managedRule, candidate, request.slots());
ScheduleRule saved = scheduleRuleRepository.saveAndFlush(managedRule);
scheduleGeneratorService.clearCache();
return toDto(saved);
@@ -161,6 +228,7 @@ public class ScheduleRuleService {
public void archive(Long id) {
ScheduleRule rule = scheduleRuleRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Правило расписания не найдено"));
lockAndEnsureEditableRule(rule);
Long semesterId = rule.getSemester().getId();
semesterRepository.findByIdForUpdate(semesterId)
.orElseThrow(() -> new NoSuchElementException("Семестр правила не найден"));
@@ -267,7 +335,7 @@ public class ScheduleRuleService {
normalizeLessonFormat(slot.lessonFormat());
}
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester) {
private ScheduleRule buildCandidate(ScheduleRuleDto request, Semester semester, ScheduleVersion version) {
Subject subject = subjectRepository.findById(request.subjectId())
.orElseThrow(() -> new IllegalArgumentException("Дисциплина не найдена"));
if (subject.isArchivedRecord()) {
@@ -286,6 +354,11 @@ public class ScheduleRuleService {
ScheduleRule candidate = new ScheduleRule();
candidate.setSubject(subject);
candidate.setSemester(semester);
candidate.setScheduleVersion(version);
Long currentUserId = AuthContext.currentUserId();
if (currentUserId != null) {
userRepository.findById(currentUserId).ifPresent(candidate::setCreatedBy);
}
candidate.setLectureAcademicHours(request.lectureAcademicHours());
candidate.setLaboratoryAcademicHours(request.laboratoryAcademicHours());
candidate.setPracticeAcademicHours(request.practiceAcademicHours());
@@ -424,6 +497,21 @@ public class ScheduleRuleService {
List<ScheduleRuleSlot> slots = new ArrayList<>(candidate.getSlots());
validateExactDuplicates(slots);
validateInternalConflicts(slots);
validateTeacherAvailability(candidate, slots);
}
private void validateTeacherAvailability(ScheduleRule candidate, List<ScheduleRuleSlot> slots) {
if (teacherAvailabilityPolicyService == null) return;
Map<ScheduleRuleSlot, Set<Integer>> activeWeeks = activeWeeksBySlot(slots);
for (ScheduleRuleSlot slot : slots) {
teacherAvailabilityPolicyService.assertRuleSlotAvailable(
teacherId(slot),
candidate.getSemester(),
slot.getDayOfWeek(),
timeSlotId(slot),
activeWeeks.get(slot)
);
}
}
private void validateExactDuplicates(List<ScheduleRuleSlot> slots) {
@@ -483,8 +571,12 @@ public class ScheduleRuleService {
private void validateAgainstSavedRules(ScheduleRule candidate, Long excludeRuleId) {
Map<ScheduleRuleSlot, Set<Integer>> candidateActiveWeeks = activeWeeksBySlot(candidate.getSlots());
ScheduleRuleConflict conflict = scheduleRuleRepository
.findActiveBySemesterIdWithDetails(candidate.getSemester().getId(), excludeRuleId)
List<ScheduleRule> savedRules = candidate.getScheduleVersion() == null
? scheduleRuleRepository.findActiveBySemesterIdWithDetails(
candidate.getSemester().getId(), excludeRuleId)
: scheduleRuleRepository.findActiveByVersionIdWithDetails(
candidate.getScheduleVersion().getId(), excludeRuleId);
ScheduleRuleConflict conflict = savedRules
.stream()
.filter(ScheduleRule::isActiveRecord)
.filter(rule -> !Objects.equals(rule.getId(), excludeRuleId))
@@ -737,7 +829,9 @@ public class ScheduleRuleService {
.toList();
}
private void applyCandidate(ScheduleRule managedRule, ScheduleRule candidate) {
private void applyCandidate(ScheduleRule managedRule,
ScheduleRule candidate,
List<ScheduleRuleSlotDto> requestedSlots) {
managedRule.setSubject(candidate.getSubject());
managedRule.setSemester(candidate.getSemester());
managedRule.setLectureAcademicHours(candidate.getLectureAcademicHours());
@@ -749,11 +843,54 @@ public class ScheduleRuleService {
managedRule.getGroups().clear();
managedRule.getGroups().addAll(candidate.getGroups());
managedRule.getSlots().clear();
for (ScheduleRuleSlot slot : candidate.getSlots()) {
slot.setScheduleRule(managedRule);
managedRule.getSlots().add(slot);
List<ScheduleRuleSlot> candidateSlots = new ArrayList<>(candidate.getSlots());
Map<Long, ScheduleRuleSlot> currentSlotsById = managedRule.getSlots().stream()
.filter(slot -> slot.getId() != null)
.collect(Collectors.toMap(ScheduleRuleSlot::getId, Function.identity()));
Set<Long> retainedSlotIds = new HashSet<>();
Set<ScheduleRuleSlot> updatedSlots = new LinkedHashSet<>();
for (int index = 0; index < candidateSlots.size(); index++) {
ScheduleRuleSlot candidateSlot = candidateSlots.get(index);
Long requestedSlotId = requestedSlots.get(index).id();
if (requestedSlotId == null) {
candidateSlot.setScheduleRule(managedRule);
updatedSlots.add(candidateSlot);
continue;
}
ScheduleRuleSlot existingSlot = currentSlotsById.get(requestedSlotId);
if (existingSlot == null) {
throw new IllegalArgumentException("Слот не принадлежит редактируемому правилу");
}
retainedSlotIds.add(requestedSlotId);
copySlot(existingSlot, candidateSlot);
updatedSlots.add(existingSlot);
}
List<Long> removedSlotIds = currentSlotsById.keySet().stream()
.filter(id -> !retainedSlotIds.contains(id))
.toList();
if (!removedSlotIds.isEmpty()
&& scheduleRuleSlotRepository != null
&& scheduleRuleSlotRepository.hasProtectedReferences(removedSlotIds)) {
throw new IllegalArgumentException(
"Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям"
);
}
managedRule.getSlots().clear();
managedRule.getSlots().addAll(updatedSlots);
}
private void copySlot(ScheduleRuleSlot target, ScheduleRuleSlot source) {
target.setDayOfWeek(source.getDayOfWeek());
target.setParity(source.getParity());
target.setTimeSlot(source.getTimeSlot());
target.setSubgroups(source.getSubgroups());
target.setTeacher(source.getTeacher());
target.setClassroom(source.getClassroom());
target.setLessonType(source.getLessonType());
target.setLessonFormat(source.getLessonFormat());
}
private ScheduleRuleDto toDto(ScheduleRule rule) {
@@ -783,10 +920,77 @@ public class ScheduleRuleService {
rule.getPracticeStartWeek(),
groups.stream().map(StudentGroup::getId).toList(),
groups.stream().map(StudentGroup::getName).toList(),
slots
slots,
rule.getScheduleVersion() == null ? null : rule.getScheduleVersion().getId()
);
}
@Transactional(readOnly = true)
public List<String> validateVersion(Long scheduleVersionId) {
if (scheduleVersionId == null) {
return List.of("Версия расписания не выбрана");
}
List<ScheduleRule> rules = scheduleRuleRepository
.findActiveByVersionIdWithDetails(scheduleVersionId, null);
if (rules.isEmpty()) {
return List.of("В черновике нет ни одного правила расписания");
}
LinkedHashSet<String> issues = new LinkedHashSet<>();
for (ScheduleRule rule : rules) {
if (rule.getSlots().isEmpty()) {
issues.add("Правило «" + rule.getSubject().getName() + "» не содержит слотов");
continue;
}
try {
validateCandidate(rule);
validateAgainstSavedRules(rule, rule.getId());
} catch (IllegalArgumentException | ScheduleRuleConflictException exception) {
issues.add("Правило «" + rule.getSubject().getName() + "»: " + exception.getMessage());
}
}
return List.copyOf(issues);
}
private ScheduleVersion resolveEditableVersion(Long versionId, Long semesterId) {
if (versionId == null) {
return null;
}
if (scheduleVersionRepository == null) {
throw new IllegalStateException("Хранилище версий расписания недоступно");
}
ScheduleVersion version = scheduleVersionRepository.findByIdForUpdate(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
if (!Objects.equals(version.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Версия относится к другому семестру");
}
ensureEditableVersion(version);
return version;
}
private void ensureEditableVersion(ScheduleVersion version) {
if (version.getStatus() == ScheduleVersionStatus.ARCHIVED) {
throw new IllegalArgumentException(
"Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик"
);
}
}
private void ensureEditableRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null) {
ensureEditableVersion(rule.getScheduleVersion());
}
}
private void lockAndEnsureEditableRule(ScheduleRule rule) {
if (rule.getScheduleVersion() != null && scheduleVersionRepository != null) {
ScheduleVersion lockedVersion = scheduleVersionRepository
.findByIdForUpdate(rule.getScheduleVersion().getId())
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
rule.setScheduleVersion(lockedVersion);
}
ensureEditableRule(rule);
}
private ScheduleRuleSlotDto toSlotDto(ScheduleRuleSlot slot) {
TimeSlot timeSlot = slot.getTimeSlot();
List<Subgroup> sortedSubgroups = sortedSubgroups(slot);

View File

@@ -0,0 +1,657 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.dto.ScheduleVersionDiffDto;
import com.magistr.app.dto.ScheduleVersionDto;
import com.magistr.app.dto.ScheduleVersionHistoryDto;
import com.magistr.app.dto.ScheduleVersionValidationDto;
import com.magistr.app.model.ScheduleRule;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionHistory;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.Subgroup;
import com.magistr.app.model.User;
import com.magistr.app.repository.ScheduleRuleRepository;
import com.magistr.app.repository.ScheduleVersionHistoryRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ScheduleVersionService {
private static final Logger log = LoggerFactory.getLogger(ScheduleVersionService.class);
private static final int MAX_RANGE_DAYS = 120;
private static final int MAX_REASON_LENGTH = 2000;
private final ScheduleVersionRepository versionRepository;
private final ScheduleVersionHistoryRepository historyRepository;
private final ScheduleRuleRepository ruleRepository;
private final SemesterRepository semesterRepository;
private final UserRepository userRepository;
private final ScheduleRuleService ruleService;
private final ScheduleGeneratorService generatorService;
public ScheduleVersionService(
ScheduleVersionRepository versionRepository,
ScheduleVersionHistoryRepository historyRepository,
ScheduleRuleRepository ruleRepository,
SemesterRepository semesterRepository,
UserRepository userRepository,
ScheduleRuleService ruleService,
ScheduleGeneratorService generatorService
) {
this.versionRepository = versionRepository;
this.historyRepository = historyRepository;
this.ruleRepository = ruleRepository;
this.semesterRepository = semesterRepository;
this.userRepository = userRepository;
this.ruleService = ruleService;
this.generatorService = generatorService;
}
@Transactional(readOnly = true)
public List<ScheduleVersionDto> getVersions(Long semesterId) {
requireSemesterId(semesterId);
return versionRepository.findBySemesterIdWithDetails(semesterId).stream()
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public ScheduleVersionDto getVersion(Long versionId) {
return toDto(findVersion(versionId));
}
@Transactional(readOnly = true)
public List<ScheduleVersionHistoryDto> getHistory(Long semesterId) {
requireSemesterId(semesterId);
return historyRepository.findBySemesterIdWithDetails(semesterId).stream()
.map(this::toHistoryDto)
.toList();
}
@Transactional
public ScheduleVersionDto createDraft(ScheduleVersionCreateDto request) {
if (request == null || request.semesterId() == null) {
throw new IllegalArgumentException("Семестр для черновика обязателен");
}
Semester semester = semesterRepository.findByIdForUpdate(request.semesterId())
.orElseThrow(() -> new NoSuchElementException("Семестр не найден"));
List<ScheduleVersion> lockedVersions = versionRepository.findBySemesterIdForUpdate(semester.getId());
ScheduleVersion base = resolveBaseVersion(request.basedOnVersionId(), semester.getId(), lockedVersions);
int nextNumber = lockedVersions.stream()
.map(ScheduleVersion::getVersionNumber)
.filter(Objects::nonNull)
.max(Integer::compareTo)
.orElse(0) + 1;
ScheduleVersion draft = new ScheduleVersion();
draft.setSemester(semester);
draft.setVersionNumber(nextNumber);
draft.setName(normalizeName(request.name(), "Черновик версии " + nextNumber));
draft.setStatus(ScheduleVersionStatus.DRAFT);
draft.setBasedOnVersion(base);
draft.setCreatedBy(currentUser());
ScheduleVersion saved = versionRepository.saveAndFlush(draft);
if (base != null) {
cloneRules(base, saved);
}
addHistory(saved, "CREATED", currentUser(), base == null
? "Создан пустой черновик"
: "Создан черновик на основе версии " + base.getVersionNumber());
log.info("Создан черновик расписания {} для семестра {} на основе версии {}",
saved.getId(), semester.getId(), base == null ? "без основы" : base.getId());
return toDto(findVersion(saved.getId()));
}
@Transactional(readOnly = true)
public ScheduleVersionValidationDto validate(Long versionId) {
ScheduleVersion version = findVersion(versionId);
List<ScheduleRule> rules = activeRules(versionId);
List<String> issues = ruleService.validateVersion(versionId);
long slotCount = rules.stream().mapToLong(rule -> rule.getSlots().size()).sum();
return new ScheduleVersionValidationDto(
version.getId(),
issues.isEmpty(),
rules.size(),
slotCount,
issues
);
}
@Transactional
public ScheduleVersionDto publish(Long versionId, String reason) {
ScheduleVersion target = lockVersion(versionId);
if (target.getStatus() != ScheduleVersionStatus.DRAFT) {
throw new IllegalArgumentException("Опубликовать можно только черновик");
}
String normalizedReason = requireReason(reason, "Для публикации укажите причину изменений");
ScheduleVersionValidationDto validation = validate(versionId);
if (!validation.valid()) {
throw new IllegalArgumentException(
"Черновик не прошёл проверку: " + String.join("; ", validation.issues())
);
}
List<ScheduleVersion> versions = versionRepository
.findBySemesterIdForUpdate(target.getSemester().getId());
User actor = currentUser();
Instant now = Instant.now();
versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.filter(version -> !Objects.equals(version.getId(), target.getId()))
.forEach(version -> archivePublished(version, actor, normalizedReason, now));
versionRepository.flush();
target.setStatus(ScheduleVersionStatus.PUBLISHED);
target.setChangeReason(normalizedReason);
target.setPublishedBy(actor);
target.setPublishedAt(now);
target.setArchivedAt(null);
versionRepository.saveAndFlush(target);
addHistory(target, "PUBLISHED", actor, normalizedReason);
generatorService.clearCache();
log.info("Опубликована версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
return toDto(findVersion(target.getId()));
}
@Transactional
public ScheduleVersionDto restore(Long versionId, String reason) {
ScheduleVersion target = lockVersion(versionId);
if (target.getStatus() != ScheduleVersionStatus.ARCHIVED || target.getPublishedAt() == null) {
throw new IllegalArgumentException("Восстановить можно только ранее опубликованную архивную версию");
}
String normalizedReason = requireReason(reason, "Для восстановления укажите причину");
List<ScheduleVersion> versions = versionRepository
.findBySemesterIdForUpdate(target.getSemester().getId());
ScheduleVersion current = versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.findFirst()
.orElse(null);
User actor = currentUser();
Instant now = Instant.now();
if (current != null && !Objects.equals(current.getId(), target.getId())) {
archivePublished(current, actor, normalizedReason, now);
versionRepository.flush();
}
target.setStatus(ScheduleVersionStatus.PUBLISHED);
target.setChangeReason(normalizedReason);
target.setPublishedBy(actor);
target.setPublishedAt(now);
target.setArchivedAt(null);
target.setRestoredFromVersion(current);
versionRepository.saveAndFlush(target);
addHistory(target, "RESTORED", actor, normalizedReason);
generatorService.clearCache();
log.info("Восстановлена версия расписания {} для семестра {}", target.getId(), target.getSemester().getId());
return toDto(findVersion(target.getId()));
}
@Transactional(readOnly = true)
public ScheduleVersionDiffDto diff(Long targetVersionId, Long requestedBaseVersionId) {
ScheduleVersion target = findVersion(targetVersionId);
ScheduleVersion base = resolveDiffBase(target, requestedBaseVersionId);
List<ScheduleRule> beforeRules = activeRules(base.getId());
List<ScheduleRule> afterRules = activeRules(target.getId());
Map<Long, ScheduleRule> beforeByGroup = indexRulesByVersionGroup(beforeRules);
Map<Long, ScheduleRule> afterByGroup = indexRulesByVersionGroup(afterRules);
LinkedHashSet<Long> allGroups = new LinkedHashSet<>();
allGroups.addAll(beforeByGroup.keySet());
allGroups.addAll(afterByGroup.keySet());
int added = 0;
int removed = 0;
int changed = 0;
int unchanged = 0;
List<ScheduleVersionDiffDto.RuleChange> changes = new ArrayList<>();
for (Long groupId : allGroups) {
ScheduleRule before = beforeByGroup.get(groupId);
ScheduleRule after = afterByGroup.get(groupId);
String type;
if (before == null) {
type = "ADDED";
added++;
} else if (after == null) {
type = "REMOVED";
removed++;
} else if (!Objects.equals(ruleFingerprint(before), ruleFingerprint(after))) {
type = "CHANGED";
changed++;
} else {
unchanged++;
continue;
}
ScheduleRule representative = after == null ? before : after;
changes.add(new ScheduleVersionDiffDto.RuleChange(
type,
groupId,
before == null ? null : before.getId(),
after == null ? null : after.getId(),
representative.getSubject().getName(),
representative.getGroups().stream().map(StudentGroup::getName).sorted().toList(),
before == null ? null : ruleSummary(before),
after == null ? null : ruleSummary(after)
));
}
LessonDifference lessons = compareLessons(base, target, beforeRules, afterRules);
return new ScheduleVersionDiffDto(
base.getId(),
versionLabel(base),
target.getId(),
versionLabel(target),
added,
removed,
changed,
unchanged,
lessons.added(),
lessons.removed(),
lessons.changed(),
changes.stream()
.sorted(Comparator.comparing(ScheduleVersionDiffDto.RuleChange::subjectName))
.toList()
);
}
private void cloneRules(ScheduleVersion base, ScheduleVersion target) {
List<ScheduleRule> sourceRules = activeRules(base.getId());
List<ScheduleRule> copies = new ArrayList<>();
for (ScheduleRule source : sourceRules) {
ScheduleRule copy = new ScheduleRule();
copy.setSubject(source.getSubject());
copy.setSemester(source.getSemester());
copy.setScheduleVersion(target);
copy.setVersionGroupId(source.getVersionGroupId() == null ? source.getId() : source.getVersionGroupId());
copy.setChangeReason(source.getChangeReason());
copy.setCreatedBy(currentUser());
copy.setStatus(source.getStatus());
copy.setValidFrom(source.getValidFrom());
copy.setValidTo(source.getValidTo());
copy.setLectureAcademicHours(source.getLectureAcademicHours());
copy.setLaboratoryAcademicHours(source.getLaboratoryAcademicHours());
copy.setPracticeAcademicHours(source.getPracticeAcademicHours());
copy.setLectureStartWeek(source.getLectureStartWeek());
copy.setLaboratoryStartWeek(source.getLaboratoryStartWeek());
copy.setPracticeStartWeek(source.getPracticeStartWeek());
copy.setGroups(new LinkedHashSet<>(source.getGroups()));
LinkedHashSet<ScheduleRuleSlot> copiedSlots = new LinkedHashSet<>();
for (ScheduleRuleSlot sourceSlot : source.getSlots()) {
ScheduleRuleSlot copySlot = new ScheduleRuleSlot();
copySlot.setScheduleRule(copy);
copySlot.setDayOfWeek(sourceSlot.getDayOfWeek());
copySlot.setParity(sourceSlot.getParity());
copySlot.setTimeSlot(sourceSlot.getTimeSlot());
copySlot.setSubgroups(new LinkedHashSet<>(sourceSlot.getSubgroups()));
copySlot.setTeacher(sourceSlot.getTeacher());
copySlot.setClassroom(sourceSlot.getClassroom());
copySlot.setLessonType(sourceSlot.getLessonType());
copySlot.setLessonFormat(sourceSlot.getLessonFormat());
copiedSlots.add(copySlot);
}
copy.setSlots(copiedSlots);
copies.add(copy);
}
ruleRepository.saveAllAndFlush(copies);
}
private void archivePublished(ScheduleVersion version, User actor, String reason, Instant archivedAt) {
version.setStatus(ScheduleVersionStatus.ARCHIVED);
version.setArchivedAt(archivedAt);
versionRepository.save(version);
addHistory(version, "ARCHIVED", actor, reason);
}
private void addHistory(ScheduleVersion version, String action, User actor, String reason) {
ScheduleVersionHistory history = new ScheduleVersionHistory();
history.setVersion(version);
history.setAction(action);
history.setActor(actor);
history.setReason(reason);
historyRepository.save(history);
}
private ScheduleVersion resolveBaseVersion(Long baseVersionId,
Long semesterId,
List<ScheduleVersion> versions) {
ScheduleVersion base = null;
if (baseVersionId != null) {
base = versions.stream()
.filter(version -> Objects.equals(version.getId(), baseVersionId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Базовая версия относится к другому семестру"));
} else {
base = versions.stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.findFirst()
.orElse(null);
}
if (base != null && !Objects.equals(base.getSemester().getId(), semesterId)) {
throw new IllegalArgumentException("Базовая версия относится к другому семестру");
}
return base;
}
private ScheduleVersion resolveDiffBase(ScheduleVersion target, Long requestedBaseVersionId) {
if (requestedBaseVersionId != null) {
ScheduleVersion base = findVersion(requestedBaseVersionId);
if (!Objects.equals(base.getSemester().getId(), target.getSemester().getId())) {
throw new IllegalArgumentException("Сравнивать можно только версии одного семестра");
}
return base;
}
if (target.getBasedOnVersion() != null) {
return findVersion(target.getBasedOnVersion().getId());
}
return versionRepository.findBySemesterIdWithDetails(target.getSemester().getId()).stream()
.filter(version -> version.getStatus() == ScheduleVersionStatus.PUBLISHED)
.filter(version -> !Objects.equals(version.getId(), target.getId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Для этой версии нет базы сравнения"));
}
private LessonDifference compareLessons(ScheduleVersion base,
ScheduleVersion target,
List<ScheduleRule> beforeRules,
List<ScheduleRule> afterRules) {
List<RenderedLessonDto> beforeLessons = loadVersionLessons(base);
List<RenderedLessonDto> afterLessons = loadVersionLessons(target);
Map<Long, Long> beforeGroups = beforeRules.stream().collect(Collectors.toMap(
ScheduleRule::getId,
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
));
Map<Long, Long> afterGroups = afterRules.stream().collect(Collectors.toMap(
ScheduleRule::getId,
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId()
));
Map<LessonIdentity, List<String>> before = indexLessons(beforeLessons, beforeGroups);
Map<LessonIdentity, List<String>> after = indexLessons(afterLessons, afterGroups);
LinkedHashSet<LessonIdentity> keys = new LinkedHashSet<>();
keys.addAll(before.keySet());
keys.addAll(after.keySet());
int added = 0;
int removed = 0;
int changed = 0;
for (LessonIdentity key : keys) {
List<String> oldValues = new ArrayList<>(before.getOrDefault(key, List.of()));
List<String> newValues = new ArrayList<>(after.getOrDefault(key, List.of()));
for (int index = oldValues.size() - 1; index >= 0; index--) {
String value = oldValues.get(index);
int match = newValues.indexOf(value);
if (match >= 0) {
oldValues.remove(index);
newValues.remove(match);
}
}
int changedHere = Math.min(oldValues.size(), newValues.size());
changed += changedHere;
removed += oldValues.size() - changedHere;
added += newValues.size() - changedHere;
}
return new LessonDifference(added, removed, changed);
}
private Map<LessonIdentity, List<String>> indexLessons(List<RenderedLessonDto> lessons,
Map<Long, Long> versionGroups) {
Map<String, RenderedLessonDto> unique = lessons.stream().collect(Collectors.toMap(
this::renderedLessonIdentity,
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
Map<LessonIdentity, List<String>> result = new LinkedHashMap<>();
for (RenderedLessonDto lesson : unique.values()) {
LessonIdentity identity = new LessonIdentity(
versionGroups.getOrDefault(lesson.scheduleRuleId(), lesson.scheduleRuleId()),
lesson.date(),
lesson.lessonTypeId(),
sortedIds(lesson.groupIds()),
sortedIds(lesson.subgroupIds())
);
String value = List.of(
Objects.toString(lesson.timeSlotId(), ""),
Objects.toString(lesson.teacherId(), ""),
Objects.toString(lesson.classroomId(), ""),
Objects.toString(lesson.lessonFormat(), "")
).toString();
result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(value);
}
result.values().forEach(values -> values.sort(String::compareTo));
return result;
}
private List<RenderedLessonDto> loadVersionLessons(ScheduleVersion version) {
List<RenderedLessonDto> lessons = new ArrayList<>();
LocalDate cursor = version.getSemester().getStartDate();
LocalDate semesterEnd = version.getSemester().getEndDate();
while (!cursor.isAfter(semesterEnd)) {
LocalDate chunkEnd = cursor.plusDays(MAX_RANGE_DAYS - 1L);
if (chunkEnd.isAfter(semesterEnd)) {
chunkEnd = semesterEnd;
}
lessons.addAll(generatorService.buildScheduleForVersion(version.getId(), cursor, chunkEnd));
cursor = chunkEnd.plusDays(1);
}
return lessons;
}
private String renderedLessonIdentity(RenderedLessonDto lesson) {
return List.of(
Objects.toString(lesson.scheduleRuleId(), ""),
Objects.toString(lesson.scheduleRuleSlotId(), ""),
Objects.toString(lesson.date(), ""),
sortedIds(lesson.groupIds()).toString(),
sortedIds(lesson.subgroupIds()).toString()
).toString();
}
private Map<Long, ScheduleRule> indexRulesByVersionGroup(List<ScheduleRule> rules) {
return rules.stream().collect(Collectors.toMap(
rule -> rule.getVersionGroupId() == null ? rule.getId() : rule.getVersionGroupId(),
Function.identity(),
(first, ignored) -> first,
LinkedHashMap::new
));
}
private String ruleFingerprint(ScheduleRule rule) {
List<String> slots = rule.getSlots().stream().map(this::slotFingerprint).sorted().toList();
return List.of(
rule.getSubject().getId(),
rule.getLectureAcademicHours(),
rule.getLaboratoryAcademicHours(),
rule.getPracticeAcademicHours(),
rule.getLectureStartWeek(),
rule.getLaboratoryStartWeek(),
rule.getPracticeStartWeek(),
rule.getGroups().stream().map(StudentGroup::getId).sorted().toList(),
slots
).toString();
}
private String slotFingerprint(ScheduleRuleSlot slot) {
return List.of(
slot.getDayOfWeek(),
slot.getParity(),
slot.getTimeSlot().getId(),
slot.getTeacher().getId(),
slot.getClassroom().getId(),
slot.getLessonType().getId(),
slot.getLessonFormat(),
slot.getSubgroups().stream().map(Subgroup::getId).sorted().toList()
).toString();
}
private String ruleSummary(ScheduleRule rule) {
String load = "лекции " + rule.getLectureAcademicHours()
+ " ч., лабораторные " + rule.getLaboratoryAcademicHours()
+ " ч., практики " + rule.getPracticeAcademicHours() + " ч.";
String slots = rule.getSlots().stream()
.sorted(Comparator
.comparing(ScheduleRuleSlot::getDayOfWeek)
.thenComparing(slot -> slot.getTimeSlot().getOrderNumber()))
.limit(4)
.map(slot -> ScheduleGeneratorService.dayName(DayOfWeek.of(slot.getDayOfWeek()))
+ ", " + slot.getTimeSlot().getOrderNumber() + " пара"
+ ", " + ScheduleGeneratorService.displayUserName(slot.getTeacher()))
.collect(Collectors.joining("; "));
return load + " · " + rule.getSlots().size() + " слотов" + (slots.isBlank() ? "" : ": " + slots);
}
private ScheduleVersionDto toDto(ScheduleVersion version) {
List<ScheduleRule> rules = activeRules(version.getId());
return new ScheduleVersionDto(
version.getId(),
version.getSemester().getId(),
semesterLabel(version.getSemester()),
version.getVersionNumber(),
version.getName(),
version.getStatus(),
idOf(version.getBasedOnVersion()),
idOf(version.getRestoredFromVersion()),
version.getChangeReason(),
idOf(version.getCreatedBy()),
nameOf(version.getCreatedBy()),
version.getCreatedAt(),
idOf(version.getPublishedBy()),
nameOf(version.getPublishedBy()),
version.getPublishedAt(),
version.getArchivedAt(),
rules.size(),
rules.stream().mapToLong(rule -> rule.getSlots().size()).sum()
);
}
private ScheduleVersionHistoryDto toHistoryDto(ScheduleVersionHistory history) {
return new ScheduleVersionHistoryDto(
history.getId(),
history.getVersion().getId(),
history.getVersion().getVersionNumber(),
history.getVersion().getName(),
history.getAction(),
idOf(history.getActor()),
nameOf(history.getActor()),
history.getReason(),
history.getCreatedAt()
);
}
private ScheduleVersion findVersion(Long versionId) {
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
return versionRepository.findByIdWithDetails(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
}
private ScheduleVersion lockVersion(Long versionId) {
if (versionId == null) {
throw new IllegalArgumentException("Версия расписания обязательна");
}
return versionRepository.findByIdForUpdate(versionId)
.orElseThrow(() -> new NoSuchElementException("Версия расписания не найдена"));
}
private List<ScheduleRule> activeRules(Long versionId) {
return ruleRepository.findActiveByVersionIdWithDetails(versionId, null);
}
private User currentUser() {
Long currentUserId = AuthContext.currentUserId();
return currentUserId == null ? null : userRepository.findById(currentUserId).orElse(null);
}
private String normalizeName(String name, String fallback) {
String normalized = name == null ? "" : name.trim();
if (normalized.isEmpty()) {
return fallback;
}
if (normalized.length() > 160) {
throw new IllegalArgumentException("Название версии не может быть длиннее 160 символов");
}
return normalized;
}
private String requireReason(String reason, String message) {
String normalized = reason == null ? "" : reason.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(message);
}
if (normalized.length() > MAX_REASON_LENGTH) {
throw new IllegalArgumentException("Причина не может быть длиннее 2000 символов");
}
return normalized;
}
private void requireSemesterId(Long semesterId) {
if (semesterId == null) {
throw new IllegalArgumentException("Семестр обязателен");
}
}
private String semesterLabel(Semester semester) {
return semester.getAcademicYear().getTitle() + " · "
+ (semester.getSemesterType().name().equals("autumn") ? "осенний" : "весенний") + " семестр";
}
private String versionLabel(ScheduleVersion version) {
return "Версия " + version.getVersionNumber() + " · " + version.getName();
}
private Long idOf(ScheduleVersion version) {
return version == null ? null : version.getId();
}
private Long idOf(User user) {
return user == null ? null : user.getId();
}
private String nameOf(User user) {
return user == null ? null : ScheduleGeneratorService.displayUserName(user);
}
private List<Long> sortedIds(List<Long> ids) {
return ids == null ? List.of() : ids.stream().filter(Objects::nonNull).sorted().toList();
}
private record LessonIdentity(
Long versionGroupId,
LocalDate date,
Long lessonTypeId,
List<Long> groupIds,
List<Long> subgroupIds
) {
}
private record LessonDifference(int added, int removed, int changed) {
}
}

View File

@@ -0,0 +1,837 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.TeacherAbsenceDto;
import com.magistr.app.dto.TeacherAbsenceRequest;
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
import com.magistr.app.dto.TeacherAbsenceReviewRequest;
import com.magistr.app.dto.TeacherAbsenceWizardDto;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.LifecycleEntity;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TeacherAbsence;
import com.magistr.app.model.TeacherAbsenceDecision;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.TeacherAbsenceDecisionRepository;
import com.magistr.app.repository.TeacherAbsenceRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class TeacherAbsenceService {
private static final Logger log = LoggerFactory.getLogger(TeacherAbsenceService.class);
private static final Set<String> ACTIVE_ABSENCE_STATUSES = Set.of("PENDING", "APPROVED", "RESOLVED");
private static final Set<String> ALL_STATUSES = Set.of(
"PENDING", "APPROVED", "REJECTED", "RESOLVED", "CANCELLED"
);
private static final Set<String> RESOLUTIONS = Set.of(
"REPLACE_TEACHER", "MOVE_TIME", "CHANGE_CLASSROOM", "CANCEL", "REJECT"
);
private static final int MAX_RANGE_DAYS = 120;
private static final int MAX_DECISIONS = 100;
private static final int CANDIDATE_LIMIT = 5;
private final TeacherAbsenceRepository absenceRepository;
private final TeacherAbsenceDecisionRepository decisionRepository;
private final UserRepository userRepository;
private final TeacherSubjectRepository teacherSubjectRepository;
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final ClassroomRepository classroomRepository;
private final TimeSlotRepository timeSlotRepository;
private final ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private final ScheduleOverrideRepository scheduleOverrideRepository;
private final ScheduleQueryService scheduleQueryService;
private final ScheduleOverrideService scheduleOverrideService;
private final BusinessTimeService businessTime;
public TeacherAbsenceService(
TeacherAbsenceRepository absenceRepository,
TeacherAbsenceDecisionRepository decisionRepository,
UserRepository userRepository,
TeacherSubjectRepository teacherSubjectRepository,
TeacherDepartmentAssignmentRepository assignmentRepository,
ClassroomRepository classroomRepository,
TimeSlotRepository timeSlotRepository,
ScheduleRuleSlotRepository scheduleRuleSlotRepository,
ScheduleOverrideRepository scheduleOverrideRepository,
ScheduleQueryService scheduleQueryService,
ScheduleOverrideService scheduleOverrideService,
BusinessTimeService businessTime
) {
this.absenceRepository = absenceRepository;
this.decisionRepository = decisionRepository;
this.userRepository = userRepository;
this.teacherSubjectRepository = teacherSubjectRepository;
this.assignmentRepository = assignmentRepository;
this.classroomRepository = classroomRepository;
this.timeSlotRepository = timeSlotRepository;
this.scheduleRuleSlotRepository = scheduleRuleSlotRepository;
this.scheduleOverrideRepository = scheduleOverrideRepository;
this.scheduleQueryService = scheduleQueryService;
this.scheduleOverrideService = scheduleOverrideService;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public List<TeacherAbsenceDto> getAll(String status) {
String normalizedStatus = cleanUpper(status);
if (normalizedStatus != null && !ALL_STATUSES.contains(normalizedStatus)) {
throw new IllegalArgumentException("Неизвестный статус отсутствия");
}
return absenceRepository.findAllWithTeacher().stream()
.filter(this::canView)
.filter(absence -> normalizedStatus == null || normalizedStatus.equals(absence.getStatus()))
.map(this::toDto)
.toList();
}
@Transactional
public TeacherAbsenceDto create(TeacherAbsenceRequest request) {
AuthenticatedUser current = currentUser();
if (request == null) {
throw new IllegalArgumentException("Передайте данные отсутствия преподавателя");
}
validateRange(request.startDate(), request.endDate());
String reason = clean(request.reason());
if (reason == null) {
throw new IllegalArgumentException("Причина отсутствия обязательна");
}
if (reason.length() > 2000) {
throw new IllegalArgumentException("Причина отсутствия не может превышать 2000 символов");
}
Long teacherId = current.role() == Role.TEACHER ? current.id() : request.teacherId();
if (teacherId == null) {
throw new IllegalArgumentException("Выберите преподавателя");
}
if (current.role() == Role.TEACHER
&& request.teacherId() != null
&& !Objects.equals(request.teacherId(), current.id())) {
throw forbidden("Преподаватель может зарегистрировать отсутствие только для себя");
}
User teacher = userRepository.findByIdForUpdate(teacherId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(user -> !user.isArchivedRecord())
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
ensureDepartmentScope(teacher, request.startDate(), request.endDate(), current);
if (absenceRepository.existsOverlapping(
teacherId,
ACTIVE_ABSENCE_STATUSES,
request.startDate(),
request.endDate()
)) {
throw new ScheduleConflictException(
"Для преподавателя уже зарегистрировано отсутствие, пересекающееся с выбранным периодом"
);
}
var now = businessTime.now();
TeacherAbsence absence = new TeacherAbsence();
absence.setTeacher(teacher);
absence.setStartDate(request.startDate());
absence.setEndDate(request.endDate());
absence.setReason(reason);
absence.setRequestedBy(current.id());
absence.setCreatedAt(now);
absence.setUpdatedAt(now);
if (current.role() == Role.TEACHER) {
absence.setStatus("PENDING");
} else {
absence.setStatus("APPROVED");
absence.setReviewedBy(current.id());
absence.setReviewedAt(now);
absence.setReviewComment("Зарегистрировано ответственным сотрудником");
}
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
log.info(
"Зарегистрировано отсутствие преподавателя {} на период {}–{}, статус {}",
teacher.getId(),
request.startDate(),
request.endDate(),
saved.getStatus()
);
return toDto(saved);
}
@Transactional
public TeacherAbsenceDto review(Long id, TeacherAbsenceReviewRequest request) {
AuthenticatedUser current = currentUser();
if (current.role() == Role.TEACHER) {
throw forbidden("Преподаватель не может согласовывать собственное отсутствие");
}
if (request == null || request.approved() == null) {
throw new IllegalArgumentException("Укажите решение по заявке на отсутствие");
}
TeacherAbsence absence = getForUpdate(id);
ensureCanManage(absence, current);
if (!"PENDING".equals(absence.getStatus())) {
throw new IllegalArgumentException("Рассмотреть можно только отсутствие со статусом PENDING");
}
var now = businessTime.now();
absence.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED");
absence.setReviewedBy(current.id());
absence.setReviewedAt(now);
absence.setReviewComment(clean(request.comment()));
absence.setUpdatedAt(now);
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
log.info("Отсутствие {} переведено в статус {}", id, saved.getStatus());
return toDto(saved);
}
@Transactional
public TeacherAbsenceDto cancel(Long id) {
AuthenticatedUser current = currentUser();
TeacherAbsence absence = getForUpdate(id);
if (current.role() == Role.TEACHER) {
if (!Objects.equals(current.id(), absence.getTeacher().getId())) {
throw forbidden("Нельзя отменить отсутствие другого преподавателя");
}
if (!"PENDING".equals(absence.getStatus())) {
throw new IllegalArgumentException("Преподаватель может отменить только ожидающую заявку");
}
} else {
ensureCanManage(absence, current);
if (!Set.of("PENDING", "APPROVED").contains(absence.getStatus())) {
throw new IllegalArgumentException("Это отсутствие уже нельзя отменить");
}
if (decisionRepository.countByAbsenceId(id) > 0) {
throw new IllegalArgumentException("Нельзя отменить отсутствие после применения решений");
}
}
absence.setStatus("CANCELLED");
absence.setUpdatedAt(businessTime.now());
TeacherAbsence saved = absenceRepository.saveAndFlush(absence);
log.info("Отсутствие {} отменено", id);
return toDto(saved);
}
@Transactional(readOnly = true)
public TeacherAbsenceWizardDto getWizard(Long id) {
TeacherAbsence absence = getVisible(id);
List<TeacherAbsenceDecision> decisions = decisionRepository.findByAbsenceIdWithDetails(id);
Map<LessonKey, TeacherAbsenceDecision> decisionsByLesson = decisions.stream()
.collect(Collectors.toMap(
decision -> new LessonKey(
decision.getBaseRuleSlot().getId(),
decision.getLessonDate()
),
decision -> decision,
(first, second) -> second,
LinkedHashMap::new
));
List<RenderedLessonDto> lessons = affectedLessons(absence);
List<TeacherAbsenceWizardDto.AffectedLesson> affected = lessons.stream()
.map(lesson -> toAffectedLesson(
absence,
lesson,
decisionsByLesson.get(new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()))
))
.toList();
return new TeacherAbsenceWizardDto(
toDto(absence),
affected,
decisions.stream().map(this::toDecisionLog).toList()
);
}
@Transactional
public TeacherAbsenceWizardDto resolve(Long id, TeacherAbsenceResolutionRequest request) {
AuthenticatedUser current = currentUser();
if (current.role() == Role.TEACHER) {
throw forbidden("Преподаватель не может применять решения мастера замены");
}
TeacherAbsence absence = getForUpdate(id);
ensureCanManage(absence, current);
if (!"APPROVED".equals(absence.getStatus())) {
throw new IllegalArgumentException("Мастер замены доступен только для согласованного отсутствия");
}
List<TeacherAbsenceResolutionRequest.Decision> requested = request == null
|| request.decisions() == null
? List.of()
: request.decisions();
if (requested.size() > MAX_DECISIONS) {
throw new IllegalArgumentException("За один раз можно обработать не более 100 занятий");
}
Map<LessonKey, RenderedLessonDto> affectedByKey = affectedLessons(absence).stream()
.collect(Collectors.toMap(
lesson -> new LessonKey(lesson.scheduleRuleSlotId(), lesson.date()),
lesson -> lesson,
(first, second) -> first,
LinkedHashMap::new
));
Set<LessonKey> processedKeys = decisionRepository.findByAbsenceIdWithDetails(id).stream()
.map(decision -> new LessonKey(
decision.getBaseRuleSlot().getId(),
decision.getLessonDate()
))
.collect(Collectors.toCollection(LinkedHashSet::new));
if (affectedByKey.isEmpty() && requested.isEmpty()) {
absence.setStatus("RESOLVED");
absence.setUpdatedAt(businessTime.now());
absenceRepository.saveAndFlush(absence);
return getWizard(id);
}
if (requested.isEmpty()) {
throw new IllegalArgumentException("Выберите хотя бы одно решение");
}
Set<LessonKey> requestKeys = new LinkedHashSet<>();
for (TeacherAbsenceResolutionRequest.Decision item : requested) {
validateDecisionKey(item);
LessonKey key = new LessonKey(item.baseRuleSlotId(), item.lessonDate());
if (!requestKeys.add(key)) {
throw new IllegalArgumentException("Одно занятие нельзя обработать дважды");
}
RenderedLessonDto lesson = affectedByKey.get(key);
if (lesson == null) {
throw new IllegalArgumentException("Занятие не относится к выбранному отсутствию");
}
if (processedKeys.contains(key)) {
throw new ScheduleConflictException("Для занятия решение уже зафиксировано в журнале");
}
applyDecision(absence, lesson, item, current.id());
processedKeys.add(key);
}
if (affectedByKey.keySet().stream().allMatch(processedKeys::contains)) {
absence.setStatus("RESOLVED");
}
absence.setUpdatedAt(businessTime.now());
absenceRepository.saveAndFlush(absence);
log.info(
"Для отсутствия {} обработано {} занятий, итоговый статус {}",
id,
requested.size(),
absence.getStatus()
);
return getWizard(id);
}
private void applyDecision(
TeacherAbsence absence,
RenderedLessonDto lesson,
TeacherAbsenceResolutionRequest.Decision request,
Long userId
) {
String resolution = cleanUpper(request.resolution());
if (resolution == null || !RESOLUTIONS.contains(resolution)) {
throw new IllegalArgumentException("Неизвестный способ разрешения занятия");
}
if (lesson.scheduleOverrideId() != null && !"REJECT".equals(resolution)) {
throw new ScheduleConflictException(
"У занятия уже есть точечное изменение; откройте его в реестре или отклоните предложение"
);
}
ScheduleOverride savedOverride = null;
if (!"REJECT".equals(resolution)) {
ScheduleOverrideDto overrideRequest = buildOverrideRequest(absence, lesson, request, resolution);
validateQualifiedReplacement(absence, lesson, overrideRequest, resolution);
ScheduleOverrideDto saved = scheduleOverrideService.create(overrideRequest);
savedOverride = scheduleOverrideRepository.findById(saved.id())
.orElseThrow(() -> new IllegalStateException(
"Созданное точечное изменение не найдено для записи в журнал"
));
}
ScheduleRuleSlot baseSlot = scheduleRuleSlotRepository.findById(request.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
TeacherAbsenceDecision decision = new TeacherAbsenceDecision();
decision.setAbsence(absence);
decision.setBaseRuleSlot(baseSlot);
decision.setLessonDate(request.lessonDate());
decision.setResolution(resolution);
decision.setDecisionStatus("REJECT".equals(resolution) ? "REJECTED" : "APPLIED");
decision.setScheduleOverride(savedOverride);
decision.setComment(clean(request.comment()));
decision.setDecidedBy(userId);
decision.setDecidedAt(businessTime.now());
decisionRepository.saveAndFlush(decision);
}
private ScheduleOverrideDto buildOverrideRequest(
TeacherAbsence absence,
RenderedLessonDto lesson,
TeacherAbsenceResolutionRequest.Decision request,
String resolution
) {
String action;
LocalDate targetDate = null;
Long timeSlotId = null;
Long classroomId = null;
Long teacherId = null;
switch (resolution) {
case "REPLACE_TEACHER" -> {
action = "REPLACE";
teacherId = required(request.newTeacherId(), "Выберите преподавателя для замены");
classroomId = request.newClassroomId();
}
case "CHANGE_CLASSROOM" -> {
action = "REPLACE";
classroomId = required(request.newClassroomId(), "Выберите новую аудиторию");
}
case "MOVE_TIME" -> {
action = "MOVE";
timeSlotId = required(request.newTimeSlotId(), "Выберите новое время занятия");
targetDate = request.targetLessonDate();
classroomId = request.newClassroomId();
if (targetDate == null
|| (!targetDate.isBefore(absence.getStartDate())
&& !targetDate.isAfter(absence.getEndDate()))) {
throw new IllegalArgumentException(
"Перенесите занятие на дату вне периода отсутствия преподавателя"
);
}
}
case "CANCEL" -> action = "CANCEL";
default -> throw new IllegalArgumentException("Неизвестный способ разрешения занятия");
}
return new ScheduleOverrideDto(
null,
lesson.scheduleRuleSlotId(),
lesson.date(),
targetDate,
action,
timeSlotId,
classroomId,
teacherId,
null,
"Отсутствие №" + absence.getId() + ": " + absence.getReason(),
null,
null
);
}
private void validateQualifiedReplacement(
TeacherAbsence absence,
RenderedLessonDto lesson,
ScheduleOverrideDto override,
String resolution
) {
if (!"REPLACE_TEACHER".equals(resolution)) {
return;
}
Long teacherId = override.newTeacherId();
List<Long> qualifiedIds = qualifiedTeacherIds(lesson);
if (!qualifiedIds.contains(teacherId)) {
throw new IllegalArgumentException(
"Выбранный преподаватель не подтверждён для дисциплины и типа занятия"
);
}
User replacement = userRepository.findById(teacherId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(user -> user.isActiveOn(lesson.date()))
.orElseThrow(() -> new IllegalArgumentException("Активный преподаватель для замены не найден"));
if (Objects.equals(replacement.getId(), absence.getTeacher().getId())
|| absenceRepository.isTeacherAbsentOn(replacement.getId(), lesson.date())) {
throw new IllegalArgumentException("Выбранный преподаватель отсутствует в дату занятия");
}
}
private TeacherAbsenceWizardDto.AffectedLesson toAffectedLesson(
TeacherAbsence absence,
RenderedLessonDto lesson,
TeacherAbsenceDecision decision
) {
AuthenticatedUser current = currentUser();
boolean canSuggest = "APPROVED".equals(absence.getStatus())
&& current.role() != Role.TEACHER
&& decision == null
&& lesson.scheduleOverrideId() == null;
return new TeacherAbsenceWizardDto.AffectedLesson(
lesson.scheduleRuleSlotId(),
lesson.date(),
lesson.startTime(),
lesson.endTime(),
lesson.subjectId(),
lesson.subjectName(),
lesson.lessonTypeId(),
lesson.lessonTypeName(),
lesson.classroomId(),
lesson.classroomName(),
lesson.groupNames() == null ? List.of() : lesson.groupNames(),
lesson.scheduleOverrideId(),
decision == null ? null : decision.getDecisionStatus(),
canSuggest ? teacherCandidates(absence, lesson) : List.of(),
canSuggest ? moveCandidates(absence, lesson) : List.of(),
canSuggest ? classroomCandidates(absence, lesson) : List.of(),
decision == null
);
}
private List<TeacherAbsenceWizardDto.Candidate> teacherCandidates(
TeacherAbsence absence,
RenderedLessonDto lesson
) {
List<Long> ids = qualifiedTeacherIds(lesson);
if (ids.isEmpty()) {
return List.of();
}
return userRepository.findAllById(ids).stream()
.filter(user -> user.getRole() == Role.TEACHER)
.filter(user -> user.isActiveOn(lesson.date()))
.filter(user -> !Objects.equals(user.getId(), absence.getTeacher().getId()))
.filter(user -> !absenceRepository.isTeacherAbsentOn(user.getId(), lesson.date()))
.sorted(Comparator.comparing(User::getFullName, Comparator.nullsLast(String::compareTo)))
.filter(user -> candidateIsValid(new ScheduleOverrideDto(
null,
lesson.scheduleRuleSlotId(),
lesson.date(),
"REPLACE",
null,
null,
user.getId(),
null,
"Предпросмотр замены преподавателя",
null,
null
)))
.limit(CANDIDATE_LIMIT)
.map(user -> new TeacherAbsenceWizardDto.Candidate(
user.getId(),
displayName(user),
user.getJobTitle()
))
.toList();
}
private List<Long> qualifiedTeacherIds(RenderedLessonDto lesson) {
if (lesson.subjectId() == null || lesson.lessonTypeId() == null) {
return List.of();
}
return teacherSubjectRepository.findQualifiedTeacherIds(
lesson.subjectId(),
lesson.lessonTypeId()
);
}
private List<TeacherAbsenceWizardDto.MoveCandidate> moveCandidates(
TeacherAbsence absence,
RenderedLessonDto lesson
) {
List<LocalDate> dates;
try {
dates = scheduleOverrideService.getAvailability(
lesson.scheduleRuleSlotId(),
lesson.date()
).availableDates();
} catch (RuntimeException exception) {
log.debug(
"Не удалось построить даты переноса для слота {}: {}",
lesson.scheduleRuleSlotId(),
exception.getMessage()
);
return List.of();
}
List<LocalDate> nearestDates = dates.stream()
.filter(date -> date.isBefore(absence.getStartDate()) || date.isAfter(absence.getEndDate()))
.sorted(Comparator
.comparingLong((LocalDate date) -> Math.abs(ChronoUnit.DAYS.between(lesson.date(), date)))
.thenComparing(LocalDate::compareTo))
.limit(7)
.toList();
List<TimeSlot> slots = timeSlotRepository.findAllForSchedule();
List<TeacherAbsenceWizardDto.MoveCandidate> result = new ArrayList<>();
for (LocalDate date : nearestDates) {
for (TimeSlot slot : slots) {
if (candidateIsValid(new ScheduleOverrideDto(
null,
lesson.scheduleRuleSlotId(),
lesson.date(),
date,
"MOVE",
slot.getId(),
null,
null,
null,
"Предпросмотр переноса из-за отсутствия преподавателя",
null,
null
))) {
result.add(new TeacherAbsenceWizardDto.MoveCandidate(
date,
slot.getId(),
slot.getOrderNumber(),
slot.getStartTime(),
slot.getEndTime(),
"Ближайшее допустимое время"
));
if (result.size() >= CANDIDATE_LIMIT) {
return result;
}
}
}
}
return result;
}
private List<TeacherAbsenceWizardDto.Candidate> classroomCandidates(
TeacherAbsence absence,
RenderedLessonDto lesson
) {
return classroomRepository.findByStatusNot(LifecycleEntity.STATUS_ARCHIVED).stream()
.filter(classroom -> !Objects.equals(classroom.getId(), lesson.classroomId()))
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.filter(classroom -> classroom.isActiveOn(lesson.date()))
.sorted(Comparator.comparing(Classroom::getName, Comparator.nullsLast(String::compareTo)))
.filter(classroom -> candidateIsValid(new ScheduleOverrideDto(
null,
lesson.scheduleRuleSlotId(),
lesson.date(),
"REPLACE",
null,
classroom.getId(),
null,
null,
"Предпросмотр замены аудитории",
null,
null
)))
.limit(CANDIDATE_LIMIT)
.map(classroom -> new TeacherAbsenceWizardDto.Candidate(
classroom.getId(),
classroom.getName(),
classroom.getBuilding()
))
.toList();
}
private boolean candidateIsValid(ScheduleOverrideDto request) {
try {
scheduleOverrideService.validateCandidate(request);
return true;
} catch (RuntimeException exception) {
return false;
}
}
private List<RenderedLessonDto> affectedLessons(TeacherAbsence absence) {
return scheduleQueryService.search(
null,
absence.getTeacher().getId(),
null,
null,
null,
null,
null,
null,
absence.getStartDate(),
absence.getEndDate()
).stream()
.filter(lesson -> lesson.scheduleRuleSlotId() != null)
.sorted(Comparator
.comparing(RenderedLessonDto::date)
.thenComparing(
RenderedLessonDto::startTime,
Comparator.nullsLast(Comparator.naturalOrder())
)
.thenComparing(
RenderedLessonDto::subjectName,
Comparator.nullsLast(String::compareTo)
))
.toList();
}
private TeacherAbsence getVisible(Long id) {
TeacherAbsence absence = absenceRepository.findByIdWithTeacher(id)
.orElseThrow(() -> notFound(id));
if (!canView(absence)) {
throw forbidden("Недостаточно прав для просмотра отсутствия");
}
return absence;
}
private TeacherAbsence getForUpdate(Long id) {
return absenceRepository.findByIdForUpdate(id)
.orElseThrow(() -> notFound(id));
}
private boolean canView(TeacherAbsence absence) {
AuthenticatedUser current = currentUser();
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) {
return true;
}
if (current.role() == Role.TEACHER) {
return Objects.equals(current.id(), absence.getTeacher().getId());
}
return current.role() == Role.DEPARTMENT
&& belongsToDepartment(absence, current.departmentId());
}
private void ensureCanManage(TeacherAbsence absence, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) {
return;
}
if (current.role() == Role.DEPARTMENT && belongsToDepartment(absence, current.departmentId())) {
return;
}
throw forbidden("Недостаточно прав для изменения отсутствия");
}
private void ensureDepartmentScope(
User teacher,
LocalDate startDate,
LocalDate endDate,
AuthenticatedUser current
) {
if (current.role() != Role.DEPARTMENT) {
return;
}
if (current.departmentId() == null
|| assignmentRepository.findForTeachersBetween(
Set.of(teacher.getId()),
startDate,
endDate
).stream().noneMatch(assignment -> Objects.equals(
assignment.getDepartment().getId(),
current.departmentId()
))) {
throw forbidden("Кафедра может регистрировать отсутствие только своего преподавателя");
}
}
private boolean belongsToDepartment(TeacherAbsence absence, Long departmentId) {
return departmentId != null
&& assignmentRepository.findForTeachersBetween(
Set.of(absence.getTeacher().getId()),
absence.getStartDate(),
absence.getEndDate()
).stream().anyMatch(assignment -> Objects.equals(
assignment.getDepartment().getId(),
departmentId
));
}
private TeacherAbsenceDto toDto(TeacherAbsence absence) {
User teacher = absence.getTeacher();
return new TeacherAbsenceDto(
absence.getId(),
teacher == null ? null : teacher.getId(),
teacher == null ? null : displayName(teacher),
teacher == null ? null : teacher.getDepartmentId(),
absence.getStartDate(),
absence.getEndDate(),
absence.getReason(),
absence.getStatus(),
absence.getRequestedBy(),
absence.getReviewedBy(),
absence.getReviewComment(),
absence.getReviewedAt(),
absence.getCreatedAt(),
absence.getUpdatedAt(),
absence.getId() == null ? 0 : decisionRepository.countByAbsenceId(absence.getId())
);
}
private TeacherAbsenceWizardDto.DecisionLog toDecisionLog(TeacherAbsenceDecision decision) {
return new TeacherAbsenceWizardDto.DecisionLog(
decision.getId(),
decision.getBaseRuleSlot().getId(),
decision.getLessonDate(),
decision.getResolution(),
decision.getDecisionStatus(),
decision.getScheduleOverride() == null ? null : decision.getScheduleOverride().getId(),
decision.getComment(),
decision.getDecidedBy(),
decision.getDecidedAt()
);
}
private void validateRange(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("Дата начала и дата окончания отсутствия обязательны");
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Дата окончания отсутствия не может быть раньше даты начала");
}
if (ChronoUnit.DAYS.between(startDate, endDate) + 1 > MAX_RANGE_DAYS) {
throw new IllegalArgumentException("Период одного отсутствия не может превышать 120 дней");
}
}
private void validateDecisionKey(TeacherAbsenceResolutionRequest.Decision decision) {
if (decision == null || decision.baseRuleSlotId() == null || decision.lessonDate() == null) {
throw new IllegalArgumentException("Для решения укажите занятие и исходную дату");
}
}
private AuthenticatedUser currentUser() {
AuthenticatedUser current = AuthContext.getCurrentUser();
if (current == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Не удалось определить пользователя");
}
return current;
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private NoSuchElementException notFound(Long id) {
return new NoSuchElementException("Отсутствие преподавателя с идентификатором " + id + " не найдено");
}
private <T> T required(T value, String message) {
if (value == null) {
throw new IllegalArgumentException(message);
}
return value;
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String cleanUpper(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
}
private String displayName(User user) {
if (user == null) {
return "Преподаватель не указан";
}
if (user.getFullName() != null && !user.getFullName().isBlank()) {
return user.getFullName();
}
return user.getUsername();
}
private record LessonKey(Long baseRuleSlotId, LocalDate lessonDate) {
}
}

View File

@@ -0,0 +1,106 @@
package com.magistr.app.service;
import com.magistr.app.model.Semester;
import com.magistr.app.model.TeacherPreference;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.TeacherPreferenceRepository;
import com.magistr.app.repository.TimeSlotRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherAvailabilityPolicyService {
private final TeacherPreferenceRepository preferenceRepository;
private final TimeSlotRepository timeSlotRepository;
public TeacherAvailabilityPolicyService(
TeacherPreferenceRepository preferenceRepository,
TimeSlotRepository timeSlotRepository
) {
this.preferenceRepository = preferenceRepository;
this.timeSlotRepository = timeSlotRepository;
}
@Transactional(readOnly = true)
public void assertLessonAvailable(Long teacherId, Long semesterId, LocalDate date, Long timeSlotId) {
if (teacherId == null || semesterId == null || date == null || timeSlotId == null) return;
TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId)
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
List<TeacherPreference> constraints = preferenceRepository
.findApprovedForTeacherAndSemester(teacherId, semesterId);
TeacherPreference conflict = constraints.stream()
.filter(this::isHard)
.filter(preference -> conflicts(preference, date, candidateSlot))
.findFirst()
.orElse(null);
if (conflict != null) {
throw new ScheduleConflictException(conflict.getPreferenceDate() == null
? "Преподаватель отметил строгую недоступность в выбранный день и время"
: "Преподаватель отметил полную недоступность на выбранную дату");
}
}
@Transactional(readOnly = true)
public void assertRuleSlotAvailable(
Long teacherId,
Semester semester,
Integer dayOfWeek,
Long timeSlotId,
Set<Integer> activeWeeks
) {
if (teacherId == null || semester == null || dayOfWeek == null || timeSlotId == null) return;
TimeSlot candidateSlot = timeSlotRepository.findById(timeSlotId)
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
List<TeacherPreference> constraints = preferenceRepository
.findApprovedForTeacherAndSemester(teacherId, semester.getId());
for (TeacherPreference preference : constraints) {
if (!isHard(preference)) continue;
if (preference.getPreferenceDate() == null) {
if (Objects.equals(preference.getDayOfWeek(), dayOfWeek)
&& sameOrder(preference.getTimeSlot(), candidateSlot)) {
throw unavailableRuleConflict();
}
continue;
}
LocalDate date = preference.getPreferenceDate();
if (date.getDayOfWeek().getValue() != dayOfWeek) continue;
int week = Math.toIntExact(ChronoUnit.DAYS.between(semester.getStartDate(), date) / 7 + 1);
if (activeWeeks == null || activeWeeks.contains(week)) {
throw unavailableRuleConflict();
}
}
}
private ScheduleRuleConflictException unavailableRuleConflict() {
return new ScheduleRuleConflictException(
"Невозможно сохранить правило: преподаватель строго недоступен в одном из выбранных интервалов",
null,
List.of("teacherAvailability"),
List.of("Строгая недоступность преподавателя")
);
}
private boolean conflicts(TeacherPreference preference, LocalDate date, TimeSlot candidateSlot) {
if (preference.getPreferenceDate() != null) {
return Objects.equals(preference.getPreferenceDate(), date);
}
return Objects.equals(preference.getDayOfWeek(), date.getDayOfWeek().getValue())
&& sameOrder(preference.getTimeSlot(), candidateSlot);
}
private boolean sameOrder(TimeSlot first, TimeSlot second) {
return first != null && second != null && Objects.equals(first.getOrderNumber(), second.getOrderNumber());
}
private boolean isHard(TeacherPreference preference) {
return "APPROVED".equals(preference.getStatus())
&& "HARD_UNAVAILABLE".equals(preference.getPreferenceType());
}
}

View File

@@ -0,0 +1,493 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.dto.ScheduleOverrideDto;
import com.magistr.app.dto.TeacherChangeCandidatesDto;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.dto.TeacherChangeRequestPayload;
import com.magistr.app.dto.TeacherChangeReviewRequest;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.Role;
import com.magistr.app.model.ScheduleOverride;
import com.magistr.app.model.ScheduleRuleSlot;
import com.magistr.app.model.TeacherChangeRequest;
import com.magistr.app.model.TeacherChangeRequestHistory;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
import com.magistr.app.repository.ScheduleRuleSlotRepository;
import com.magistr.app.repository.TeacherChangeRequestHistoryRepository;
import com.magistr.app.repository.TeacherChangeRequestRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherChangeRequestService {
private static final Logger log = LoggerFactory.getLogger(TeacherChangeRequestService.class);
private static final Set<String> TYPES = Set.of("MOVE", "CHANGE_CLASSROOM", "CANCEL");
private static final Set<String> STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED");
private final TeacherChangeRequestRepository requestRepository;
private final TeacherChangeRequestHistoryRepository historyRepository;
private final ScheduleRuleSlotRepository ruleSlotRepository;
private final ScheduleOverrideRepository overrideRepository;
private final ClassroomRepository classroomRepository;
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final ScheduleQueryService scheduleQueryService;
private final ScheduleOverrideService scheduleOverrideService;
private final EffectiveTimeSlotService effectiveTimeSlotService;
private final BusinessTimeService businessTime;
public TeacherChangeRequestService(
TeacherChangeRequestRepository requestRepository,
TeacherChangeRequestHistoryRepository historyRepository,
ScheduleRuleSlotRepository ruleSlotRepository,
ScheduleOverrideRepository overrideRepository,
ClassroomRepository classroomRepository,
TeacherDepartmentAssignmentRepository assignmentRepository,
ScheduleQueryService scheduleQueryService,
ScheduleOverrideService scheduleOverrideService,
EffectiveTimeSlotService effectiveTimeSlotService,
BusinessTimeService businessTime
) {
this.requestRepository = requestRepository;
this.historyRepository = historyRepository;
this.ruleSlotRepository = ruleSlotRepository;
this.overrideRepository = overrideRepository;
this.classroomRepository = classroomRepository;
this.assignmentRepository = assignmentRepository;
this.scheduleQueryService = scheduleQueryService;
this.scheduleOverrideService = scheduleOverrideService;
this.effectiveTimeSlotService = effectiveTimeSlotService;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public List<TeacherChangeRequestDto> getAll(String status) {
AuthenticatedUser current = currentUser();
String normalizedStatus = cleanUpper(status);
if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) {
throw new IllegalArgumentException("Неизвестный статус заявки на изменение расписания");
}
return requestRepository.findAllWithDetails().stream()
.filter(request -> normalizedStatus == null || normalizedStatus.equals(request.getStatus()))
.filter(request -> canView(request, current))
.map(this::toDto)
.toList();
}
@Transactional(readOnly = true)
public TeacherChangeCandidatesDto getCandidates(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetDate
) {
AuthenticatedUser current = currentUser();
RenderedLessonDto lesson = sourceLesson(baseRuleSlotId, lessonDate);
ensureCanUseLesson(lesson, current);
var availability = scheduleOverrideService.getAvailability(baseRuleSlotId, lessonDate);
LocalDate checkedDate = targetDate == null ? lessonDate : targetDate;
if (!availability.availableDates().contains(checkedDate)) {
throw new IllegalArgumentException("Целевая дата недоступна для этого правила расписания");
}
List<TeacherChangeCandidatesDto.CandidateOption> timeSlots = effectiveTimeSlotService.findEffective(checkedDate)
.stream()
.sorted(Comparator.comparing(TimeSlot::getOrderNumber).thenComparing(TimeSlot::getStartTime))
.map(slot -> candidateOption(
slot.getId(),
slot.getOrderNumber() + " пара · " + shortTime(slot),
buildOverride(
baseRuleSlotId,
lessonDate,
checkedDate,
"MOVE",
slot.getId(),
null,
"Предварительная проверка заявки преподавателя"
)
))
.toList();
List<TeacherChangeCandidatesDto.CandidateOption> classrooms = classroomRepository.findByStatusNot("ARCHIVED")
.stream()
.filter(classroom -> Boolean.TRUE.equals(classroom.getIsAvailable()))
.sorted(Comparator.comparing(Classroom::getName))
.map(classroom -> candidateOption(
classroom.getId(),
classroom.getName(),
buildOverride(
baseRuleSlotId,
lessonDate,
lessonDate,
"REPLACE",
null,
classroom.getId(),
"Предварительная проверка заявки преподавателя"
)
))
.toList();
return new TeacherChangeCandidatesDto(
lesson,
availability.semesterId(),
availability.semesterStartDate(),
availability.semesterEndDate(),
availability.availableDates(),
checkedDate,
timeSlots,
classrooms
);
}
@Transactional
public TeacherChangeRequestDto create(TeacherChangeRequestPayload payload) {
AuthenticatedUser current = currentUser();
if (current.role() != Role.TEACHER) {
throw forbidden("Заявку на изменение занятия отправляет преподаватель");
}
if (payload == null || payload.baseRuleSlotId() == null || payload.lessonDate() == null) {
throw new IllegalArgumentException("Выберите занятие для заявки");
}
String type = cleanUpper(payload.requestType());
if (type == null || !TYPES.contains(type)) {
throw new IllegalArgumentException("Неизвестный тип изменения занятия");
}
String reason = clean(payload.reason());
if (reason == null) {
throw new IllegalArgumentException("Обоснование заявки обязательно");
}
if (reason.length() > 2000) {
throw new IllegalArgumentException("Обоснование не может превышать 2000 символов");
}
RenderedLessonDto lesson = sourceLesson(payload.baseRuleSlotId(), payload.lessonDate());
ensureCanUseLesson(lesson, current);
if (requestRepository.existsByBaseRuleSlotIdAndLessonDateAndStatus(
payload.baseRuleSlotId(), payload.lessonDate(), "PENDING")) {
throw new ScheduleConflictException("По выбранному занятию уже есть заявка на рассмотрении");
}
ScheduleRuleSlot baseSlot = ruleSlotRepository.findById(payload.baseRuleSlotId())
.orElseThrow(() -> new IllegalArgumentException("Базовый слот расписания не найден"));
ScheduleOverrideDto candidate = validatePayload(payload, type, lesson);
scheduleOverrideService.validateCandidate(candidate);
var now = businessTime.now();
TeacherChangeRequest request = new TeacherChangeRequest();
request.setTeacher(baseSlot.getTeacher());
request.setBaseRuleSlot(baseSlot);
request.setLessonDate(payload.lessonDate());
request.setRequestType(type);
request.setTargetLessonDate("MOVE".equals(type) ? candidate.targetLessonDate() : null);
request.setRequestedTimeSlot(candidate.newTimeSlotId() == null
? null
: effectiveTimeSlotService.findEffective(
candidate.targetLessonDate() == null ? payload.lessonDate() : candidate.targetLessonDate())
.stream()
.filter(slot -> Objects.equals(slot.getId(), candidate.newTimeSlotId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Выбранный временной слот недоступен на целевую дату")));
request.setRequestedClassroom(candidate.newClassroomId() == null
? null
: classroomRepository.findById(candidate.newClassroomId())
.orElseThrow(() -> new IllegalArgumentException("Аудитория не найдена")));
request.setReason(reason);
request.setStatus("PENDING");
request.setCreatedAt(now);
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, "PENDING", reason, current.id(), now);
log.info("Преподаватель {} отправил заявку {} по занятию {} на {}",
current.id(), saved.getId(), payload.baseRuleSlotId(), payload.lessonDate());
return toDto(saved);
}
@Transactional
public TeacherChangeRequestDto review(Long id, TeacherChangeReviewRequest review) {
AuthenticatedUser current = currentUser();
if (current.role() != Role.ADMIN && current.role() != Role.EDUCATION_OFFICE) {
throw forbidden("Применять заявки на изменение расписания может только учебный отдел");
}
if (review == null || review.approved() == null) {
throw new IllegalArgumentException("Укажите решение по заявке");
}
TeacherChangeRequest request = requestRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена"));
if (!"PENDING".equals(request.getStatus())) {
throw new IllegalArgumentException("Рассмотреть можно только ожидающую заявку");
}
String comment = clean(review.comment());
if (!Boolean.TRUE.equals(review.approved()) && comment == null) {
throw new IllegalArgumentException("При отклонении укажите комментарий");
}
var now = businessTime.now();
if (Boolean.TRUE.equals(review.approved())) {
ScheduleOverrideDto applied = scheduleOverrideService.create(toOverride(request, comment));
ScheduleOverride override = overrideRepository.findById(applied.id())
.orElseThrow(() -> new IllegalStateException("Применённое изменение расписания не найдено"));
request.setAppliedOverride(override);
request.setStatus("APPROVED");
} else {
request.setStatus("REJECTED");
}
request.setReviewedBy(current.id());
request.setReviewComment(comment);
request.setReviewedAt(now);
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, saved.getStatus(), comment, current.id(), now);
log.info("Заявка преподавателя {} переведена в статус {}", id, saved.getStatus());
return toDto(saved);
}
@Transactional
public TeacherChangeRequestDto cancel(Long id) {
AuthenticatedUser current = currentUser();
TeacherChangeRequest request = requestRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Заявка на изменение расписания не найдена"));
if (current.role() != Role.TEACHER || !Objects.equals(current.id(), request.getTeacher().getId())) {
throw forbidden("Отменить заявку может только отправивший её преподаватель");
}
if (!"PENDING".equals(request.getStatus())) {
throw new IllegalArgumentException("Отменить можно только ожидающую заявку");
}
var now = businessTime.now();
request.setStatus("CANCELLED");
request.setUpdatedAt(now);
TeacherChangeRequest saved = requestRepository.saveAndFlush(request);
addHistory(saved, "CANCELLED", "Заявка отозвана преподавателем", current.id(), now);
return toDto(saved);
}
private ScheduleOverrideDto validatePayload(
TeacherChangeRequestPayload payload,
String type,
RenderedLessonDto lesson
) {
if ("MOVE".equals(type)) {
if (payload.requestedTimeSlotId() == null) {
throw new IllegalArgumentException("Для переноса выберите новую пару");
}
if (payload.requestedClassroomId() != null) {
throw new IllegalArgumentException("Смену аудитории оформите отдельным типом заявки");
}
LocalDate targetDate = payload.targetLessonDate() == null ? payload.lessonDate() : payload.targetLessonDate();
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), targetDate, "MOVE",
payload.requestedTimeSlotId(), null, payload.reason());
}
if ("CHANGE_CLASSROOM".equals(type)) {
if (payload.requestedClassroomId() == null) {
throw new IllegalArgumentException("Выберите новую аудиторию");
}
if (payload.targetLessonDate() != null || payload.requestedTimeSlotId() != null) {
throw new IllegalArgumentException("Для смены аудитории дата и время не изменяются");
}
if (Objects.equals(payload.requestedClassroomId(), lesson.classroomId())) {
throw new IllegalArgumentException("Новая аудитория должна отличаться от текущей");
}
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "REPLACE",
null, payload.requestedClassroomId(), payload.reason());
}
if (payload.targetLessonDate() != null
|| payload.requestedTimeSlotId() != null
|| payload.requestedClassroomId() != null) {
throw new IllegalArgumentException("Для отмены занятия новые параметры не указываются");
}
return buildOverride(payload.baseRuleSlotId(), payload.lessonDate(), payload.lessonDate(), "CANCEL",
null, null, payload.reason());
}
private TeacherChangeCandidatesDto.CandidateOption candidateOption(
Long id,
String label,
ScheduleOverrideDto candidate
) {
try {
scheduleOverrideService.validateCandidate(candidate);
return new TeacherChangeCandidatesDto.CandidateOption(id, label, true, null);
} catch (RuntimeException exception) {
return new TeacherChangeCandidatesDto.CandidateOption(id, label, false, exception.getMessage());
}
}
private ScheduleOverrideDto buildOverride(
Long baseRuleSlotId,
LocalDate lessonDate,
LocalDate targetDate,
String action,
Long timeSlotId,
Long classroomId,
String comment
) {
LocalDate storedTargetDate = Objects.equals(lessonDate, targetDate) ? null : targetDate;
return new ScheduleOverrideDto(
null,
baseRuleSlotId,
lessonDate,
storedTargetDate,
action,
timeSlotId,
classroomId,
null,
null,
comment,
null,
null
);
}
private ScheduleOverrideDto toOverride(TeacherChangeRequest request, String reviewComment) {
String comment = "Одобрена заявка преподавателя №" + request.getId() + ": " + request.getReason();
if (reviewComment != null) comment += ". Решение: " + reviewComment;
String action = switch (request.getRequestType()) {
case "MOVE" -> "MOVE";
case "CHANGE_CLASSROOM" -> "REPLACE";
case "CANCEL" -> "CANCEL";
default -> throw new IllegalStateException("Неизвестный тип заявки");
};
return buildOverride(
request.getBaseRuleSlot().getId(),
request.getLessonDate(),
request.getTargetLessonDate() == null ? request.getLessonDate() : request.getTargetLessonDate(),
action,
request.getRequestedTimeSlot() == null ? null : request.getRequestedTimeSlot().getId(),
request.getRequestedClassroom() == null ? null : request.getRequestedClassroom().getId(),
comment
);
}
private RenderedLessonDto sourceLesson(Long baseRuleSlotId, LocalDate lessonDate) {
if (baseRuleSlotId == null || lessonDate == null) {
throw new IllegalArgumentException("Базовый слот и дата занятия обязательны");
}
if (overrideRepository.findByBaseRuleSlotIdAndLessonDate(baseRuleSlotId, lessonDate).isPresent()) {
throw new ScheduleConflictException("Для занятия уже существует точечное изменение расписания");
}
return scheduleQueryService.buildBaseDayForAllGroups(lessonDate).stream()
.filter(lesson -> Objects.equals(lesson.scheduleRuleSlotId(), baseRuleSlotId))
.filter(lesson -> Objects.equals(lesson.date(), lessonDate))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Выбранное занятие не найдено в базовом расписании"));
}
private void ensureCanUseLesson(RenderedLessonDto lesson, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return;
if (current.role() == Role.TEACHER && Objects.equals(current.id(), lesson.teacherId())) return;
if (current.role() == Role.DEPARTMENT
&& current.departmentId() != null
&& assignmentRepository.existsActiveAssignment(
lesson.teacherId(), current.departmentId(), lesson.date())) return;
throw forbidden("Выбранное занятие находится вне доступной области");
}
private boolean canView(TeacherChangeRequest request, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true;
if (current.role() == Role.TEACHER) {
return Objects.equals(current.id(), request.getTeacher().getId());
}
return current.role() == Role.DEPARTMENT
&& current.departmentId() != null
&& assignmentRepository.existsActiveAssignment(
request.getTeacher().getId(), current.departmentId(), request.getLessonDate());
}
private void addHistory(
TeacherChangeRequest request,
String status,
String comment,
Long actorId,
java.time.Instant now
) {
TeacherChangeRequestHistory history = new TeacherChangeRequestHistory();
history.setRequest(request);
history.setStatus(status);
history.setComment(comment);
history.setActorId(actorId);
history.setCreatedAt(now);
historyRepository.save(history);
}
private TeacherChangeRequestDto toDto(TeacherChangeRequest request) {
ScheduleRuleSlot source = request.getBaseRuleSlot();
TimeSlot sourceSlot = source.getTimeSlot();
TimeSlot targetSlot = request.getRequestedTimeSlot();
Classroom sourceClassroom = source.getClassroom();
Classroom targetClassroom = request.getRequestedClassroom();
List<TeacherChangeRequestDto.HistoryItem> history = historyRepository
.findByRequestIdOrderByCreatedAtAscIdAsc(request.getId()).stream()
.map(item -> new TeacherChangeRequestDto.HistoryItem(
item.getId(), item.getStatus(), item.getComment(), item.getActorId(), item.getCreatedAt()))
.toList();
return new TeacherChangeRequestDto(
request.getId(),
request.getTeacher().getId(),
ScheduleGeneratorService.displayUserName(request.getTeacher()),
request.getTeacher().getDepartmentId(),
source.getId(),
request.getLessonDate(),
source.getScheduleRule().getSubject().getName(),
source.getLessonType().getLessonType(),
source.getScheduleRule().getGroups().stream().map(group -> group.getName()).sorted().toList(),
sourceSlot.getId(),
sourceSlot.getOrderNumber(),
sourceSlot.getStartTime(),
sourceSlot.getEndTime(),
sourceClassroom.getId(),
sourceClassroom.getName(),
request.getRequestType(),
request.getTargetLessonDate(),
targetSlot == null ? null : targetSlot.getId(),
targetSlot == null ? null : targetSlot.getOrderNumber(),
targetSlot == null ? null : targetSlot.getStartTime(),
targetSlot == null ? null : targetSlot.getEndTime(),
targetClassroom == null ? null : targetClassroom.getId(),
targetClassroom == null ? null : targetClassroom.getName(),
request.getReason(),
request.getStatus(),
request.getReviewedBy(),
request.getReviewComment(),
request.getReviewedAt(),
request.getAppliedOverride() == null ? null : request.getAppliedOverride().getId(),
request.getCreatedAt(),
request.getUpdatedAt(),
history
);
}
private String shortTime(TimeSlot slot) {
return slot.getStartTime().toString() + "–" + slot.getEndTime();
}
private AuthenticatedUser currentUser() {
AuthenticatedUser current = AuthContext.getCurrentUser();
if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация");
return current;
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String cleanUpper(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
}
}

View File

@@ -136,6 +136,23 @@ public class TeacherDepartmentService {
.findFirst();
}
public boolean hasDepartmentAssignmentAtDate(
Collection<TeacherDepartmentAssignment> assignments,
Long departmentId,
LocalDate date
) {
if (assignments == null || departmentId == null || date == null) {
return false;
}
return assignments.stream()
.filter(Objects::nonNull)
.filter(assignment -> isActiveAt(assignment, date))
.map(TeacherDepartmentAssignment::getDepartment)
.filter(Objects::nonNull)
.map(Department::getId)
.anyMatch(departmentId::equals);
}
@Transactional
public TeacherDepartmentAssignment transferTeacher(Long teacherId,
DepartmentTransferRequest request,

View File

@@ -0,0 +1,368 @@
package com.magistr.app.service;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.AuthenticatedUser;
import com.magistr.app.dto.SemesterDto;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.dto.TeacherPreferenceMetaDto;
import com.magistr.app.dto.TeacherPreferenceRequest;
import com.magistr.app.dto.TeacherPreferenceReviewRequest;
import com.magistr.app.dto.TimeSlotDto;
import com.magistr.app.model.Role;
import com.magistr.app.model.Semester;
import com.magistr.app.model.TeacherPreference;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.model.User;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.TeacherDepartmentAssignmentRepository;
import com.magistr.app.repository.TeacherPreferenceRepository;
import com.magistr.app.repository.TimeSlotRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
@Service
public class TeacherPreferenceService {
private static final Logger log = LoggerFactory.getLogger(TeacherPreferenceService.class);
private static final Set<String> TYPES = Set.of(
"HARD_UNAVAILABLE", "SOFT_PREFERRED", "SOFT_UNWANTED", "CONSECUTIVE", "NO_GAPS"
);
private static final Set<String> STATUSES = Set.of("PENDING", "APPROVED", "REJECTED", "CANCELLED");
private static final Set<String> ACTIVE_STATUSES = Set.of("PENDING", "APPROVED");
private final TeacherPreferenceRepository preferenceRepository;
private final UserRepository userRepository;
private final SemesterRepository semesterRepository;
private final TimeSlotRepository timeSlotRepository;
private final TeacherDepartmentAssignmentRepository assignmentRepository;
private final BusinessTimeService businessTime;
public TeacherPreferenceService(
TeacherPreferenceRepository preferenceRepository,
UserRepository userRepository,
SemesterRepository semesterRepository,
TimeSlotRepository timeSlotRepository,
TeacherDepartmentAssignmentRepository assignmentRepository,
BusinessTimeService businessTime
) {
this.preferenceRepository = preferenceRepository;
this.userRepository = userRepository;
this.semesterRepository = semesterRepository;
this.timeSlotRepository = timeSlotRepository;
this.assignmentRepository = assignmentRepository;
this.businessTime = businessTime;
}
@Transactional(readOnly = true)
public TeacherPreferenceMetaDto getMeta() {
List<SemesterDto> semesters = semesterRepository.findAll().stream()
.sorted(Comparator.comparing(Semester::getStartDate).reversed())
.map(semester -> new SemesterDto(
semester.getId(),
semester.getAcademicYear().getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType(),
semester.getStartDate(),
semester.getEndDate()
))
.toList();
List<TimeSlotDto> timeSlots = timeSlotRepository.findAllForSchedule().stream()
.filter(slot -> slot.getTimeSlotScope() != null)
.filter(slot -> "DEFAULT".equals(slot.getTimeSlotScope().getApplyMode()))
.map(this::toTimeSlotDto)
.toList();
return new TeacherPreferenceMetaDto(semesters, timeSlots);
}
@Transactional(readOnly = true)
public List<TeacherPreferenceDto> getAll(Long semesterId, String status) {
AuthenticatedUser current = currentUser();
String normalizedStatus = cleanUpper(status);
if (normalizedStatus != null && !STATUSES.contains(normalizedStatus)) {
throw new IllegalArgumentException("Неизвестный статус пожелания преподавателя");
}
return preferenceRepository.findAllWithDetails().stream()
.filter(thisPreference -> semesterId == null
|| Objects.equals(thisPreference.getSemester().getId(), semesterId))
.filter(thisPreference -> normalizedStatus == null
|| normalizedStatus.equals(thisPreference.getStatus()))
.filter(thisPreference -> canView(thisPreference, current))
.map(this::toDto)
.toList();
}
@Transactional
public TeacherPreferenceDto create(TeacherPreferenceRequest request) {
AuthenticatedUser current = currentUser();
if (request == null) {
throw new IllegalArgumentException("Передайте данные пожелания преподавателя");
}
String type = cleanUpper(request.preferenceType());
if (type == null || !TYPES.contains(type)) {
throw new IllegalArgumentException("Неизвестный тип пожелания преподавателя");
}
if (request.semesterId() == null) {
throw new IllegalArgumentException("Выберите семестр");
}
Semester semester = semesterRepository.findById(request.semesterId())
.orElseThrow(() -> new IllegalArgumentException("Семестр не найден"));
Long teacherId = current.role() == Role.TEACHER ? current.id() : request.teacherId();
if (teacherId == null) {
throw new IllegalArgumentException("Выберите преподавателя");
}
if (current.role() == Role.TEACHER
&& request.teacherId() != null
&& !Objects.equals(request.teacherId(), current.id())) {
throw forbidden("Преподаватель может создавать пожелания только для себя");
}
User teacher = userRepository.findById(teacherId)
.filter(user -> user.getRole() == Role.TEACHER)
.filter(User::isActiveRecord)
.orElseThrow(() -> new IllegalArgumentException("Преподаватель не найден"));
ensureManageScope(teacher, semester, current);
PreferencePayload payload = validatePayload(type, semester, request);
String comment = clean(request.comment());
if (comment != null && comment.length() > 2000) {
throw new IllegalArgumentException("Комментарий не может превышать 2000 символов");
}
if (preferenceRepository.existsDuplicate(
teacherId,
semester.getId(),
type,
payload.dayOfWeek(),
payload.timeSlot() == null ? null : payload.timeSlot().getId(),
payload.preferenceDate(),
ACTIVE_STATUSES
)) {
throw new ScheduleConflictException("Такое пожелание уже ожидает решения или согласовано");
}
var now = businessTime.now();
TeacherPreference preference = new TeacherPreference();
preference.setTeacher(teacher);
preference.setSemester(semester);
preference.setPreferenceType(type);
preference.setDayOfWeek(payload.dayOfWeek());
preference.setTimeSlot(payload.timeSlot());
preference.setPreferenceDate(payload.preferenceDate());
preference.setComment(comment);
preference.setRequestedBy(current.id());
preference.setCreatedAt(now);
preference.setUpdatedAt(now);
if (current.role() == Role.TEACHER) {
preference.setStatus("PENDING");
} else {
preference.setStatus("APPROVED");
preference.setReviewedBy(current.id());
preference.setReviewedAt(now);
preference.setReviewComment("Добавлено ответственным сотрудником");
}
TeacherPreference saved = preferenceRepository.saveAndFlush(preference);
log.info("Создано пожелание преподавателя {} на семестр {}, тип {}", teacherId, semester.getId(), type);
return toDto(saved);
}
@Transactional
public TeacherPreferenceDto review(Long id, TeacherPreferenceReviewRequest request) {
AuthenticatedUser current = currentUser();
if (current.role() == Role.TEACHER) {
throw forbidden("Преподаватель не может согласовывать пожелания");
}
if (request == null || request.approved() == null) {
throw new IllegalArgumentException("Укажите решение по пожеланию преподавателя");
}
TeacherPreference preference = getForUpdate(id);
ensureManageScope(preference.getTeacher(), preference.getSemester(), current);
if (!"PENDING".equals(preference.getStatus())) {
throw new IllegalArgumentException("Рассмотреть можно только ожидающее пожелание");
}
String comment = clean(request.comment());
if (!Boolean.TRUE.equals(request.approved()) && comment == null) {
throw new IllegalArgumentException("При отклонении укажите комментарий");
}
var now = businessTime.now();
preference.setStatus(Boolean.TRUE.equals(request.approved()) ? "APPROVED" : "REJECTED");
preference.setReviewedBy(current.id());
preference.setReviewComment(comment);
preference.setReviewedAt(now);
preference.setUpdatedAt(now);
TeacherPreference saved = preferenceRepository.saveAndFlush(preference);
log.info("Пожелание преподавателя {} переведено в статус {}", id, saved.getStatus());
return toDto(saved);
}
@Transactional
public TeacherPreferenceDto cancel(Long id) {
AuthenticatedUser current = currentUser();
TeacherPreference preference = getForUpdate(id);
if (current.role() == Role.TEACHER) {
if (!Objects.equals(current.id(), preference.getTeacher().getId())) {
throw forbidden("Нельзя отменить пожелание другого преподавателя");
}
if (!"PENDING".equals(preference.getStatus())) {
throw new IllegalArgumentException("Преподаватель может отменить только ожидающее пожелание");
}
} else {
ensureManageScope(preference.getTeacher(), preference.getSemester(), current);
if (!Set.of("PENDING", "APPROVED").contains(preference.getStatus())) {
throw new IllegalArgumentException("Это пожелание уже нельзя отменить");
}
}
preference.setStatus("CANCELLED");
preference.setUpdatedAt(businessTime.now());
return toDto(preferenceRepository.saveAndFlush(preference));
}
private PreferencePayload validatePayload(String type, Semester semester, TeacherPreferenceRequest request) {
boolean compact = Set.of("CONSECUTIVE", "NO_GAPS").contains(type);
if (compact) {
if (request.dayOfWeek() != null || request.timeSlotId() != null || request.preferenceDate() != null) {
throw new IllegalArgumentException("Для пожелания к компактности день, дата и пара не указываются");
}
return new PreferencePayload(null, null, null);
}
if (request.preferenceDate() != null) {
if (!"HARD_UNAVAILABLE".equals(type)) {
throw new IllegalArgumentException("Полную недоступность по дате можно отметить только как строгую");
}
if (request.dayOfWeek() != null || request.timeSlotId() != null) {
throw new IllegalArgumentException("Для полной недоступности по дате день недели и пара не указываются");
}
if (request.preferenceDate().isBefore(semester.getStartDate())
|| request.preferenceDate().isAfter(semester.getEndDate())) {
throw new IllegalArgumentException("Дата недоступности должна находиться внутри семестра");
}
return new PreferencePayload(null, null, request.preferenceDate());
}
if (request.dayOfWeek() == null || request.dayOfWeek() < 1 || request.dayOfWeek() > 7) {
throw new IllegalArgumentException("Выберите день недели");
}
if (request.timeSlotId() == null) {
throw new IllegalArgumentException("Выберите временной слот");
}
TimeSlot slot = timeSlotRepository.findById(request.timeSlotId())
.orElseThrow(() -> new IllegalArgumentException("Временной слот не найден"));
if (slot.getTimeSlotScope() == null || !"DEFAULT".equals(slot.getTimeSlotScope().getApplyMode())) {
throw new IllegalArgumentException("Для пожеланий используйте базовую сетку времени");
}
return new PreferencePayload(request.dayOfWeek(), slot, null);
}
private TeacherPreference getForUpdate(Long id) {
return preferenceRepository.findByIdForUpdate(id)
.orElseThrow(() -> new NoSuchElementException("Пожелание преподавателя не найдено"));
}
private boolean canView(TeacherPreference preference, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return true;
if (current.role() == Role.TEACHER) {
return Objects.equals(current.id(), preference.getTeacher().getId());
}
return current.role() == Role.DEPARTMENT
&& belongsToDepartment(preference.getTeacher(), preference.getSemester(), current.departmentId());
}
private void ensureManageScope(User teacher, Semester semester, AuthenticatedUser current) {
if (current.role() == Role.ADMIN || current.role() == Role.EDUCATION_OFFICE) return;
if (current.role() == Role.TEACHER && Objects.equals(current.id(), teacher.getId())) return;
if (current.role() == Role.DEPARTMENT
&& belongsToDepartment(teacher, semester, current.departmentId())) return;
throw forbidden("Пожелание преподавателя находится вне доступной кафедры");
}
private boolean belongsToDepartment(User teacher, Semester semester, Long departmentId) {
if (departmentId == null) return false;
return assignmentRepository.findForTeachersBetween(
Set.of(teacher.getId()),
semester.getStartDate(),
semester.getEndDate()
).stream()
.anyMatch(assignment -> assignment.getDepartment() != null
&& Objects.equals(assignment.getDepartment().getId(), departmentId));
}
private TeacherPreferenceDto toDto(TeacherPreference preference) {
User teacher = preference.getTeacher();
Semester semester = preference.getSemester();
TimeSlot slot = preference.getTimeSlot();
return new TeacherPreferenceDto(
preference.getId(),
teacher.getId(),
ScheduleGeneratorService.displayUserName(teacher),
teacher.getDepartmentId(),
semester.getId(),
semester.getAcademicYear().getTitle(),
semester.getSemesterType().name(),
semester.getStartDate(),
semester.getEndDate(),
preference.getPreferenceType(),
preference.getDayOfWeek(),
dayName(preference.getDayOfWeek()),
slot == null ? null : slot.getId(),
slot == null ? null : slot.getOrderNumber(),
slot == null ? null : slot.getStartTime(),
slot == null ? null : slot.getEndTime(),
preference.getPreferenceDate(),
preference.getComment(),
preference.getStatus(),
preference.getRequestedBy(),
preference.getReviewedBy(),
preference.getReviewComment(),
preference.getReviewedAt(),
preference.getCreatedAt(),
preference.getUpdatedAt()
);
}
private TimeSlotDto toTimeSlotDto(TimeSlot slot) {
var scope = slot.getTimeSlotScope();
return new TimeSlotDto(
slot.getId(), slot.getOrderNumber(), scope.getId(), scope.getName(), scope.getApplyMode(),
scope.getDayOfWeek(), slot.getStartTime(), slot.getEndTime(), slot.getDurationMinutes()
);
}
private String dayName(Integer dayOfWeek) {
if (dayOfWeek == null || dayOfWeek < 1 || dayOfWeek > 7) return null;
String value = DayOfWeek.of(dayOfWeek).getDisplayName(TextStyle.FULL, Locale.forLanguageTag("ru"));
return value.substring(0, 1).toUpperCase(Locale.forLanguageTag("ru")) + value.substring(1);
}
private AuthenticatedUser currentUser() {
AuthenticatedUser current = AuthContext.getCurrentUser();
if (current == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Требуется авторизация");
return current;
}
private ResponseStatusException forbidden(String message) {
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
private String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String cleanUpper(String value) {
String cleaned = clean(value);
return cleaned == null ? null : cleaned.toUpperCase(Locale.ROOT);
}
private record PreferencePayload(Integer dayOfWeek, TimeSlot timeSlot, LocalDate preferenceDate) {
}
}

View File

@@ -2,6 +2,7 @@
-- Инициализация расширений
-- ==========================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- ===============================
-- Справочники высшего уровня
@@ -18,11 +19,6 @@ CREATE TABLE IF NOT EXISTS departments (
archive_reason TEXT
);
INSERT INTO departments (name, code) VALUES
('Кафедра ИБ', 1),
('Кафедра ВТ', 2),
('Кафедра КТ', 3);
CREATE TABLE IF NOT EXISTS specialties (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
@@ -35,12 +31,6 @@ CREATE TABLE IF NOT EXISTS specialties (
archive_reason TEXT
);
INSERT INTO specialties (name, specialty_code) VALUES
('Информационная безопасность', '10.03.01'),
('Информатика и вычислительная техника', '09.03.01'),
('Программная инженерия', '09.03.04')
ON CONFLICT (specialty_code) DO NOTHING;
CREATE TABLE IF NOT EXISTS specialty_profiles (
id BIGSERIAL PRIMARY KEY,
specialty_id BIGINT NOT NULL REFERENCES specialties(id) ON DELETE CASCADE,
@@ -56,11 +46,6 @@ CREATE TABLE IF NOT EXISTS specialty_profiles (
CONSTRAINT uq_specialty_profiles_name UNIQUE (specialty_id, name)
);
INSERT INTO specialty_profiles (specialty_id, name)
SELECT id, 'Без профиля'
FROM specialties
ON CONFLICT (specialty_id, name) DO NOTHING;
-- ==========================================
-- Пользователи и роли
-- ==========================================
@@ -82,15 +67,6 @@ CREATE TABLE IF NOT EXISTS users (
archive_reason TEXT
);
-- Админ по умолчанию: admin / admin (bcrypt через pgcrypto)
INSERT INTO users (username, password, role, full_name, job_title, department_id)
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Админ Иванович', 'Доцент', 1),
('Тестовый преподаватель', crypt('1234567890', gen_salt('bf', 10)), 'TEACHER', 'Петров Препод Петрович', 'Профессор', 2),
('учебный_отдел', crypt('1234567890', gen_salt('bf', 10)), 'EDUCATION_OFFICE', 'Оператор учебного отдела', 'Учебный отдел', 1),
('кафедра_иб', crypt('1234567890', gen_salt('bf', 10)), 'DEPARTMENT', 'Оператор кафедры ИБ', 'Кафедра', 1),
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING;
CREATE TABLE IF NOT EXISTS auth_refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -188,16 +164,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_teacher_department_open_pair
ON teacher_department_assignments(teacher_id, department_id)
WHERE valid_to IS NULL;
INSERT INTO teacher_department_assignments (teacher_id, department_id, valid_from, is_primary, comment)
SELECT u.id, u.department_id, COALESCE(u.created_at::DATE, CURRENT_DATE), TRUE, 'Начальная кафедра из users.department_id'
FROM users u
WHERE u.role = 'TEACHER'
AND NOT EXISTS (
SELECT 1
FROM teacher_department_assignments existing
WHERE existing.teacher_id = u.id
);
CREATE TABLE IF NOT EXISTS teacher_creation_requests (
id BIGSERIAL PRIMARY KEY,
department_id BIGINT NOT NULL REFERENCES departments(id),
@@ -242,12 +208,6 @@ CREATE TABLE IF NOT EXISTS education_forms (
archive_reason TEXT
);
INSERT INTO education_forms (name) VALUES
('Бакалавриат'),
('Магистратура'),
('Специалитет')
ON CONFLICT (name) DO NOTHING;
-- ==========================================
-- Учебные группы
-- ==========================================
@@ -271,19 +231,6 @@ CREATE TABLE IF NOT EXISTS student_groups (
archive_reason TEXT
);
-- Тестовая базовая группа для работы
INSERT INTO student_groups (name, group_size, education_form_id, department_id, specialty_id, specialty_profile_id, year_start_study)
SELECT data.name, data.group_size, ef.id, dept.id, specialty.id, profile.id, data.year_start_study
FROM (VALUES
('ИВТ-21-1', 25::BIGINT, 'Бакалавриат', 'Кафедра ИБ', '09.03.01', 2025::BIGINT),
('ИБ-41м', 15::BIGINT, 'Магистратура', 'Кафедра ИБ', '10.03.01', 2024::BIGINT)
) AS data(name, group_size, education_form_name, department_name, specialty_code, year_start_study)
JOIN education_forms ef ON ef.name = data.education_form_name
JOIN departments dept ON dept.name = data.department_name
JOIN specialties specialty ON specialty.specialty_code = data.specialty_code
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE NOT EXISTS (SELECT 1 FROM student_groups existing WHERE existing.name = data.name);
-- ==========================================
-- Подгруппы (например: "ИВТ-21-1 Подгруппа 1")
-- ==========================================
@@ -372,22 +319,6 @@ CREATE TRIGGER validate_subgroup_student_capacity
FOR EACH ROW
EXECUTE FUNCTION validate_subgroup_student_capacity();
INSERT INTO subgroups (group_id, name, student_capacity)
SELECT
sg.id,
data.name,
CASE
WHEN data.part_number = 1 THEN CEIL(sg.group_size::NUMERIC / 2)::INT
ELSE FLOOR(sg.group_size::NUMERIC / 2)::INT
END
FROM student_groups sg
CROSS JOIN (VALUES
('Подгруппа 1', 1),
('Подгруппа 2', 2)
) AS data(name, part_number)
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, name) DO NOTHING;
-- ==========================================
-- Справочники
-- ==========================================
@@ -413,14 +344,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_subjects_name_ci
COMMENT ON INDEX uq_subjects_name_ci IS
'Глобально уникальное название дисциплины без учёта регистра';
INSERT INTO subjects (name, department_id) VALUES
('Высшая математика', 1),
('Философия', 1),
('Информатика', 1),
('Базы данных', 1),
('Английский язык', 1)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS subject_comments (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -440,12 +363,6 @@ CREATE TABLE IF NOT EXISTS lesson_types (
duration_minutes INT DEFAULT 90
);
INSERT INTO lesson_types (name, color_code) VALUES
('Лекция', '#FF6B6B'),
('Практика', '#4ECDC4'),
('Лабораторная работа', '#45B7D1')
ON CONFLICT (name) DO NOTHING;
-- Оборудование
CREATE TABLE IF NOT EXISTS equipments (
id BIGSERIAL PRIMARY KEY,
@@ -460,15 +377,6 @@ CREATE TABLE IF NOT EXISTS equipments (
archive_reason TEXT
);
INSERT INTO equipments (name) VALUES
('Проектор'),
('ПК'),
('Лаборатория'),
('Интерактивная доска'),
('Документ-камера'),
('Аудиосистема')
ON CONFLICT (name) DO NOTHING;
-- Аудитории
CREATE TABLE IF NOT EXISTS classrooms (
id BIGSERIAL PRIMARY KEY,
@@ -487,12 +395,6 @@ CREATE TABLE IF NOT EXISTS classrooms (
archive_reason TEXT
);
INSERT INTO classrooms (name, capacity, building, floor) VALUES
('101 Ленинская', 120, 'Главный корпус', 1),
('202 IT Lab', 20, 'Корпус IT', 2),
('303 Обычная', 30, 'Главный корпус', 3)
ON CONFLICT (name) DO NOTHING;
-- Привязка оборудования к аудиториям (Many-to-Many)
CREATE TABLE IF NOT EXISTS classroom_equipments (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
@@ -502,20 +404,6 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
PRIMARY KEY (classroom_id, equipment_id)
);
INSERT INTO classroom_equipments (classroom_id, equipment_id, quantity)
SELECT c.id, e.id,
CASE
WHEN e.name = 'ПК' AND c.name = '202 IT Lab' THEN 15
WHEN e.name = 'ПК' THEN 1
ELSE 1
END
FROM classrooms c, equipments e
WHERE
(c.name = '101 Ленинская' AND e.name IN ('Проектор', 'Интерактивная доска', 'Аудиосистема'))
OR (c.name = '202 IT Lab' AND e.name IN ('ПК', 'Проектор', 'Лаборатория', 'Интерактивная доска'))
OR (c.name = '303 Обычная' AND e.name IN ('Проектор'))
ON CONFLICT (classroom_id, equipment_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_departments_status ON departments(status);
CREATE INDEX IF NOT EXISTS idx_specialties_status ON specialties(status);
CREATE INDEX IF NOT EXISTS idx_specialty_profiles_status ON specialty_profiles(status);
@@ -574,11 +462,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slot_scopes_weekday
ON time_slot_scopes(day_of_week)
WHERE apply_mode = 'WEEKDAY';
INSERT INTO time_slot_scopes (code, name, apply_mode, day_of_week, system_scope, display_order) VALUES
('default', 'Базовая сетка', 'DEFAULT', NULL, TRUE, 10),
('saturday', 'Субботняя сетка', 'WEEKDAY', 6, TRUE, 20)
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slots (
id BIGSERIAL PRIMARY KEY,
time_slot_scope_id BIGINT NOT NULL REFERENCES time_slot_scopes(id) ON DELETE CASCADE,
@@ -596,21 +479,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_time_slots_scope_order
CREATE INDEX IF NOT EXISTS idx_time_slots_scope ON time_slots(time_slot_scope_id, order_number);
INSERT INTO time_slots (time_slot_scope_id, order_number, start_time, end_time, duration_minutes)
SELECT scope.id, data.order_number, data.start_time::TIME, data.end_time::TIME, data.duration_minutes
FROM (VALUES
(1, '08:00', '09:30', 90),
(2, '09:40', '11:10', 90),
(3, '11:40', '13:10', 90),
(4, '13:20', '14:50', 90),
(5, '15:00', '16:30', 90),
(6, '16:50', '18:20', 90),
(7, '18:30', '20:00', 90)
) AS data(order_number, start_time, end_time, duration_minutes)
CROSS JOIN time_slot_scopes scope
WHERE scope.code IN ('default', 'saturday')
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS time_slot_date_assignments (
id BIGSERIAL PRIMARY KEY,
assignment_date DATE NOT NULL UNIQUE,
@@ -628,11 +496,6 @@ CREATE TABLE IF NOT EXISTS academic_years (
CONSTRAINT chk_academic_years_dates CHECK (start_date <= end_date)
);
INSERT INTO academic_years (title, start_date, end_date) VALUES
('2024-2025', '2024-09-01', '2025-06-30'),
('2025-2026', '2025-09-01', '2026-06-30')
ON CONFLICT (title) DO NOTHING;
CREATE TABLE IF NOT EXISTS semesters (
id BIGSERIAL PRIMARY KEY,
academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
@@ -644,16 +507,6 @@ CREATE TABLE IF NOT EXISTS semesters (
CONSTRAINT uq_semesters_year_type UNIQUE (academic_year_id, semester_type)
);
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'autumn', start_date, MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 1, 31)
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
INSERT INTO semesters (academic_year_id, semester_type, start_date, end_date)
SELECT id, 'spring', MAKE_DATE(SPLIT_PART(title, '-', 2)::INT, 2, 1), end_date
FROM academic_years
ON CONFLICT (academic_year_id, semester_type) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date);
CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
@@ -666,22 +519,6 @@ CREATE TABLE IF NOT EXISTS academic_calendar_activity_types (
description TEXT
);
INSERT INTO academic_calendar_activity_types (code, name, allow_schedule, color_code, display_order, description) VALUES
('Т', 'Теоретическое обучение', TRUE, '#10b981', 10, 'Обычные занятия динамического расписания разрешены'),
('Э', 'Экзаменационная сессия', FALSE, '#f59e0b', 20, 'Обычные занятия не генерируются'),
('К', 'Каникулы', FALSE, '#38bdf8', 30, 'Обычные занятия не генерируются'),
('У', 'Учебная практика', FALSE, '#a78bfa', 40, 'Обычные занятия не генерируются'),
('П', 'Производственная практика', FALSE, '#fb7185', 50, 'Обычные занятия не генерируются'),
('Пд', 'Преддипломная практика', FALSE, '#f97316', 60, 'Обычные занятия не генерируются'),
('Н', 'Научно-исследовательская работа', FALSE, '#22c55e', 70, 'Обычные занятия не генерируются'),
('Г', 'Подготовка к сдаче и сдача государственного экзамена', FALSE, '#ef4444', 80, 'Обычные занятия не генерируются'),
('Д', 'Подготовка к защите и защита выпускной квалификационной работы', FALSE, '#dc2626', 90, 'Обычные занятия не генерируются'),
('ПА', 'Повторная промежуточная аттестация', FALSE, '#eab308', 100, 'Обычные занятия не генерируются'),
('С', 'Симуляционный курс', FALSE, '#06b6d4', 110, 'Обычные занятия не генерируются'),
('*', 'Нерабочий праздничный день', FALSE, '#64748b', 120, 'День исключается из генерации расписания'),
('=', 'День вне учебного года', FALSE, '#475569', 130, 'День исключается из генерации расписания')
ON CONFLICT (code) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendars (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
@@ -699,37 +536,25 @@ CREATE TABLE IF NOT EXISTS academic_calendars (
CREATE INDEX IF NOT EXISTS idx_academic_calendars_context
ON academic_calendars(academic_year_id, specialty_id, specialty_profile_id, study_form_id);
INSERT INTO academic_calendars (title, academic_year_id, specialty_id, specialty_profile_id, study_form_id, course_count)
SELECT
specialty.specialty_code || ' ' || profile.name || ' ' || study_form.name || ' ' || ay.title,
ay.id,
specialty.id,
profile.id,
study_form.id,
4
FROM academic_years ay
CROSS JOIN education_forms study_form
JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01')
JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля'
WHERE ay.title = '2025-2026'
ON CONFLICT (academic_year_id, specialty_profile_id, study_form_id, title) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_days (
CREATE TABLE IF NOT EXISTS academic_calendar_periods (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
course_number INT NOT NULL,
date DATE NOT NULL,
week_number INT NOT NULL,
day_of_week INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
activity_type_id BIGINT NOT NULL REFERENCES academic_calendar_activity_types(id),
CONSTRAINT chk_calendar_days_course_positive CHECK (course_number > 0),
CONSTRAINT chk_calendar_days_week_positive CHECK (week_number > 0),
CONSTRAINT chk_calendar_days_day CHECK (day_of_week BETWEEN 1 AND 7),
CONSTRAINT uq_calendar_days UNIQUE (calendar_id, course_number, date)
CONSTRAINT chk_calendar_periods_course_positive CHECK (course_number > 0),
CONSTRAINT chk_calendar_periods_dates CHECK (end_date >= start_date),
CONSTRAINT ex_calendar_periods_no_overlap
EXCLUDE USING gist (
calendar_id WITH =,
course_number WITH =,
(daterange(start_date, end_date, '[]')) WITH &&
)
);
CREATE INDEX IF NOT EXISTS idx_calendar_days_lookup
ON academic_calendar_days(calendar_id, course_number, date);
CREATE INDEX IF NOT EXISTS idx_calendar_periods_schedule
ON academic_calendar_periods(calendar_id, course_number, start_date, end_date);
CREATE OR REPLACE FUNCTION calculate_academic_calendar_week_number(
academic_year_start DATE,
@@ -750,21 +575,6 @@ AS $$
) + 1
$$;
INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id)
SELECT
calendar.id,
course.course_number,
days.date::DATE,
calculate_academic_calendar_week_number(ay.start_date, days.date::DATE),
EXTRACT(ISODOW FROM days.date::DATE)::INT,
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)
CROSS JOIN GENERATE_SERIES(ay.start_date, ay.end_date, INTERVAL '1 day') AS days(date)
ON CONFLICT (calendar_id, course_number, date) DO NOTHING;
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
calendar_id BIGINT NOT NULL REFERENCES academic_calendars(id) ON DELETE CASCADE,
@@ -793,21 +603,6 @@ CREATE TABLE IF NOT EXISTS student_group_calendar_assignments (
CREATE INDEX IF NOT EXISTS idx_group_calendar_assignments_lookup
ON student_group_calendar_assignments(group_id, academic_year_id);
INSERT INTO student_group_calendar_assignments (group_id, academic_year_id, calendar_id)
SELECT
sg.id,
ay.id,
calendar.id
FROM student_groups sg
JOIN academic_years ay ON ay.title = '2025-2026'
JOIN academic_calendars calendar
ON calendar.academic_year_id = ay.id
AND calendar.specialty_id = sg.specialty_id
AND calendar.specialty_profile_id = sg.specialty_profile_id
AND calendar.study_form_id = sg.education_form_id
WHERE sg.name IN ('ИВТ-21-1', 'ИБ-41м')
ON CONFLICT (group_id, academic_year_id) DO NOTHING;
CREATE TABLE IF NOT EXISTS schedule_rules (
id BIGSERIAL PRIMARY KEY,
subject_id BIGINT NOT NULL REFERENCES subjects(id),
@@ -984,121 +779,6 @@ CREATE TRIGGER validate_schedule_rule_slot_subgroups
FOR EACH ROW
EXECUTE FUNCTION validate_schedule_rule_slot_subgroups();
CREATE TEMP TABLE tmp_default_rules AS
SELECT
NEXTVAL(PG_GET_SERIAL_SEQUENCE('schedule_rules', 'id')) AS schedule_rule_id,
rule_key,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM (
SELECT
data.rule_key,
subj.id AS subject_id,
sem.id AS semester_id,
data.lecture_academic_hours,
data.laboratory_academic_hours,
data.practice_academic_hours,
data.lecture_start_week,
data.laboratory_start_week,
data.practice_start_week
FROM (VALUES
('stream_math', 'Высшая математика', '2025-2026', 'spring', 72, 0, 0, 1, 1, 1),
('ivt_informatics_lab', 'Информатика', '2025-2026', 'spring', 0, 72, 0, 1, 1, 1),
('ivt_databases_practice', 'Базы данных', '2025-2026', 'spring', 0, 0, 72, 1, 1, 1),
('ib_philosophy', 'Философия', '2025-2026', 'spring', 54, 0, 0, 1, 1, 1)
) AS data(
rule_key,
subject_name,
year_title,
semester_type,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
JOIN subjects subj ON subj.name = data.subject_name
JOIN academic_years ay ON ay.title = data.year_title
JOIN semesters sem ON sem.academic_year_id = ay.id AND sem.semester_type = data.semester_type
) prepared_rules;
INSERT INTO schedule_rules (
id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
)
SELECT
schedule_rule_id,
subject_id,
semester_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
FROM tmp_default_rules;
INSERT INTO schedule_rule_groups (schedule_rule_id, group_id)
SELECT
rules.schedule_rule_id,
sg.id
FROM (VALUES
('stream_math', 'ИВТ-21-1'),
('stream_math', 'ИБ-41м'),
('ivt_informatics_lab', 'ИВТ-21-1'),
('ivt_databases_practice', 'ИВТ-21-1'),
('ib_philosophy', 'ИБ-41м')
) AS data(rule_key, group_name)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN student_groups sg ON sg.name = data.group_name
ON CONFLICT DO NOTHING;
INSERT INTO schedule_rule_slots (
schedule_rule_id,
day_of_week,
parity,
time_slot_id,
teacher_id,
classroom_id,
lesson_type_id,
lesson_format
)
SELECT
rules.schedule_rule_id,
data.day_of_week,
data.parity,
ts.id,
teacher.id,
classroom.id,
lt.id,
data.lesson_format
FROM (VALUES
('stream_math', 1, 'BOTH', 3, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Очно'),
('ivt_informatics_lab', 3, 'BOTH', 1, 'Тестовый преподаватель', '202 IT Lab', 'Лабораторная работа', 'Очно'),
('ivt_databases_practice', 5, 'EVEN', 5, 'Тестовый преподаватель', '303 Обычная', 'Практика', 'Онлайн'),
('ib_philosophy', 2, 'ODD', 2, 'Тестовый преподаватель', '101 Ленинская', 'Лекция', 'Онлайн')
) AS data(rule_key, day_of_week, parity, time_slot_order, teacher_username, classroom_name, lesson_type_name, lesson_format)
JOIN tmp_default_rules rules ON rules.rule_key = data.rule_key
JOIN time_slot_scopes scope ON scope.code = 'default'
JOIN time_slots ts ON ts.time_slot_scope_id = scope.id AND ts.order_number = data.time_slot_order
JOIN users teacher ON teacher.username = data.teacher_username
JOIN classrooms classroom ON classroom.name = data.classroom_name
JOIN lesson_types lt ON lt.name = data.lesson_type_name;
-- ==========================================
-- Функция обновления timestamp
-- ==========================================
@@ -1134,7 +814,7 @@ COMMENT ON TABLE academic_years IS 'Учебные годы';
COMMENT ON TABLE semesters IS 'Семестры учебного года';
COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика';
COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году';
COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика';
COMMENT ON TABLE academic_calendar_periods IS 'Непересекающиеся периоды активности календарного учебного графика';
COMMENT ON TABLE academic_calendar_subjects IS 'Привязка дисциплин к календарному учебному графику по номерам учебных семестров';
COMMENT ON TABLE student_group_calendar_assignments IS 'Назначения календарных графиков учебным группам по учебному году';
COMMENT ON TABLE schedule_rules IS 'Правила динамической генерации расписания';
@@ -1275,12 +955,11 @@ COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля
COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms';
COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике';
COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_days.course_number IS 'Номер курса';
COMMENT ON COLUMN academic_calendar_days.date IS 'Дата';
COMMENT ON COLUMN academic_calendar_days.week_number IS 'Номер недели в учебном году';
COMMENT ON COLUMN academic_calendar_days.day_of_week IS 'День недели ISO';
COMMENT ON COLUMN academic_calendar_days.activity_type_id IS 'ID кода активности';
COMMENT ON COLUMN academic_calendar_periods.calendar_id IS 'ID календарного учебного графика';
COMMENT ON COLUMN academic_calendar_periods.course_number IS 'Номер курса';
COMMENT ON COLUMN academic_calendar_periods.start_date IS 'Первая дата периода включительно';
COMMENT ON COLUMN academic_calendar_periods.end_date IS 'Последняя дата периода включительно';
COMMENT ON COLUMN academic_calendar_periods.activity_type_id IS 'ID кода активности';
COMMENT ON FUNCTION calculate_academic_calendar_week_number(DATE, DATE)
IS 'Номер недели календарного учебного графика по периодам понедельник–воскресенье';
@@ -1365,7 +1044,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: невалидные точечные изменения CANCEL=%s, MOVE=%s, REPLACE=%s, формат=%s, целевая дата=%s. Исправьте исходные данные и повторите создание схемы.',
cancel_violations,
move_violations,
replace_violations,
@@ -1464,7 +1143,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: нечётные академические часы правил расписания — лекции=%s, лабораторные=%s, практики=%s. Исправьте исходные данные и повторите создание схемы.',
lecture_violations,
laboratory_violations,
practice_violations
@@ -1505,7 +1184,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23505',
MESSAGE = format(
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: найдено групп точных дублей слотов правил=%s. Исправьте исходные данные и повторите создание схемы.',
duplicate_slot_groups
);
END IF;
@@ -1571,7 +1250,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: неверная длительность слотов=%s, пересекающиеся интервалы=%s, слоты правил вне базовой сетки=%s. Исправьте исходные данные и повторите создание схемы.',
duration_violations,
overlap_violations,
scope_violations
@@ -1579,8 +1258,6 @@ BEGIN
END IF;
END $$;
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE time_slots
ADD CONSTRAINT chk_time_slots_duration_matches_range
CHECK (
@@ -1729,7 +1406,7 @@ BEGIN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: пересечения учебных годов=%s, семестры вне границ года=%s, пересечения семестров=%s. Исправьте исходные данные и повторите создание схемы.',
academic_year_overlap_violations,
semester_bounds_violations,
semester_overlap_violations
@@ -1818,7 +1495,7 @@ CREATE TRIGGER trg_academic_years_include_semesters
DO $$
DECLARE
assignment_violations BIGINT;
calendar_day_violations BIGINT;
calendar_period_violations BIGINT;
calendar_subject_violations BIGINT;
BEGIN
SELECT count(*)
@@ -1840,13 +1517,13 @@ BEGIN
END NOT BETWEEN 1 AND calendar.course_count;
SELECT count(*)
INTO calendar_day_violations
FROM academic_calendar_days calendar_day
JOIN academic_calendars calendar ON calendar.id = calendar_day.calendar_id
INTO calendar_period_violations
FROM academic_calendar_periods calendar_period
JOIN academic_calendars calendar ON calendar.id = calendar_period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
WHERE calendar_day.course_number > calendar.course_count
OR calendar_day.date < academic_year.start_date
OR calendar_day.date > academic_year.end_date;
WHERE calendar_period.course_number > calendar.course_count
OR calendar_period.start_date < academic_year.start_date
OR calendar_period.end_date > academic_year.end_date;
SELECT count(*)
INTO calendar_subject_violations
@@ -1855,14 +1532,14 @@ BEGIN
WHERE calendar_subject.semester_number > calendar.course_count * 2;
IF assignment_violations > 0
OR calendar_day_violations > 0
OR calendar_period_violations > 0
OR calendar_subject_violations > 0 THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = format(
'Невозможно создать baseline: несовместимые назначения графиков=%s, строки сетки вне измерений=%s, дисциплины вне измерений=%s. Исправьте seed-данные V1 и повторите создание схемы.',
'Невозможно создать baseline: несовместимые назначения графиков=%s, периоды активности вне измерений=%s, дисциплины вне измерений=%s. Исправьте исходные данные и повторите создание схемы.',
assignment_violations,
calendar_day_violations,
calendar_period_violations,
calendar_subject_violations
);
END IF;
@@ -1990,12 +1667,12 @@ BEGIN
IF EXISTS (
SELECT 1
FROM academic_calendar_days calendar_day
WHERE calendar_day.calendar_id = OLD.id
FROM academic_calendar_periods calendar_period
WHERE calendar_period.calendar_id = OLD.id
AND (
calendar_day.course_number > NEW.course_count
OR calendar_day.date < academic_year_start
OR calendar_day.date > academic_year_end
calendar_period.course_number > NEW.course_count
OR calendar_period.start_date < academic_year_start
OR calendar_period.end_date > academic_year_end
)
) THEN
RAISE EXCEPTION USING
@@ -2059,7 +1736,7 @@ CREATE TRIGGER trg_student_groups_protect_calendar_assignments
FOR EACH ROW
EXECUTE FUNCTION prevent_incompatible_student_group_change();
CREATE OR REPLACE FUNCTION enforce_calendar_day_dimensions()
CREATE OR REPLACE FUNCTION enforce_calendar_period_dimensions()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
@@ -2078,22 +1755,22 @@ BEGIN
IF calendar_course_count IS NOT NULL
AND (
NEW.course_number > calendar_course_count
OR NEW.date < academic_year_start
OR NEW.date > academic_year_end
OR NEW.start_date < academic_year_start
OR NEW.end_date > academic_year_end
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'Строка сетки выходит за измерения календарного графика';
MESSAGE = 'Период активности выходит за измерения календарного графика';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_calendar_days_dimensions
BEFORE INSERT OR UPDATE OF calendar_id, course_number, date
ON academic_calendar_days
CREATE TRIGGER trg_calendar_periods_dimensions
BEFORE INSERT OR UPDATE OF calendar_id, course_number, start_date, end_date
ON academic_calendar_periods
FOR EACH ROW
EXECUTE FUNCTION enforce_calendar_day_dimensions();
EXECUTE FUNCTION enforce_calendar_period_dimensions();
CREATE OR REPLACE FUNCTION enforce_calendar_subject_dimensions()
RETURNS TRIGGER
@@ -2132,11 +1809,11 @@ BEGIN
IF EXISTS (
SELECT 1
FROM academic_calendars calendar
JOIN academic_calendar_days calendar_day ON calendar_day.calendar_id = calendar.id
JOIN academic_calendar_periods calendar_period ON calendar_period.calendar_id = calendar.id
WHERE calendar.academic_year_id = OLD.id
AND (
calendar_day.date < NEW.start_date
OR calendar_day.date > NEW.end_date
calendar_period.start_date < NEW.start_date
OR calendar_period.end_date > NEW.end_date
)
) OR EXISTS (
SELECT 1
@@ -2178,3 +1855,291 @@ ALTER TABLE teacher_department_assignments
COMMENT ON CONSTRAINT ex_teacher_primary_department_no_overlap
ON teacher_department_assignments
IS 'У преподавателя не могут пересекаться периоды двух основных кафедр';
-- ==========================================
-- Отсутствия преподавателей и мастер замены
-- ==========================================
CREATE TABLE teacher_absences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
requested_by BIGINT REFERENCES users(id),
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_absences_period CHECK (end_date >= start_date),
CONSTRAINT chk_teacher_absences_reason CHECK (length(trim(reason)) BETWEEN 1 AND 2000),
CONSTRAINT chk_teacher_absences_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'RESOLVED', 'CANCELLED')
)
);
CREATE INDEX idx_teacher_absences_teacher_period
ON teacher_absences(teacher_id, start_date, end_date);
CREATE INDEX idx_teacher_absences_status
ON teacher_absences(status);
CREATE TABLE teacher_absence_decisions (
id BIGSERIAL PRIMARY KEY,
absence_id BIGINT NOT NULL REFERENCES teacher_absences(id) ON DELETE CASCADE,
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
resolution VARCHAR(30) NOT NULL,
decision_status VARCHAR(20) NOT NULL,
schedule_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
comment TEXT,
decided_by BIGINT REFERENCES users(id),
decided_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_teacher_absence_decision_lesson UNIQUE (
absence_id,
base_rule_slot_id,
lesson_date
),
CONSTRAINT chk_teacher_absence_decision_resolution CHECK (
resolution IN ('REPLACE_TEACHER', 'MOVE_TIME', 'CHANGE_CLASSROOM', 'CANCEL', 'REJECT')
),
CONSTRAINT chk_teacher_absence_decision_status CHECK (
decision_status IN ('APPLIED', 'REJECTED')
)
);
CREATE INDEX idx_teacher_absence_decisions_absence
ON teacher_absence_decisions(absence_id, lesson_date);
COMMENT ON TABLE teacher_absences IS 'Временные отсутствия преподавателей с согласованием кафедрой';
COMMENT ON COLUMN teacher_absences.status IS 'PENDING, APPROVED, REJECTED, RESOLVED или CANCELLED';
COMMENT ON TABLE teacher_absence_decisions IS 'Журнал применённых и отклонённых решений мастера замены';
COMMENT ON COLUMN teacher_absence_decisions.resolution IS 'Выбранный способ разрешения затронутого занятия';
-- ==========================================
-- Пожелания и заявки преподавателей
-- ==========================================
CREATE TABLE teacher_preferences (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
semester_id BIGINT NOT NULL REFERENCES semesters(id),
preference_type VARCHAR(30) NOT NULL,
day_of_week SMALLINT,
time_slot_id BIGINT REFERENCES time_slots(id),
preference_date DATE,
comment TEXT,
status VARCHAR(20) NOT NULL,
requested_by BIGINT REFERENCES users(id),
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_preferences_type CHECK (
preference_type IN (
'HARD_UNAVAILABLE',
'SOFT_PREFERRED',
'SOFT_UNWANTED',
'CONSECUTIVE',
'NO_GAPS'
)
),
CONSTRAINT chk_teacher_preferences_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_preferences_day CHECK (
day_of_week IS NULL OR day_of_week BETWEEN 1 AND 7
),
CONSTRAINT chk_teacher_preferences_payload CHECK (
(
preference_type = 'HARD_UNAVAILABLE'
AND (
(preference_date IS NOT NULL AND day_of_week IS NULL AND time_slot_id IS NULL)
OR
(preference_date IS NULL AND day_of_week IS NOT NULL AND time_slot_id IS NOT NULL)
)
)
OR
(
preference_type IN ('SOFT_PREFERRED', 'SOFT_UNWANTED')
AND preference_date IS NULL
AND day_of_week IS NOT NULL
AND time_slot_id IS NOT NULL
)
OR
(
preference_type IN ('CONSECUTIVE', 'NO_GAPS')
AND preference_date IS NULL
AND day_of_week IS NULL
AND time_slot_id IS NULL
)
),
CONSTRAINT chk_teacher_preferences_comment CHECK (
comment IS NULL OR length(trim(comment)) BETWEEN 1 AND 2000
)
);
CREATE INDEX idx_teacher_preferences_teacher_semester
ON teacher_preferences(teacher_id, semester_id, status);
CREATE INDEX idx_teacher_preferences_slot
ON teacher_preferences(semester_id, day_of_week, time_slot_id)
WHERE status = 'APPROVED';
CREATE INDEX idx_teacher_preferences_date
ON teacher_preferences(teacher_id, preference_date)
WHERE status = 'APPROVED' AND preference_date IS NOT NULL;
CREATE TABLE teacher_change_requests (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id),
base_rule_slot_id BIGINT NOT NULL REFERENCES schedule_rule_slots(id),
lesson_date DATE NOT NULL,
request_type VARCHAR(30) NOT NULL,
target_lesson_date DATE,
requested_time_slot_id BIGINT REFERENCES time_slots(id),
requested_classroom_id BIGINT REFERENCES classrooms(id),
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
reviewed_by BIGINT REFERENCES users(id),
review_comment TEXT,
reviewed_at TIMESTAMPTZ,
applied_override_id BIGINT REFERENCES schedule_overrides(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_requests_type CHECK (
request_type IN ('MOVE', 'CHANGE_CLASSROOM', 'CANCEL')
),
CONSTRAINT chk_teacher_change_requests_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
),
CONSTRAINT chk_teacher_change_requests_reason CHECK (
length(trim(reason)) BETWEEN 1 AND 2000
),
CONSTRAINT chk_teacher_change_requests_payload CHECK (
(
request_type = 'MOVE'
AND requested_time_slot_id IS NOT NULL
AND requested_classroom_id IS NULL
)
OR
(
request_type = 'CHANGE_CLASSROOM'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NOT NULL
)
OR
(
request_type = 'CANCEL'
AND target_lesson_date IS NULL
AND requested_time_slot_id IS NULL
AND requested_classroom_id IS NULL
)
)
);
CREATE INDEX idx_teacher_change_requests_teacher
ON teacher_change_requests(teacher_id, created_at DESC);
CREATE INDEX idx_teacher_change_requests_status
ON teacher_change_requests(status, created_at);
CREATE UNIQUE INDEX uq_teacher_change_requests_pending_lesson
ON teacher_change_requests(base_rule_slot_id, lesson_date)
WHERE status = 'PENDING';
CREATE TABLE teacher_change_request_history (
id BIGSERIAL PRIMARY KEY,
request_id BIGINT NOT NULL REFERENCES teacher_change_requests(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL,
comment TEXT,
actor_id BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_teacher_change_request_history_status CHECK (
status IN ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED')
)
);
CREATE INDEX idx_teacher_change_request_history_request
ON teacher_change_request_history(request_id, created_at, id);
COMMENT ON TABLE teacher_preferences IS 'Согласуемые ограничения и пожелания преподавателей на семестр';
COMMENT ON COLUMN teacher_preferences.preference_type IS 'Строгая недоступность, мягкое пожелание или пожелание к компактности расписания';
COMMENT ON TABLE teacher_change_requests IS 'Заявки преподавателей на перенос, смену аудитории или отмену опубликованного занятия';
COMMENT ON TABLE teacher_change_request_history IS 'Неизменяемая история статусов и комментариев по заявке преподавателя';
-- ==========================================
-- Черновики, версии, публикация и аудит расписания
-- ==========================================
CREATE TABLE schedule_versions (
id BIGSERIAL PRIMARY KEY,
semester_id BIGINT NOT NULL REFERENCES semesters(id) ON DELETE CASCADE,
version_number INT NOT NULL,
name VARCHAR(160) NOT NULL,
status VARCHAR(20) NOT NULL,
based_on_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
restored_from_version_id BIGINT REFERENCES schedule_versions(id) ON DELETE SET NULL,
change_reason TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_by BIGINT REFERENCES users(id),
published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
CONSTRAINT uq_schedule_versions_number UNIQUE (semester_id, version_number),
CONSTRAINT chk_schedule_versions_number CHECK (version_number > 0),
CONSTRAINT chk_schedule_versions_name CHECK (length(trim(name)) BETWEEN 1 AND 160),
CONSTRAINT chk_schedule_versions_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
CONSTRAINT chk_schedule_versions_publish_fields CHECK (
status <> 'PUBLISHED' OR published_at IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_schedule_versions_published_semester
ON schedule_versions(semester_id)
WHERE status = 'PUBLISHED';
CREATE INDEX idx_schedule_versions_semester_history
ON schedule_versions(semester_id, version_number DESC);
ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT;
ALTER TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version
FOREIGN KEY (schedule_version_id) REFERENCES schedule_versions(id) ON DELETE CASCADE;
CREATE INDEX idx_schedule_rules_version
ON schedule_rules(schedule_version_id, status);
CREATE UNIQUE INDEX uq_schedule_rules_version_group
ON schedule_rules(schedule_version_id, version_group_id)
WHERE version_group_id IS NOT NULL;
CREATE TABLE schedule_version_history (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES schedule_versions(id) ON DELETE CASCADE,
action VARCHAR(30) NOT NULL,
actor_id BIGINT REFERENCES users(id),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_schedule_version_history_action CHECK (
action IN ('CREATED', 'PUBLISHED', 'ARCHIVED', 'RESTORED')
),
CONSTRAINT chk_schedule_version_history_reason CHECK (
reason IS NULL OR length(trim(reason)) BETWEEN 1 AND 2000
)
);
CREATE INDEX idx_schedule_version_history_version
ON schedule_version_history(version_id, created_at DESC, id DESC);
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
COMMENT ON COLUMN schedule_versions.restored_from_version_id IS 'Предыдущая опубликованная версия при восстановлении';
COMMENT ON COLUMN schedule_rules.schedule_version_id IS 'Версия расписания, которой принадлежит правило';
COMMENT ON COLUMN schedule_rules.version_group_id IS 'Стабильный идентификатор правила между версиями';
COMMENT ON TABLE schedule_version_history IS 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,17 @@
package com.magistr.app.controller;
import com.magistr.app.dto.AcademicCalendarDto;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendar;
import com.magistr.app.model.AcademicCalendarActivityType;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.EducationForm;
import com.magistr.app.model.Speciality;
import com.magistr.app.model.SpecialtyProfile;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import com.magistr.app.repository.AcademicCalendarRepository;
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
import com.magistr.app.repository.AcademicYearRepository;
import com.magistr.app.repository.EducationFormRepository;
import com.magistr.app.repository.SpecialtiesRepository;
import com.magistr.app.repository.SpecialtyProfileRepository;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.service.AcademicCalendarGridService;
import com.magistr.app.service.AcademicStructureService;
@@ -20,22 +19,42 @@ import com.magistr.app.service.ScheduleGeneratorService;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.Optional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AcademicCalendarControllerTest {
@Test
void createCalendarBuildsTitleWithoutClientValue() {
@org.junit.jupiter.api.DisplayName("Страница графиков ограничивает размер и сохраняет фильтр учебного года")
void returnsBoundedCalendarPage() throws Exception {
AcademicCalendarRepository repository = mock(AcademicCalendarRepository.class);
var pageable = org.springframework.data.domain.PageRequest.of(0, 100,
org.springframework.data.domain.Sort.by("academicYear.startDate").descending()
.and(org.springframework.data.domain.Sort.by("title", "id")));
when(repository.searchOptions("профиль", 9L, null, null, null, pageable))
.thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(), pageable, 0));
var controller = new AcademicCalendarController(repository,
mock(AcademicCalendarPeriodRepository.class), mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class), mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class), mock(AcademicStructureService.class));
org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build()
.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.get("/api/admin/academic-calendars/page")
.param("query", " ПРОФИЛЬ ").param("academicYearId", "9")
.param("page", "-1").param("size", "1000"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.size").value(100))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.page").value(0))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.items").isArray());
}
@Test
void createCalendarReturnsCalendarCreatedByTransactionalService() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicYearRepository academicYearRepository = mock(AcademicYearRepository.class);
SpecialtiesRepository specialtiesRepository = mock(SpecialtiesRepository.class);
SpecialtyProfileRepository profileRepository = mock(SpecialtyProfileRepository.class);
EducationFormRepository educationFormRepository = mock(EducationFormRepository.class);
AcademicStructureService academicStructureService = mock(AcademicStructureService.class);
AcademicYear year = new AcademicYear();
year.setId(1L);
@@ -51,26 +70,6 @@ class AcademicCalendarControllerTest {
studyForm.setId(4L);
studyForm.setName("Очная");
when(academicYearRepository.findById(1L)).thenReturn(Optional.of(year));
when(specialtiesRepository.findById(2L)).thenReturn(Optional.of(speciality));
when(profileRepository.findById(3L)).thenReturn(Optional.of(profile));
when(educationFormRepository.findById(4L)).thenReturn(Optional.of(studyForm));
when(calendarRepository.save(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarDayRepository.class),
academicYearRepository,
specialtiesRepository,
profileRepository,
educationFormRepository,
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
mock(AcademicStructureService.class)
);
AcademicCalendarDto request = new AcademicCalendarDto(
null,
null,
@@ -87,12 +86,97 @@ class AcademicCalendarControllerTest {
null,
4
);
AcademicCalendar saved = new AcademicCalendar();
saved.setTitle("09.03.04 — Разработка ПО — Очная — 2026-2027");
saved.setAcademicYear(year);
saved.setSpeciality(speciality);
saved.setSpecialtyProfile(profile);
saved.setStudyForm(studyForm);
saved.setCourseCount(4);
when(academicStructureService.createCalendar(request)).thenReturn(saved);
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
mock(AcademicCalendarPeriodRepository.class),
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
academicStructureService
);
var response = controller.createCalendar(request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(AcademicCalendarDto.class);
assertThat(((AcademicCalendarDto) response.getBody()).title())
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
.isEqualTo(saved.getTitle());
}
@Test
void getGridExpandsPeriodIntoDailyRows() {
AcademicCalendarRepository calendarRepository = mock(AcademicCalendarRepository.class);
AcademicCalendarPeriodRepository periodRepository = mock(AcademicCalendarPeriodRepository.class);
AcademicYear year = new AcademicYear();
year.setId(1L);
year.setStartDate(LocalDate.of(2026, 9, 1));
year.setEndDate(LocalDate.of(2027, 6, 30));
AcademicCalendar calendar = new AcademicCalendar();
calendar.setId(10L);
calendar.setAcademicYear(year);
AcademicCalendarActivityType activityType = new AcademicCalendarActivityType();
activityType.setId(20L);
activityType.setCode("Т");
activityType.setName("Теоретическое обучение");
activityType.setAllowSchedule(true);
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
period.setId(30L);
period.setAcademicCalendar(calendar);
period.setCourseNumber(1);
period.setStartDate(LocalDate.of(2026, 9, 6));
period.setEndDate(LocalDate.of(2026, 9, 8));
period.setActivityType(activityType);
when(calendarRepository.existsById(10L)).thenReturn(true);
when(periodRepository.findByCalendarIdWithActivity(10L)).thenReturn(List.of(period));
AcademicCalendarController controller = new AcademicCalendarController(
calendarRepository,
periodRepository,
mock(SubjectRepository.class),
mock(AcademicCalendarSubjectRepository.class),
mock(AcademicCalendarGridService.class),
mock(ScheduleGeneratorService.class),
mock(AcademicStructureService.class)
);
var response = controller.getGrid(10L);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isInstanceOf(List.class);
@SuppressWarnings("unchecked")
List<AcademicCalendarGridDayDto> rows = (List<AcademicCalendarGridDayDto>) response.getBody();
assertThat(rows)
.extracting(AcademicCalendarGridDayDto::date)
.containsExactly(
LocalDate.of(2026, 9, 6),
LocalDate.of(2026, 9, 7),
LocalDate.of(2026, 9, 8)
);
assertThat(rows)
.extracting(AcademicCalendarGridDayDto::weekNumber)
.containsExactly(1, 2, 2);
assertThat(rows)
.extracting(AcademicCalendarGridDayDto::dayOfWeek)
.containsExactly(7, 1, 2);
assertThat(rows)
.allSatisfy(row -> {
assertThat(row.id()).isEqualTo(30L);
assertThat(row.activityTypeId()).isEqualTo(20L);
});
}
}

View File

@@ -0,0 +1,71 @@
package com.magistr.app.controller;
import com.magistr.app.dto.ScheduleQualityAnalysisDto;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.service.ScheduleQualityService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class ScheduleQualityControllerTest {
private ScheduleQualityService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(ScheduleQualityService.class);
mockMvc = standaloneSetup(new ScheduleQualityController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsExplainableSemesterScore() throws Exception {
when(service.analyze(7L, 12L)).thenReturn(new ScheduleQualityAnalysisDto(
7L,
"2026/2027 · осенний семестр",
LocalDate.of(2026, 9, 1),
LocalDate.of(2026, 12, 31),
82,
18,
420,
3,
false,
List.of(new ScheduleQualityAnalysisDto.Metric(
"GROUP_GAPS",
"Окна у групп",
3,
"окон",
6,
"Свободные пары между занятиями"
)),
List.of(),
12L,
2,
"Черновик новой сетки",
ScheduleVersionStatus.DRAFT
));
mockMvc.perform(get("/api/edu-office/schedule/quality")
.param("semesterId", "7")
.param("versionId", "12"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(82))
.andExpect(jsonPath("$.scheduleVersionStatus").value("DRAFT"))
.andExpect(jsonPath("$.metrics[0].code").value("GROUP_GAPS"));
verify(service).analyze(7L, 12L);
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More