12 Commits

Author SHA1 Message Date
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
156 changed files with 17897 additions and 1121 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

File diff suppressed because it is too large Load Diff

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 и удаление БД я не выполняю без отдельного явного разрешения: это меняет
внешнюю систему и может прервать работу либо уничтожить данные.

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,11 +7,12 @@ 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.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;
@@ -25,11 +26,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 +34,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;
@@ -79,10 +68,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 +94,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 +137,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 +207,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

@@ -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

@@ -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

@@ -34,8 +34,9 @@ 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("/{id}")
@@ -46,6 +47,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 +57,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 +80,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

@@ -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

@@ -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

@@ -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,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

@@ -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

@@ -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

@@ -17,9 +17,11 @@ public interface ScheduleRuleRepository extends JpaRepository<ScheduleRule, Long
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 +32,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 +44,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 +58,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 +70,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 +84,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 +96,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 +110,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 +121,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 +134,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 +167,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

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

@@ -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

@@ -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,38 @@ 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(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

@@ -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,
private List<AcademicCalendarPeriod> buildReplacement(AcademicCalendar calendar,
List<AcademicCalendarGridDayDto> rows) {
List<AcademicCalendarDay> replacement = new ArrayList<>(rows.size());
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,13 @@ 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.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,6 +17,7 @@ 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.NoSuchElementException;
@@ -26,13 +32,19 @@ public class AcademicPeriodService {
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
@@ -102,6 +114,7 @@ public class AcademicPeriodService {
semester.setAcademicYear(year);
applySemester(semester, request);
Semester saved = saveSemester(semester);
createInitialPublishedSchedule(saved);
clearScheduleCacheAfterCommit();
return saved;
}
@@ -235,6 +248,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()) {

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

@@ -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);

View File

@@ -56,6 +56,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 +107,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 +190,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 +337,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 +352,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())

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;
@@ -24,25 +25,38 @@ public class ScheduleQueryService {
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,
@@ -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

@@ -2,6 +2,7 @@ package com.magistr.app.service;
import com.magistr.app.dto.ScheduleRuleDto;
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 +11,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 +23,14 @@ 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -70,6 +76,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 +102,32 @@ 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 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 +152,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 +175,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 +186,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 +211,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 +318,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 +337,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 +480,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 +554,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 +812,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 +826,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 +903,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;
-- ===============================
-- Справочники высшего уровня
@@ -714,22 +715,25 @@ JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profi
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,20 +754,23 @@ AS $$
) + 1
$$;
INSERT INTO academic_calendar_days (calendar_id, course_number, date, week_number, day_of_week, activity_type_id)
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
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,
ay.start_date,
ay.end_date,
activity.id
FROM academic_calendars calendar
JOIN academic_years ay ON ay.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'Т'
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
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;
CROSS JOIN GENERATE_SERIES(1, calendar.course_count) AS course(course_number);
CREATE TABLE IF NOT EXISTS academic_calendar_subjects (
id BIGSERIAL PRIMARY KEY,
@@ -1134,7 +1141,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 +1282,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 'Номер недели календарного учебного графика по периодам понедельник–воскресенье';
@@ -1579,8 +1585,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 (
@@ -1818,7 +1822,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 +1844,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 +1859,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. Исправьте seed-данные V1 и повторите создание схемы.',
assignment_violations,
calendar_day_violations,
calendar_period_violations,
calendar_subject_violations
);
END IF;
@@ -1990,12 +1994,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 +2063,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 +2082,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 +2136,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 +2182,323 @@ 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);
INSERT INTO schedule_versions (
semester_id,
version_number,
name,
status,
created_at,
published_at
)
SELECT
semester.id,
1,
'Опубликованная версия 1',
'PUBLISHED',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM semesters semester;
ALTER TABLE schedule_rules
ADD COLUMN schedule_version_id BIGINT;
UPDATE schedule_rules rule
SET schedule_version_id = version.id,
version_group_id = COALESCE(rule.version_group_id, rule.id)
FROM schedule_versions version
WHERE version.semester_id = rule.semester_id
AND version.status = 'PUBLISHED';
ALTER TABLE schedule_rules
ALTER COLUMN schedule_version_id SET NOT NULL,
ADD CONSTRAINT fk_schedule_rules_version
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);
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'CREATED', 'Начальная версия расписания', created_at
FROM schedule_versions;
INSERT INTO schedule_version_history (version_id, action, reason, created_at)
SELECT id, 'PUBLISHED', 'Начальная публикация расписания', published_at
FROM schedule_versions;
COMMENT ON TABLE schedule_versions IS 'Изолированные черновики и опубликованные версии правил расписания';
COMMENT ON COLUMN schedule_versions.status IS 'DRAFT, PUBLISHED или ARCHIVED';
COMMENT ON COLUMN schedule_versions.based_on_version_id IS 'Версия, из которой был скопирован черновик';
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 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';

View File

@@ -0,0 +1,24 @@
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT
calendar.id,
course.course_number,
academic_year.start_date,
academic_year.end_date,
theory_activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year
ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types theory_activity
ON theory_activity.code = 'Т'
CROSS JOIN LATERAL GENERATE_SERIES(1, calendar.course_count) AS course(course_number)
WHERE NOT EXISTS (
SELECT 1
FROM academic_calendar_periods existing_period
WHERE existing_period.calendar_id = calendar.id
);

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,18 @@ 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() {
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 +46,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 +62,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

@@ -118,7 +118,8 @@ class ScheduleRuleAdminControllerTest {
1,
List.of(3L),
List.of("Группа"),
List.of()
List.of(),
9L
);
}
@@ -127,6 +128,7 @@ class ScheduleRuleAdminControllerTest {
{
"subjectId": 1,
"semesterId": 2,
"scheduleVersionId": 9,
"lectureAcademicHours": 2,
"laboratoryAcademicHours": 0,
"practiceAcademicHours": 0,

View File

@@ -0,0 +1,136 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherAbsenceDto;
import com.magistr.app.dto.TeacherAbsenceWizardDto;
import com.magistr.app.service.TeacherAbsenceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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.request.MockMvcRequestBuilders.post;
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 TeacherAbsenceControllerTest {
private TeacherAbsenceService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(TeacherAbsenceService.class);
mockMvc = standaloneSetup(new TeacherAbsenceController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void createsAbsenceWithRussianReason() throws Exception {
when(service.create(any())).thenReturn(absence("APPROVED"));
mockMvc.perform(post("/api/teacher-absences")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"teacherId": 11,
"startDate": "2026-09-10",
"endDate": "2026-09-12",
"reason": "Командировка"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.teacherName").value("Иванов Иван Иванович"))
.andExpect(jsonPath("$.status").value("APPROVED"));
}
@Test
void returnsWizardWithAffectedLessonsAndJournal() throws Exception {
TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto(
absence("APPROVED"),
List.of(new TeacherAbsenceWizardDto.AffectedLesson(
501L,
LocalDate.of(2026, 9, 10),
java.time.LocalTime.of(9, 0),
java.time.LocalTime.of(10, 30),
40L,
"Базы данных",
3L,
"Лекция",
8L,
"101",
List.of("ИВТ-21"),
null,
null,
List.of(),
List.of(),
List.of(),
true
)),
List.of()
);
when(service.getWizard(101L)).thenReturn(wizard);
mockMvc.perform(get("/api/teacher-absences/101/wizard"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lessons[0].subjectName").value("Базы данных"))
.andExpect(jsonPath("$.lessons[0].cancellationAvailable").value(true));
verify(service).getWizard(101L);
}
@Test
void appliesSelectedDecisionsAsBatch() throws Exception {
TeacherAbsenceWizardDto wizard = new TeacherAbsenceWizardDto(
absence("RESOLVED"),
List.of(),
List.of()
);
when(service.resolve(eq(101L), any())).thenReturn(wizard);
mockMvc.perform(post("/api/teacher-absences/101/resolve")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"decisions": [{
"baseRuleSlotId": 501,
"lessonDate": "2026-09-10",
"resolution": "CANCEL"
}]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.absence.status").value("RESOLVED"));
}
private TeacherAbsenceDto absence(String status) {
return new TeacherAbsenceDto(
101L,
11L,
"Иванов Иван Иванович",
7L,
LocalDate.of(2026, 9, 10),
LocalDate.of(2026, 9, 12),
"Командировка",
status,
1L,
1L,
null,
Instant.parse("2026-08-06T10:00:00Z"),
Instant.parse("2026-08-06T10:00:00Z"),
Instant.parse("2026-08-06T10:00:00Z"),
0
);
}
}

View File

@@ -0,0 +1,90 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherChangeRequestDto;
import com.magistr.app.service.TeacherChangeRequestService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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.post;
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 TeacherChangeRequestControllerTest {
private TeacherChangeRequestService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(TeacherChangeRequestService.class);
mockMvc = standaloneSetup(new TeacherChangeRequestController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void createsMoveRequestWithRussianReason() throws Exception {
when(service.create(any())).thenReturn(request("PENDING"));
mockMvc.perform(post("/api/teacher-change-requests")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"baseRuleSlotId": 40,
"lessonDate": "2026-09-14",
"requestType": "MOVE",
"targetLessonDate": "2026-09-16",
"requestedTimeSlotId": 4,
"reason": "Участие в конференции"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.reason").value("Участие в конференции"));
}
@Test
void approvesRequestAndReturnsAppliedOverride() throws Exception {
when(service.review(eq(12L), any())).thenReturn(request("APPROVED"));
mockMvc.perform(post("/api/teacher-change-requests/12/review")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"approved": true, "comment": "Свободный слот подтверждён"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("APPROVED"))
.andExpect(jsonPath("$.appliedOverrideId").value(77));
verify(service).review(eq(12L), any());
}
private TeacherChangeRequestDto request(String status) {
Instant now = Instant.parse("2026-08-11T10:00:00Z");
return new TeacherChangeRequestDto(
12L, 11L, "Иванов Иван Иванович", 7L, 40L,
LocalDate.of(2026, 9, 14), "Базы данных", "Лекция", List.of("ИВТ-21"),
2L, 1, LocalTime.of(9, 0), LocalTime.of(10, 30), 8L, "101",
"MOVE", LocalDate.of(2026, 9, 16), 4L, 2,
LocalTime.of(10, 40), LocalTime.of(12, 10), null, null,
"Участие в конференции", status, status.equals("APPROVED") ? 1L : null,
status.equals("APPROVED") ? "Свободный слот подтверждён" : null,
status.equals("APPROVED") ? now : null, status.equals("APPROVED") ? 77L : null,
now, now,
List.of(new TeacherChangeRequestDto.HistoryItem(1L, status, "Комментарий", 1L, now))
);
}
}

View File

@@ -0,0 +1,77 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherPreferenceDto;
import com.magistr.app.service.TeacherPreferenceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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.request.MockMvcRequestBuilders.post;
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 TeacherPreferenceControllerTest {
private TeacherPreferenceService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(TeacherPreferenceService.class);
mockMvc = standaloneSetup(new TeacherPreferenceController(service))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsApprovedPreferencesForSemester() throws Exception {
when(service.getAll(5L, "APPROVED")).thenReturn(List.of(preference("APPROVED")));
mockMvc.perform(get("/api/teacher-preferences")
.param("semesterId", "5")
.param("status", "APPROVED"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].preferenceType").value("SOFT_PREFERRED"))
.andExpect(jsonPath("$[0].teacherName").value("Иванов Иван Иванович"));
}
@Test
void reviewsPendingPreference() throws Exception {
when(service.review(eq(9L), any())).thenReturn(preference("APPROVED"));
mockMvc.perform(post("/api/teacher-preferences/9/review")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"approved": true, "comment": "Учтено при составлении расписания"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("APPROVED"));
verify(service).review(eq(9L), any());
}
private TeacherPreferenceDto preference(String status) {
Instant now = Instant.parse("2026-08-11T10:00:00Z");
return new TeacherPreferenceDto(
9L, 11L, "Иванов Иван Иванович", 7L, 5L, "2026/2027", "AUTUMN",
LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
"SOFT_PREFERRED", 1, "Понедельник", 3L, 2,
LocalTime.of(10, 40), LocalTime.of(12, 10), null, null, status,
11L, status.equals("APPROVED") ? 1L : null, null,
status.equals("APPROVED") ? now : null, now, now
);
}
}

View File

@@ -82,6 +82,36 @@ class WorkloadControllerTest {
.containsOnly(1L);
}
@Test
void departmentTeacherWorkloadUsesRequestedAdditionalDepartment() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
DepartmentRepository departmentRepository = mock(DepartmentRepository.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 31);
RenderedLessonDto lesson = lesson(10L, "Петров Пётр", LocalDate.of(2026, 5, 15), 1L);
when(scheduleQueryService.searchForAggregation(7L, null, startDate, endDate))
.thenReturn(List.of(lesson));
when(departmentRepository.findById(7L))
.thenReturn(Optional.of(new Department(7L, "Кафедра ИБ", 7L)));
WorkloadController controller = new WorkloadController(
scheduleQueryService,
mock(ClassroomRepository.class),
departmentRepository,
teacherDepartmentService
);
authenticate(Role.DEPARTMENT, 7L);
List<WorkloadSummaryDto> result = controller.teacherWorkload(startDate, endDate, null);
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(10L);
assertThat(item.departmentId()).isEqualTo(7L);
assertThat(item.lessonCount()).isEqualTo(1L);
});
verifyNoInteractions(teacherDepartmentService);
}
@Test
void freeClassroomsSkipsNullAvailabilityWithoutNpe() {
ScheduleQueryService scheduleQueryService = mock(ScheduleQueryService.class);

View File

@@ -0,0 +1,148 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@DisplayName("Миграция начальных периодов календарного графика")
class AcademicCalendarDefaultTheoryPeriodMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(com.magistr.app.testing.TestContainerImages.POSTGRES);
@Test
@DisplayName("V2 заполняет кодом Т только полностью пустые графики")
void migrationBackfillsOnlyEmptyCalendars() throws SQLException {
Flyway baseline = flyway("1");
baseline.clean();
baseline.migrate();
long emptyCalendarId;
long configuredCalendarId;
try (Connection connection = POSTGRES.createConnection("")) {
emptyCalendarId = insertCalendarFromSeed(connection, "Пустой график для V2", 3);
configuredCalendarId = insertCalendarFromSeed(connection, "Настроенный график для V2", 2);
insertConfiguredPeriod(connection, configuredCalendarId);
}
Flyway allMigrations = flyway(null);
allMigrations.migrate();
assertThat(allMigrations.info().current()).isNotNull();
assertThat(allMigrations.info().current().getVersion().getVersion()).isEqualTo("2");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(readSummary(connection, emptyCalendarId))
.isEqualTo(new PeriodSummary(3, 1, 3, true));
assertThat(readSummary(connection, configuredCalendarId))
.isEqualTo(new PeriodSummary(1, 1, 1, false));
}
}
private Flyway flyway(String targetVersion) {
var configuration = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.cleanDisabled(false);
if (targetVersion != null) {
configuration.target(MigrationVersion.fromVersion(targetVersion));
}
return configuration.load();
}
private long insertCalendarFromSeed(Connection connection,
String title,
int courseCount) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendars (
title,
academic_year_id,
specialty_id,
specialty_profile_id,
study_form_id,
course_count
)
SELECT ?, academic_year_id, specialty_id, specialty_profile_id, study_form_id, ?
FROM academic_calendars
ORDER BY id
LIMIT 1
RETURNING id
""")) {
statement.setString(1, title);
statement.setInt(2, courseCount);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}
private void insertConfiguredPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
)
SELECT calendar.id, 1, academic_year.start_date, academic_year.start_date, activity.id
FROM academic_calendars calendar
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.code = 'К'
WHERE calendar.id = ?
""")) {
statement.setLong(1, calendarId);
assertThat(statement.executeUpdate()).isEqualTo(1);
}
}
private PeriodSummary readSummary(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT
COUNT(*),
MIN(period.course_number),
MAX(period.course_number),
BOOL_AND(
activity.code = 'Т'
AND period.start_date = academic_year.start_date
AND period.end_date = academic_year.end_date
)
FROM academic_calendar_periods period
JOIN academic_calendars calendar ON calendar.id = period.calendar_id
JOIN academic_years academic_year ON academic_year.id = calendar.academic_year_id
JOIN academic_calendar_activity_types activity ON activity.id = period.activity_type_id
WHERE period.calendar_id = ?
""")) {
statement.setLong(1, calendarId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return new PeriodSummary(
resultSet.getInt(1),
resultSet.getInt(2),
resultSet.getInt(3),
resultSet.getBoolean(4)
);
}
}
}
private record PeriodSummary(int count,
int minCourse,
int maxCourse,
boolean fullYearTheory) {
}
}

View File

@@ -1,6 +1,7 @@
package com.magistr.app.migration;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
@@ -30,6 +31,7 @@ class AcademicCalendarWeekNumberBaselineIntegrationTest {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();

View File

@@ -92,9 +92,14 @@ class AcademicStructureInvariantIntegrationTest {
"сохранённая сетка выходит за новые измерения"
);
assertSqlState(
() -> insertOutOfRangeCalendarDay(connection, calendarId),
() -> insertOutOfRangeCalendarPeriod(connection, calendarId),
"23514",
"Строка сетки выходит за измерения календарного графика"
"Период активности выходит за измерения календарного графика"
);
assertSqlState(
() -> insertOverlappingCalendarPeriod(connection, calendarId),
"23P01",
"ex_calendar_periods_no_overlap"
);
assertSqlState(
() -> insertOutOfRangeCalendarSubject(connection, calendarId),
@@ -479,16 +484,15 @@ class AcademicStructureInvariantIntegrationTest {
}
}
private void insertOutOfRangeCalendarDay(Connection connection, long calendarId) throws SQLException {
private void insertOutOfRangeCalendarPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_days (
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
date,
week_number,
day_of_week,
start_date,
end_date,
activity_type_id
) VALUES (?, 5, DATE '2025-09-01', 1, 1, (
) VALUES (?, 5, DATE '2025-09-01', DATE '2025-09-01', (
SELECT id FROM academic_calendar_activity_types WHERE code = 'Т'
))
""")) {
@@ -497,6 +501,23 @@ class AcademicStructureInvariantIntegrationTest {
}
}
private void insertOverlappingCalendarPeriod(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_periods (
calendar_id,
course_number,
start_date,
end_date,
activity_type_id
) VALUES (?, 1, DATE '2025-09-15', DATE '2025-09-30', (
SELECT id FROM academic_calendar_activity_types WHERE code = 'К'
))
""")) {
statement.setLong(1, calendarId);
statement.executeUpdate();
}
}
private void insertOutOfRangeCalendarSubject(Connection connection, long calendarId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO academic_calendar_subjects (calendar_id, semester_number, subject_id)

View File

@@ -44,6 +44,13 @@ class ScheduleRuleHoursMigrationIntegrationTest {
try (Connection connection = POSTGRES.createConnection("")) {
SeedIds seedIds = loadSeedIds(connection);
long rulesBeforeInsert = queryLong(connection, "SELECT count(*) FROM schedule_rules");
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_rules WHERE schedule_version_id IS NULL"))
.as("Все исходные правила должны принадлежать опубликованной версии")
.isZero();
assertThat(queryLong(connection, "SELECT count(*) FROM schedule_versions WHERE status = 'PUBLISHED'"))
.as("V1 должна создать по одной опубликованной версии на семестр")
.isEqualTo(queryLong(connection, "SELECT count(*) FROM semesters"));
assertSecondPublishedVersionRejected(connection, seedIds.semesterId());
insertScheduleRule(connection, seedIds, 2, 4, 6);
@@ -83,7 +90,8 @@ class ScheduleRuleHoursMigrationIntegrationTest {
private SeedIds loadSeedIds(Connection connection) throws SQLException {
return new SeedIds(
queryLong(connection, "SELECT id FROM subjects ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1")
queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1"),
queryLong(connection, "SELECT id FROM schedule_versions ORDER BY id LIMIT 1")
);
}
@@ -98,19 +106,21 @@ class ScheduleRuleHoursMigrationIntegrationTest {
INSERT INTO schedule_rules (
subject_id,
semester_id,
schedule_version_id,
lecture_academic_hours,
laboratory_academic_hours,
practice_academic_hours,
lecture_start_week,
laboratory_start_week,
practice_start_week
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 1)
""")) {
statement.setLong(1, seedIds.subjectId());
statement.setLong(2, seedIds.semesterId());
statement.setInt(3, lectureHours);
statement.setInt(4, laboratoryHours);
statement.setInt(5, practiceHours);
statement.setLong(3, seedIds.scheduleVersionId());
statement.setInt(4, lectureHours);
statement.setInt(5, laboratoryHours);
statement.setInt(6, practiceHours);
statement.executeUpdate();
}
}
@@ -143,6 +153,23 @@ class ScheduleRuleHoursMigrationIntegrationTest {
.contains(HOURS_CONSTRAINT);
}
private void assertSecondPublishedVersionRejected(Connection connection, long semesterId) {
Throwable failure = catchThrowable(() -> {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO schedule_versions (
semester_id, version_number, name, status, published_at
) VALUES (?, 99, 'Недопустимая вторая публикация', 'PUBLISHED', CURRENT_TIMESTAMP)
""")) {
statement.setLong(1, semesterId);
statement.executeUpdate();
}
});
assertThat(failure)
.as("В одном семестре не может быть двух опубликованных версий")
.isInstanceOf(SQLException.class);
assertThat(((SQLException) failure).getSQLState()).isEqualTo("23505");
}
private void assertExactSlotDuplicateRejected(Connection connection) {
Throwable failure = catchThrowable(() -> duplicateExistingSlot(connection));
@@ -201,6 +228,6 @@ class ScheduleRuleHoursMigrationIntegrationTest {
}
}
private record SeedIds(long subjectId, long semesterId) {
private record SeedIds(long subjectId, long semesterId, long scheduleVersionId) {
}
}

View File

@@ -45,13 +45,16 @@ class TeacherDepartmentInvariantIntegrationTest {
long teacherId = seedTeacherId(connection);
long firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
long secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2026, 7, 31));
LocalDate initialStart = openPrimaryStart(connection, teacherId);
LocalDate initialEnd = initialStart.plusDays(30);
LocalDate transferStart = initialEnd.plusDays(1);
updateOpenPrimaryEnd(connection, teacherId, initialEnd);
insertAssignment(
connection,
teacherId,
secondDepartmentId,
LocalDate.of(2026, 8, 1),
transferStart,
null,
true
);
@@ -59,7 +62,7 @@ class TeacherDepartmentInvariantIntegrationTest {
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 1),
transferStart,
null,
false
);
@@ -69,8 +72,8 @@ class TeacherDepartmentInvariantIntegrationTest {
connection,
teacherId,
firstDepartmentId,
LocalDate.of(2026, 8, 15),
LocalDate.of(2026, 9, 1),
transferStart.plusDays(14),
transferStart.plusDays(31),
true
),
"23P01",
@@ -94,11 +97,14 @@ class TeacherDepartmentInvariantIntegrationTest {
long teacherId;
long firstDepartmentId;
long secondDepartmentId;
LocalDate firstCandidateStart;
try (Connection connection = POSTGRES.createConnection("")) {
teacherId = seedTeacherId(connection);
firstDepartmentId = queryLong(connection, "SELECT min(id) FROM departments");
secondDepartmentId = queryLong(connection, "SELECT max(id) FROM departments");
updateOpenPrimaryEnd(connection, teacherId, LocalDate.of(2089, 12, 31));
LocalDate initialStart = openPrimaryStart(connection, teacherId);
updateOpenPrimaryEnd(connection, teacherId, initialStart.plusDays(30));
firstCandidateStart = initialStart.plusDays(31);
}
ExecutorService executor = Executors.newFixedThreadPool(2);
@@ -109,16 +115,16 @@ class TeacherDepartmentInvariantIntegrationTest {
executor.submit(() -> concurrentInsert(
teacherId,
firstDepartmentId,
LocalDate.of(2090, 1, 1),
LocalDate.of(2090, 12, 31),
firstCandidateStart,
firstCandidateStart.plusDays(364),
ready,
start
)),
executor.submit(() -> concurrentInsert(
teacherId,
secondDepartmentId,
LocalDate.of(2090, 6, 1),
LocalDate.of(2091, 5, 31),
firstCandidateStart.plusDays(151),
firstCandidateStart.plusDays(515),
ready,
start
))
@@ -186,6 +192,22 @@ class TeacherDepartmentInvariantIntegrationTest {
);
}
private LocalDate openPrimaryStart(Connection connection, long teacherId) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT valid_from
FROM teacher_department_assignments
WHERE teacher_id = ?
AND is_primary = TRUE
AND valid_to IS NULL
""")) {
statement.setLong(1, teacherId);
try (ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getDate(1).toLocalDate();
}
}
}
private void updateOpenPrimaryEnd(Connection connection,
long teacherId,
LocalDate validTo) throws SQLException {

View File

@@ -0,0 +1,85 @@
package com.magistr.app.migration;
import com.magistr.app.testing.TestContainerImages;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@Testcontainers
@DisplayName("Преподавательские процессы в базовой схеме")
class TeacherPreferencesMigrationIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(TestContainerImages.POSTGRES);
@Test
@DisplayName("V1 содержит таблицы прежних V2/V3 и отклоняет противоречивое пожелание")
void migrationCreatesWorkflowTablesAndConstraints() throws SQLException {
Flyway flyway = Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("1"))
.cleanDisabled(false)
.load();
flyway.clean();
flyway.migrate();
assertThat(flyway.info().current().getVersion().getVersion()).isEqualTo("1");
try (Connection connection = POSTGRES.createConnection("")) {
assertThat(queryLong(connection, """
SELECT count(*)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN (
'teacher_absences',
'teacher_absence_decisions',
'teacher_preferences',
'teacher_change_requests',
'teacher_change_request_history'
)
""")).isEqualTo(5L);
long teacherId = queryLong(connection, "SELECT id FROM users WHERE role = 'TEACHER' ORDER BY id LIMIT 1");
long semesterId = queryLong(connection, "SELECT id FROM semesters ORDER BY id LIMIT 1");
long timeSlotId = queryLong(connection, "SELECT id FROM time_slots ORDER BY id LIMIT 1");
Throwable failure = catchThrowable(() -> {
try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO teacher_preferences (
teacher_id, semester_id, preference_type,
day_of_week, time_slot_id, preference_date, status
) VALUES (?, ?, 'SOFT_PREFERRED', 1, ?, CURRENT_DATE, 'PENDING')
""")) {
statement.setLong(1, teacherId);
statement.setLong(2, semesterId);
statement.setLong(3, timeSlotId);
statement.executeUpdate();
}
});
assertThat(failure).isInstanceOf(SQLException.class);
assertThat(((SQLException) failure).getSQLState()).isEqualTo("23514");
assertThat(failure.getMessage()).contains("chk_teacher_preferences_payload");
}
}
private long queryLong(Connection connection, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
assertThat(resultSet.next()).isTrue();
return resultSet.getLong(1);
}
}
}

View File

@@ -1,8 +1,8 @@
package com.magistr.app.service;
import com.magistr.app.dto.AcademicCalendarGridDayDto;
import com.magistr.app.model.AcademicCalendarDay;
import com.magistr.app.repository.AcademicCalendarDayRepository;
import com.magistr.app.model.AcademicCalendarPeriod;
import com.magistr.app.repository.AcademicCalendarPeriodRepository;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -110,12 +110,13 @@ class AcademicCalendarGridServiceIntegrationTest {
assertThat(fingerprint(calendarId)).isEqualTo(before);
assertThat(queryLong("""
SELECT count(*)
FROM academic_calendar_days day
WHERE day.calendar_id = ?
AND day.course_number = 1
AND day.date = ?
AND day.activity_type_id = ?
""", calendarId, yearStart, holidayActivityId)).isZero();
FROM academic_calendar_periods period
WHERE period.calendar_id = ?
AND period.course_number = 1
AND period.start_date <= ?
AND period.end_date >= ?
AND period.activity_type_id = ?
""", calendarId, yearStart, yearStart, holidayActivityId)).isZero();
verify(scheduleGeneratorService, never()).clearCache();
AcademicCalendarGridDayDto secondValid = row(
@@ -131,15 +132,26 @@ class AcademicCalendarGridServiceIntegrationTest {
assertThat(after).isNotEqualTo(before);
assertThat(queryLong("""
SELECT count(*)
FROM academic_calendar_days day
WHERE day.calendar_id = ?
FROM academic_calendar_periods period
WHERE period.calendar_id = ?
AND (
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
OR
(day.course_number = 1 AND day.date = ? AND day.activity_type_id = ?)
(
period.course_number = 1
AND period.start_date = ?
AND period.end_date = ?
AND period.activity_type_id = ?
)
""", calendarId, yearStart, holidayActivityId,
yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
OR
(
period.course_number = 1
AND period.start_date = ?
AND period.end_date = ?
AND period.activity_type_id = ?
)
)
""", calendarId,
yearStart, yearStart, holidayActivityId,
yearStart.plusDays(1), yearStart.plusDays(1), theoryActivityId)).isEqualTo(2L);
verify(scheduleGeneratorService, times(1)).clearCache();
}
@@ -149,16 +161,15 @@ class AcademicCalendarGridServiceIntegrationTest {
count(*) AS row_count,
md5(string_agg(
concat_ws('|',
day.course_number::text,
day.date::text,
day.week_number::text,
day.day_of_week::text,
day.activity_type_id::text
period.course_number::text,
period.start_date::text,
period.end_date::text,
period.activity_type_id::text
),
E'\\n' ORDER BY day.course_number, day.date, day.id
E'\\n' ORDER BY period.course_number, period.start_date, period.id
)) AS content_hash
FROM academic_calendar_days day
WHERE day.calendar_id = ?
FROM academic_calendar_periods period
WHERE period.calendar_id = ?
""", (result, rowNumber) -> new GridFingerprint(
result.getLong("row_count"),
result.getString("content_hash")
@@ -196,8 +207,8 @@ class AcademicCalendarGridServiceIntegrationTest {
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = AcademicCalendarDay.class)
@EnableJpaRepositories(basePackageClasses = AcademicCalendarDayRepository.class)
@EntityScan(basePackageClasses = AcademicCalendarPeriod.class)
@EnableJpaRepositories(basePackageClasses = AcademicCalendarPeriodRepository.class)
@Import({AcademicCalendarGridService.class, TestDependencies.class})
static class TestApplication {
}

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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -41,7 +41,7 @@ class AcademicCalendarGridServiceTest {
@Mock
private AcademicCalendarRepository calendarRepository;
@Mock
private AcademicCalendarDayRepository calendarDayRepository;
private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
@@ -73,7 +73,7 @@ class AcademicCalendarGridServiceTest {
service = new AcademicCalendarGridService(
calendarRepository,
calendarDayRepository,
calendarPeriodRepository,
activityTypeRepository,
scheduleGeneratorService
);
@@ -180,17 +180,18 @@ class AcademicCalendarGridServiceTest {
service.replaceGrid(CALENDAR_ID, List.of(first, second));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
InOrder mutationOrder = inOrder(calendarDayRepository, scheduleGeneratorService);
mutationOrder.verify(calendarDayRepository).deleteByCalendarId(CALENDAR_ID);
mutationOrder.verify(calendarDayRepository).flush();
mutationOrder.verify(calendarDayRepository).saveAll(replacementCaptor.capture());
mutationOrder.verify(calendarDayRepository).flush();
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
InOrder mutationOrder = inOrder(calendarPeriodRepository, scheduleGeneratorService);
mutationOrder.verify(calendarPeriodRepository).deleteByCalendarId(CALENDAR_ID);
mutationOrder.verify(calendarPeriodRepository).flush();
mutationOrder.verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
mutationOrder.verify(calendarPeriodRepository).flush();
mutationOrder.verify(scheduleGeneratorService).clearCache();
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getDate)
.containsExactly(YEAR_START, YEAR_START.plusDays(1));
assertThat(replacementCaptor.getValue()).singleElement().satisfies(period -> {
assertThat(period.getStartDate()).isEqualTo(YEAR_START);
assertThat(period.getEndDate()).isEqualTo(YEAR_START.plusDays(1));
});
assertThat(replacementCaptor.getValue())
.allSatisfy(day -> {
assertThat(day.getAcademicCalendar().getId()).isEqualTo(CALENDAR_ID);
@@ -212,11 +213,22 @@ class AcademicCalendarGridServiceTest {
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
.extracting(AcademicCalendarPeriod::getStartDate)
.containsExactly(
yearStart,
LocalDate.of(2026, 9, 6),
LocalDate.of(2026, 9, 14)
);
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarPeriod::getEndDate)
.containsExactly(
yearStart,
LocalDate.of(2026, 9, 7),
LocalDate.of(2026, 9, 14)
);
}
@Test
@@ -233,11 +245,22 @@ class AcademicCalendarGridServiceTest {
));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarDay>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarDayRepository).saveAll(replacementCaptor.capture());
ArgumentCaptor<List<AcademicCalendarPeriod>> replacementCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarPeriodRepository).saveAll(replacementCaptor.capture());
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarDay::getWeekNumber)
.containsExactly(1, 1, 2, 3);
.extracting(AcademicCalendarPeriod::getStartDate)
.containsExactly(
yearStart,
LocalDate.of(2027, 9, 5),
LocalDate.of(2027, 9, 13)
);
assertThat(replacementCaptor.getValue())
.extracting(AcademicCalendarPeriod::getEndDate)
.containsExactly(
yearStart,
LocalDate.of(2027, 9, 6),
LocalDate.of(2027, 9, 13)
);
}
private AcademicCalendarGridDayDto validRow(LocalDate date, int weekNumber, int dayOfWeek) {
@@ -263,10 +286,10 @@ class AcademicCalendarGridServiceTest {
}
private void verifyNoMutation() {
verify(calendarDayRepository, never()).deleteByCalendarId(any());
verify(calendarDayRepository, never()).flush();
verify(calendarDayRepository, never()).save(any());
verify(calendarDayRepository, never()).saveAll(any());
verify(calendarPeriodRepository, never()).deleteByCalendarId(any());
verify(calendarPeriodRepository, never()).flush();
verify(calendarPeriodRepository, never()).save(any());
verify(calendarPeriodRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache();
}
}

View File

@@ -3,9 +3,13 @@ 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.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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -42,6 +46,10 @@ class AcademicPeriodServiceTest {
private SemesterRepository semesterRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleVersionHistoryRepository scheduleVersionHistoryRepository;
private AcademicPeriodService service;
private AcademicYear year;
@@ -51,13 +59,17 @@ class AcademicPeriodServiceTest {
service = new AcademicPeriodService(
academicYearRepository,
semesterRepository,
scheduleGeneratorService
scheduleGeneratorService,
scheduleVersionRepository,
scheduleVersionHistoryRepository
);
year = year();
when(academicYearRepository.saveAndFlush(any(AcademicYear.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(semesterRepository.saveAndFlush(any(Semester.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(scheduleVersionRepository.saveAndFlush(any(ScheduleVersion.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
@@ -158,6 +170,12 @@ class AcademicPeriodServiceTest {
assertThat(saved.getStartDate()).isEqualTo(LocalDate.of(2027, 2, 1));
assertThat(saved.getAcademicYear()).isSameAs(year);
verify(scheduleVersionRepository).saveAndFlush(org.mockito.ArgumentMatchers.argThat(version ->
version.getSemester() == saved
&& version.getStatus() == ScheduleVersionStatus.PUBLISHED
&& "Основное расписание".equals(version.getName())
));
verify(scheduleVersionHistoryRepository, org.mockito.Mockito.times(2)).save(any());
verify(scheduleGeneratorService).clearCache();
}

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;
@@ -23,6 +26,7 @@ import com.magistr.app.repository.SubgroupRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
@@ -54,7 +58,9 @@ class AcademicStructureServiceTest {
@Mock
private AcademicCalendarRepository calendarRepository;
@Mock
private AcademicCalendarDayRepository calendarDayRepository;
private AcademicCalendarPeriodRepository calendarPeriodRepository;
@Mock
private AcademicCalendarActivityTypeRepository activityTypeRepository;
@Mock
private AcademicCalendarSubjectRepository calendarSubjectRepository;
@Mock
@@ -87,7 +93,8 @@ class AcademicStructureServiceTest {
void setUp() {
service = new AcademicStructureService(
calendarRepository,
calendarDayRepository,
calendarPeriodRepository,
activityTypeRepository,
calendarSubjectRepository,
academicYearRepository,
specialtiesRepository,
@@ -118,12 +125,54 @@ class AcademicStructureServiceTest {
when(subgroupRepository.sumActiveStudentCapacityByGroupId(GROUP_ID)).thenReturn(0L);
when(calendarRepository.saveAndFlush(any(AcademicCalendar.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
AcademicCalendarActivityType theoryActivity = new AcademicCalendarActivityType();
theoryActivity.setId(80L);
theoryActivity.setCode("Т");
theoryActivity.setName("Теоретическое обучение");
theoryActivity.setAllowSchedule(true);
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.of(theoryActivity));
when(groupRepository.saveAndFlush(any(StudentGroup.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(assignmentRepository.saveAndFlush(any(StudentGroupCalendarAssignment.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void createsCalendarWithTheoryPeriodForEveryCourseAcrossWholeAcademicYear() {
AcademicCalendar saved = service.createCalendar(calendarRequest(3, FORM_ID));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<AcademicCalendarPeriod>> periodsCaptor = ArgumentCaptor.forClass(List.class);
verify(calendarPeriodRepository).saveAll(periodsCaptor.capture());
assertThat(saved.getTitle())
.isEqualTo("09.03.01 — Без профиля — Бакалавриат — 2025-2026");
assertThat(periodsCaptor.getValue())
.extracting(AcademicCalendarPeriod::getCourseNumber)
.containsExactly(1, 2, 3);
assertThat(periodsCaptor.getValue()).allSatisfy(period -> {
assertThat(period.getAcademicCalendar()).isSameAs(saved);
assertThat(period.getStartDate()).isEqualTo(academicYear.getStartDate());
assertThat(period.getEndDate()).isEqualTo(academicYear.getEndDate());
assertThat(period.getActivityType().getCode()).isEqualTo("Т");
assertThat(period.getActivityType().getAllowSchedule()).isTrue();
});
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsCalendarCreationWhenSchedulingTheoryActivityIsMissing() {
when(activityTypeRepository.findByCode("Т")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.createCalendar(calendarRequest(4, FORM_ID)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Код активности «Т», разрешающий обычные занятия, не найден");
verify(calendarRepository, never()).saveAndFlush(any());
verify(calendarPeriodRepository, never()).saveAll(any());
verify(scheduleGeneratorService, never()).clearCache();
}
@Test
void rejectsCalendarDimensionChangeAndKeepsEntityUnchanged() {
when(assignmentRepository.findByCalendarIdWithDetails(CALENDAR_ID))
@@ -142,7 +191,7 @@ class AcademicStructureServiceTest {
@Test
void rejectsCourseCountBelowExistingGrid() {
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
when(calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
CALENDAR_ID, 3)).thenReturn(true);
assertThatThrownBy(() -> service.updateCalendar(

View File

@@ -32,7 +32,7 @@ class ScheduleGenerationQueryCountTest {
SemesterRepository semesterRepository = mock(SemesterRepository.class);
StudentGroupCalendarAssignmentRepository assignmentRepository =
mock(StudentGroupCalendarAssignmentRepository.class);
AcademicCalendarDayRepository calendarDayRepository = mock(AcademicCalendarDayRepository.class);
AcademicCalendarPeriodRepository calendarPeriodRepository = mock(AcademicCalendarPeriodRepository.class);
ScheduleRuleRepository ruleRepository = mock(ScheduleRuleRepository.class);
TimeSlotDateAssignmentRepository dateAssignmentRepository = mock(TimeSlotDateAssignmentRepository.class);
TimeSlotScopeRepository scopeRepository = mock(TimeSlotScopeRepository.class);
@@ -47,9 +47,9 @@ class ScheduleGenerationQueryCountTest {
List<StudentGroupCalendarAssignment> assignments = groups.stream()
.map(group -> assignment(group, academicYear, calendar))
.toList();
List<AcademicCalendarDay> calendarDays = START_DATE.datesUntil(END_DATE.plusDays(1))
.map(date -> calendarDay(calendar, date))
.toList();
List<AcademicCalendarPeriod> calendarPeriods = List.of(
calendarPeriod(calendar, START_DATE, END_DATE)
);
List<ScheduleRule> rules = IntStream.rangeClosed(1, RULE_COUNT)
.mapToObj(index -> rule(index, semester, groups))
.toList();
@@ -63,8 +63,12 @@ class ScheduleGenerationQueryCountTest {
.thenReturn(List.of(semester));
when(assignmentRepository.findForScheduleBatch(anyCollection(), anyCollection()))
.thenReturn(assignments);
when(calendarDayRepository.findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE)))
.thenReturn(calendarDays);
when(calendarPeriodRepository.findForScheduleBatch(
anyCollection(),
any(),
eq(START_DATE),
eq(END_DATE)
)).thenReturn(calendarPeriods);
when(ruleRepository.findByGroupIdsAndSemesterIds(anyCollection(), anyCollection()))
.thenReturn(rules);
when(dateAssignmentRepository.findForScheduleRange(START_DATE, END_DATE)).thenReturn(List.of());
@@ -74,7 +78,7 @@ class ScheduleGenerationQueryCountTest {
AcademicDateService academicDateService = new AcademicDateService(
semesterRepository,
calendarDayRepository,
calendarPeriodRepository,
assignmentRepository,
false
);
@@ -103,7 +107,12 @@ class ScheduleGenerationQueryCountTest {
verify(semesterRepository).findOverlappingForSchedule(START_DATE, END_DATE);
verify(assignmentRepository).findForScheduleBatch(anyCollection(), anyCollection());
verify(calendarDayRepository).findForScheduleBatch(anyCollection(), eq(START_DATE), eq(END_DATE));
verify(calendarPeriodRepository).findForScheduleBatch(
anyCollection(),
any(),
eq(START_DATE),
eq(END_DATE)
);
verify(ruleRepository).findByGroupIdsAndSemesterIds(anyCollection(), anyCollection());
verify(dateAssignmentRepository).findForScheduleRange(START_DATE, END_DATE);
verify(scopeRepository).findByApplyModeOrderByDisplayOrderAsc("WEEKDAY");
@@ -112,7 +121,7 @@ class ScheduleGenerationQueryCountTest {
verify(semesterRepository, never())
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqual(any(), any());
verify(assignmentRepository, never()).findForSchedule(any(), any());
verify(calendarDayRepository, never())
verify(calendarPeriodRepository, never())
.findByCalendarIdAndCourseNumberAndDate(any(), any(), any());
verify(ruleRepository, never()).findByGroupIdAndSemesterId(any(), any());
verify(dateAssignmentRepository, never()).findByDate(any());
@@ -122,7 +131,7 @@ class ScheduleGenerationQueryCountTest {
int repositoryCalls = Stream.of(
semesterRepository,
assignmentRepository,
calendarDayRepository,
calendarPeriodRepository,
ruleRepository,
dateAssignmentRepository,
scopeRepository,
@@ -209,19 +218,22 @@ class ScheduleGenerationQueryCountTest {
return assignment;
}
private AcademicCalendarDay calendarDay(AcademicCalendar calendar, LocalDate date) {
private AcademicCalendarPeriod calendarPeriod(AcademicCalendar calendar,
LocalDate startDate,
LocalDate endDate) {
AcademicCalendarActivityType theory = new AcademicCalendarActivityType();
theory.setId(30L);
theory.setCode("Т");
theory.setName("Теоретическое обучение");
theory.setAllowSchedule(true);
AcademicCalendarDay day = new AcademicCalendarDay();
day.setAcademicCalendar(calendar);
day.setCourseNumber(1);
day.setDate(date);
day.setActivityType(theory);
return day;
AcademicCalendarPeriod period = new AcademicCalendarPeriod();
period.setAcademicCalendar(calendar);
period.setCourseNumber(1);
period.setStartDate(startDate);
period.setEndDate(endDate);
period.setActivityType(theory);
return period;
}
private ScheduleRule rule(int index, Semester semester, List<StudentGroup> groups) {

View File

@@ -0,0 +1,315 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.AcademicYear;
import com.magistr.app.model.Classroom;
import com.magistr.app.model.ScheduleParity;
import com.magistr.app.model.ScheduleVersion;
import com.magistr.app.model.ScheduleVersionStatus;
import com.magistr.app.model.Semester;
import com.magistr.app.model.SemesterType;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.model.TimeSlot;
import com.magistr.app.repository.ClassroomRepository;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.SemesterRepository;
import com.magistr.app.repository.ScheduleVersionRepository;
import com.magistr.app.repository.SubgroupRepository;
import com.magistr.app.repository.TeacherPreferenceRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ScheduleQualityServiceTest {
private ScheduleQueryService scheduleQueryService;
private SemesterRepository semesterRepository;
private GroupRepository groupRepository;
private SubgroupRepository subgroupRepository;
private ClassroomRepository classroomRepository;
private TeacherPreferenceRepository preferenceRepository;
private EffectiveTimeSlotService effectiveTimeSlotService;
private ScheduleOverrideService scheduleOverrideService;
private ScheduleQualityService service;
@BeforeEach
void setUp() {
scheduleQueryService = mock(ScheduleQueryService.class);
semesterRepository = mock(SemesterRepository.class);
groupRepository = mock(GroupRepository.class);
subgroupRepository = mock(SubgroupRepository.class);
classroomRepository = mock(ClassroomRepository.class);
preferenceRepository = mock(TeacherPreferenceRepository.class);
effectiveTimeSlotService = mock(EffectiveTimeSlotService.class);
scheduleOverrideService = mock(ScheduleOverrideService.class);
service = new ScheduleQualityService(
scheduleQueryService,
semesterRepository,
groupRepository,
subgroupRepository,
classroomRepository,
preferenceRepository,
effectiveTimeSlotService,
scheduleOverrideService
);
}
@Test
void analysisExplainsGroupGapAndCapacityShortage() {
Semester semester = semester();
StudentGroup group = group(1L, 30L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate date = LocalDate.of(2026, 9, 7);
List<RenderedLessonDto> lessons = List.of(
lesson(11L, date, 1, 1L, room, group),
lesson(12L, date, 3, 3L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.lessonCount()).isEqualTo(2);
assertThat(result.score()).isLessThan(100);
assertThat(result.problems().stream()
.flatMap(problem -> problem.problemTypes().stream()))
.contains("GROUP_GAP", "TEACHER_GAP", "ROOM_CAPACITY_SHORTAGE");
assertThat(result.metrics())
.filteredOn(metric -> "GROUP_GAPS".equals(metric.code()))
.singleElement()
.satisfies(metric -> assertThat(metric.value()).isEqualTo(1.0));
}
@Test
void analysisAggregatesRecurringProblemsByRuleAndSumsPenalty() {
Semester semester = semester();
StudentGroup group = group(1L, 30L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate firstDate = LocalDate.of(2026, 9, 7);
List<RenderedLessonDto> lessons = List.of(
lesson(777L, 11L, firstDate, 1, 1L, room, group),
lesson(777L, 11L, firstDate.plusWeeks(1), 1, 1L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.totalProblemCount()).isOne();
assertThat(result.rawPenalty()).isEqualTo(18);
assertThat(result.problems())
.singleElement()
.satisfies(problem -> {
assertThat(problem.scheduleRuleId()).isEqualTo(777L);
assertThat(problem.affectedLessonCount()).isEqualTo(2);
assertThat(problem.penalty()).isEqualTo(18);
assertThat(problem.problemTypes()).containsExactly("ROOM_CAPACITY_SHORTAGE");
assertThat(problem.issueLabels()).containsExactly("Аудитория меньше состава занятия");
});
}
@Test
void analysisMeasuresDailyDistributionSeparatelyFromOverload() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom room = classroom(1L, "А-101", 20);
LocalDate monday = LocalDate.of(2026, 9, 7);
LocalDate tuesday = monday.plusDays(1);
List<RenderedLessonDto> lessons = List.of(
lesson(11L, monday, 1, 1L, room, group),
lesson(12L, tuesday, 1, 1L, room, group),
lesson(13L, tuesday, 2, 2L, room, group),
lesson(14L, tuesday, 3, 3L, room, group)
);
stubContext(semester, group, List.of(room), lessons);
var result = service.analyze(semester.getId());
assertThat(result.metrics())
.filteredOn(metric -> "DISTRIBUTION_BALANCE".equals(metric.code()))
.singleElement()
.satisfies(metric -> {
assertThat(metric.value()).isEqualTo(50.0);
assertThat(metric.penalty()).isZero();
});
}
@Test
void recommendationsOfferOnlyValidatedTighterClassroom() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom oversized = classroom(1L, "Актовый зал", 120);
Classroom compact = classroom(2L, "А-204", 24);
LocalDate date = LocalDate.of(2026, 9, 7);
RenderedLessonDto source = lesson(11L, date, 1, 1L, oversized, group);
stubContext(semester, group, List.of(oversized, compact), List.of(source));
when(effectiveTimeSlotService.findEffective(date)).thenReturn(List.of(timeSlot(1L, 1)));
var result = service.recommendations(semester.getId(), 11L, date);
assertThat(result.candidates())
.filteredOn(candidate -> "REPLACE".equals(candidate.action()))
.singleElement()
.satisfies(candidate -> {
assertThat(candidate.label()).contains("А-204", "24 мест");
assertThat(candidate.penaltyDelta()).isPositive();
assertThat(candidate.override().newClassroomId()).isEqualTo(2L);
});
verify(scheduleOverrideService).validateCandidate(any());
}
@Test
void draftQualityUsesItsOwnRulesInsteadOfPublishedSchedule() {
Semester semester = semester();
StudentGroup group = group(1L, 20L);
Classroom room = classroom(1L, "А-101", 20);
ScheduleVersion draft = new ScheduleVersion();
draft.setId(12L);
draft.setSemester(semester);
draft.setVersionNumber(2);
draft.setName("Черновик новой сетки");
draft.setStatus(ScheduleVersionStatus.DRAFT);
ScheduleVersionRepository versionRepository = mock(ScheduleVersionRepository.class);
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
service.setVersionDependencies(versionRepository, generatorService);
when(versionRepository.findByIdWithDetails(12L)).thenReturn(Optional.of(draft));
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
when(generatorService.buildScheduleForVersion(12L, semester.getStartDate(), semester.getEndDate()))
.thenReturn(List.of(lesson(11L, LocalDate.of(2026, 9, 7), 1, 1L, room, group)));
when(groupRepository.findAll()).thenReturn(List.of(group));
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
when(classroomRepository.findAll()).thenReturn(List.of(room));
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
var result = service.analyze(semester.getId(), 12L);
assertThat(result.scheduleVersionStatus()).isEqualTo(ScheduleVersionStatus.DRAFT);
assertThat(result.scheduleVersionId()).isEqualTo(12L);
assertThat(result.lessonCount()).isOne();
verify(scheduleQueryService, never()).searchForAggregation(any(), any(), any(), any());
}
private void stubContext(
Semester semester,
StudentGroup group,
List<Classroom> classrooms,
List<RenderedLessonDto> lessons
) {
when(semesterRepository.findById(semester.getId())).thenReturn(Optional.of(semester));
when(scheduleQueryService.searchForAggregation(
null,
null,
semester.getStartDate(),
semester.getEndDate()
)).thenReturn(lessons);
when(groupRepository.findAll()).thenReturn(List.of(group));
when(subgroupRepository.findAllWithGroupsOrderByGroupNameAndName()).thenReturn(List.of());
when(classroomRepository.findAll()).thenReturn(classrooms);
when(preferenceRepository.findApprovedForSemester(semester.getId())).thenReturn(List.of());
}
private Semester semester() {
AcademicYear academicYear = new AcademicYear();
academicYear.setId(1L);
academicYear.setTitle("2026/2027");
academicYear.setStartDate(LocalDate.of(2026, 9, 1));
academicYear.setEndDate(LocalDate.of(2027, 8, 31));
Semester semester = new Semester();
semester.setId(7L);
semester.setAcademicYear(academicYear);
semester.setSemesterType(SemesterType.autumn);
semester.setStartDate(LocalDate.of(2026, 9, 1));
semester.setEndDate(LocalDate.of(2026, 9, 30));
return semester;
}
private StudentGroup group(Long id, Long size) {
StudentGroup group = new StudentGroup();
group.setId(id);
group.setName("ИБ-101");
group.setGroupSize(size);
return group;
}
private Classroom classroom(Long id, String name, int capacity) {
Classroom classroom = new Classroom();
classroom.setId(id);
classroom.setName(name);
classroom.setCapacity(capacity);
classroom.setIsAvailable(true);
return classroom;
}
private TimeSlot timeSlot(Long id, int order) {
TimeSlot slot = new TimeSlot();
slot.setId(id);
slot.setOrderNumber(order);
slot.setStartTime(LocalTime.of(8 + order, 0));
slot.setEndTime(LocalTime.of(9 + order, 30));
return slot;
}
private RenderedLessonDto lesson(
Long ruleSlotId,
LocalDate date,
int order,
Long timeSlotId,
Classroom classroom,
StudentGroup group
) {
return lesson(100L + ruleSlotId, ruleSlotId, date, order, timeSlotId, classroom, group);
}
private RenderedLessonDto lesson(
Long ruleId,
Long ruleSlotId,
LocalDate date,
int order,
Long timeSlotId,
Classroom classroom,
StudentGroup group
) {
return new RenderedLessonDto(
ruleId,
ruleSlotId,
date,
date.getDayOfWeek().getValue(),
"Понедельник",
1,
ScheduleParity.BOTH,
timeSlotId,
order,
LocalTime.of(8 + order, 0),
LocalTime.of(9 + order, 30),
1L,
"Архитектура систем",
5L,
"Петров Пётр Петрович",
classroom.getId(),
classroom.getName(),
1L,
"Лекция",
"Очно",
null,
null,
List.of(),
List.of(),
List.of(group.getId()),
List.of(group.getName()),
"Т",
36,
0,
34,
ScheduleParity.BOTH
);
}
}

View File

@@ -1,5 +1,7 @@
package com.magistr.app.service;
import com.magistr.app.dto.RenderedLessonDto;
import com.magistr.app.model.TeacherDepartmentAssignment;
import com.magistr.app.model.StudentGroup;
import com.magistr.app.repository.GroupRepository;
import com.magistr.app.repository.ScheduleOverrideRepository;
@@ -9,6 +11,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -138,6 +142,63 @@ class ScheduleQueryServiceTest {
verify(overrideRepository).findByLessonDateBetweenWithDetails(startDate, endDate);
}
@Test
void departmentSearchIncludesTeacherLessonsForGroupsFromAnotherDepartment() {
ScheduleGeneratorService generatorService = mock(ScheduleGeneratorService.class);
GroupRepository groupRepository = mock(GroupRepository.class);
ScheduleOverrideRepository overrideRepository = mock(ScheduleOverrideRepository.class);
StudentGroupLifecycleService groupLifecycleService = mock(StudentGroupLifecycleService.class);
TeacherDepartmentService teacherDepartmentService = mock(TeacherDepartmentService.class);
LocalDate startDate = LocalDate.of(2026, 5, 1);
LocalDate endDate = LocalDate.of(2026, 5, 7);
LocalDate lessonDate = LocalDate.of(2026, 5, 4);
StudentGroup externalGroup = group(20);
RenderedLessonDto ownTeacherLesson = mock(RenderedLessonDto.class);
RenderedLessonDto anotherDepartmentLesson = mock(RenderedLessonDto.class);
TeacherDepartmentAssignment ownAssignment = new TeacherDepartmentAssignment();
TeacherDepartmentAssignment anotherAssignment = new TeacherDepartmentAssignment();
when(ownTeacherLesson.teacherId()).thenReturn(10L);
when(ownTeacherLesson.date()).thenReturn(lessonDate);
when(anotherDepartmentLesson.teacherId()).thenReturn(11L);
when(anotherDepartmentLesson.date()).thenReturn(lessonDate);
when(groupRepository.findAll()).thenReturn(List.of(externalGroup));
when(groupLifecycleService.mayHaveScheduleInRange(externalGroup, startDate, endDate)).thenReturn(true);
when(generatorService.buildScheduleForGroups(any(), eq(startDate), eq(endDate)))
.thenReturn(List.of(ownTeacherLesson, anotherDepartmentLesson));
when(overrideRepository.findByLessonDateBetweenWithDetails(startDate, endDate)).thenReturn(List.of());
when(teacherDepartmentService.findAssignmentsForTeachersBetween(
Set.of(10L, 11L),
startDate,
endDate
)).thenReturn(Map.of(
10L, List.of(ownAssignment),
11L, List.of(anotherAssignment)
));
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(ownAssignment), 7L, lessonDate
)).thenReturn(true);
when(teacherDepartmentService.hasDepartmentAssignmentAtDate(
List.of(anotherAssignment), 7L, lessonDate
)).thenReturn(false);
ScheduleQueryService service = new ScheduleQueryService(
generatorService,
groupRepository,
overrideRepository,
groupLifecycleService,
mock(AcademicDateService.class),
teacherDepartmentService
);
var result = service.searchForAggregation(7L, null, startDate, endDate);
assertThat(result).singleElement().satisfies(lesson ->
assertThat(lesson.teacherId()).isEqualTo(10L)
);
verify(groupRepository).findAll();
verify(groupRepository, never()).findByDepartmentId(7L);
}
private StudentGroup group(int id) {
StudentGroup group = new StudentGroup();
group.setId((long) id);

View File

@@ -267,9 +267,15 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
VALUES (?, 'autumn', DATE '2098-09-01', DATE '2099-01-31')
RETURNING id
""", Long.class, academicYearId);
Long scheduleVersionId = jdbcTemplate.queryForObject("""
INSERT INTO schedule_versions (semester_id, version_number, name, status)
VALUES (?, 1, 'Конкурентный черновик', 'DRAFT')
RETURNING id
""", Long.class, semesterId);
return new SeedIds(
semesterId,
scheduleVersionId,
queryId("SELECT id FROM subjects WHERE name = 'Высшая математика'"),
queryId("SELECT id FROM student_groups WHERE name = 'ИВТ-21-1'"),
queryId("""
@@ -332,7 +338,8 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
1,
List.of(ids.groupId()),
List.of(),
List.of(slot)
List.of(slot),
ids.scheduleVersionId()
);
}
@@ -355,6 +362,7 @@ class ScheduleRuleServiceConcurrencyIntegrationTest {
private record SeedIds(
long semesterId,
long scheduleVersionId,
long subjectId,
long groupId,
long timeSlotId,

View File

@@ -10,6 +10,8 @@ import com.magistr.app.model.Role;
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.SemesterType;
import com.magistr.app.model.StudentGroup;
@@ -22,6 +24,8 @@ 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;
@@ -88,6 +92,10 @@ class ScheduleRuleServiceTest {
private SubgroupRepository subgroupRepository;
@Mock
private ScheduleGeneratorService scheduleGeneratorService;
@Mock
private ScheduleVersionRepository scheduleVersionRepository;
@Mock
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
private ScheduleRuleService service;
private Subject subject;
@@ -130,6 +138,8 @@ class ScheduleRuleServiceTest {
subgroupRepository,
scheduleGeneratorService
);
service.setScheduleVersionRepository(scheduleVersionRepository);
service.setScheduleRuleSlotRepository(scheduleRuleSlotRepository);
when(semesterRepository.findByIdForUpdate(SEMESTER_ID)).thenReturn(Optional.of(semester));
when(semesterRepository.findAllByIdForUpdateOrderById(List.of(SEMESTER_ID)))
@@ -188,6 +198,76 @@ class ScheduleRuleServiceTest {
assertThat(saved.practiceAcademicHours()).isZero();
}
@Test
void allowsEditingPublishedVersionButRejectsArchive() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, null)).thenReturn(List.of());
ScheduleRuleDto saved = service.create(ruleRequestForVersion(90L, lectureSlot()));
assertThat(saved.scheduleVersionId()).isEqualTo(90L);
assertThat(saved.id()).isNotNull();
ScheduleVersion archived = scheduleVersion(91L, ScheduleVersionStatus.ARCHIVED);
when(scheduleVersionRepository.findByIdForUpdate(91L)).thenReturn(Optional.of(archived));
assertThatThrownBy(() -> service.create(ruleRequestForVersion(91L, lectureSlot())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Архивное расписание доступно только для просмотра; выберите опубликованное расписание или черновик");
}
@Test
void updatesRuleInsidePublishedVersionImmediately() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
managed.setScheduleVersion(published);
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
ScheduleRuleDto updated = service.update(
RULE_ID,
ruleRequestForVersion(90L, slot(
RULE_ID * 10,
ScheduleParity.BOTH,
TEACHER_ID,
CLASSROOM_ID,
LECTURE_TYPE_ID,
List.of(),
"Онлайн"
))
);
assertThat(updated.scheduleVersionId()).isEqualTo(90L);
assertThat(updated.slots()).singleElement()
.satisfies(slot -> {
assertThat(slot.id()).isEqualTo(RULE_ID * 10);
assertThat(slot.lessonFormat()).isEqualTo("Онлайн");
});
verify(scheduleGeneratorService).clearCache();
}
@Test
void rejectsRemovingPublishedSlotReferencedByOperationalHistory() {
ScheduleVersion published = scheduleVersion(90L, ScheduleVersionStatus.PUBLISHED);
ScheduleRule managed = persistedRule(RULE_ID, LifecycleEntity.STATUS_ACTIVE, "Очно");
managed.setScheduleVersion(published);
when(scheduleRuleRepository.findByIdForUpdate(RULE_ID)).thenReturn(Optional.of(managed));
when(scheduleVersionRepository.findByIdForUpdate(90L)).thenReturn(Optional.of(published));
when(scheduleRuleRepository.findActiveByVersionIdWithDetails(90L, RULE_ID)).thenReturn(List.of());
when(scheduleRuleSlotRepository.hasProtectedReferences(List.of(RULE_ID * 10))).thenReturn(true);
assertThatThrownBy(() -> service.update(
RULE_ID,
ruleRequestForVersion(90L, lectureSlot())
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Нельзя удалить слот: на него ссылаются точечные изменения, заявки преподавателей или решения по отсутствиям");
verify(scheduleRuleRepository, never()).saveAndFlush(managed);
}
@Test
void rejectsActiveUserWithoutTeacherRole() {
User administrator = user(TEACHER_ID, Role.ADMIN, "Администратор");
@@ -432,10 +512,42 @@ class ScheduleRuleServiceTest {
1,
List.of(GROUP_ID),
null,
List.of(slots)
List.of(slots),
null
);
}
private ScheduleRuleDto ruleRequestForVersion(Long versionId, ScheduleRuleSlotDto... slots) {
return new ScheduleRuleDto(
null,
SUBJECT_ID,
null,
SEMESTER_ID,
null,
null,
2,
0,
0,
1,
1,
1,
List.of(GROUP_ID),
null,
List.of(slots),
versionId
);
}
private ScheduleVersion scheduleVersion(Long id, ScheduleVersionStatus status) {
ScheduleVersion version = new ScheduleVersion();
version.setId(id);
version.setSemester(semester);
version.setVersionNumber(1);
version.setName("Тестовая версия");
version.setStatus(status);
return version;
}
private ScheduleRuleSlotDto lectureSlot() {
return slot(
ScheduleParity.BOTH, TEACHER_ID, CLASSROOM_ID,
@@ -460,8 +572,18 @@ class ScheduleRuleServiceTest {
long lessonTypeId,
List<Long> subgroupIds,
String format) {
return slot(null, parity, teacherId, classroomId, lessonTypeId, subgroupIds, format);
}
private ScheduleRuleSlotDto slot(Long id,
ScheduleParity parity,
long teacherId,
long classroomId,
long lessonTypeId,
List<Long> subgroupIds,
String format) {
return new ScheduleRuleSlotDto(
null,
id,
1,
null,
parity,

View File

@@ -0,0 +1,163 @@
package com.magistr.app.service;
import com.magistr.app.dto.ScheduleVersionCreateDto;
import com.magistr.app.model.AcademicYear;
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.SemesterType;
import com.magistr.app.model.Subject;
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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
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.verify;
import static org.mockito.Mockito.when;
class ScheduleVersionServiceTest {
private ScheduleVersionRepository versionRepository;
private ScheduleVersionHistoryRepository historyRepository;
private ScheduleRuleRepository ruleRepository;
private SemesterRepository semesterRepository;
private ScheduleRuleService ruleService;
private ScheduleGeneratorService generatorService;
private ScheduleVersionService service;
@BeforeEach
void setUp() {
versionRepository = mock(ScheduleVersionRepository.class);
historyRepository = mock(ScheduleVersionHistoryRepository.class);
ruleRepository = mock(ScheduleRuleRepository.class);
semesterRepository = mock(SemesterRepository.class);
ruleService = mock(ScheduleRuleService.class);
generatorService = mock(ScheduleGeneratorService.class);
service = new ScheduleVersionService(
versionRepository,
historyRepository,
ruleRepository,
semesterRepository,
mock(UserRepository.class),
ruleService,
generatorService
);
}
@Test
void draftClonesRulesWithoutChangingPublishedVersion() {
Semester semester = semester();
ScheduleVersion published = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
ScheduleRule source = rule(21L, 100L, published);
when(semesterRepository.findByIdForUpdate(semester.getId())).thenReturn(Optional.of(semester));
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(published));
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> {
ScheduleVersion draft = invocation.getArgument(0);
draft.setId(2L);
return draft;
});
when(ruleRepository.findActiveByVersionIdWithDetails(1L, null)).thenReturn(List.of(source));
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of());
when(versionRepository.findByIdWithDetails(2L)).thenAnswer(invocation -> Optional.of(
version(2L, 2, ScheduleVersionStatus.DRAFT, semester)
));
service.createDraft(new ScheduleVersionCreateDto(semester.getId(), "Рабочая копия", 1L));
ArgumentCaptor<List<ScheduleRule>> copies = ArgumentCaptor.forClass(List.class);
verify(ruleRepository).saveAllAndFlush(copies.capture());
assertThat(copies.getValue()).singleElement().satisfies(copy -> {
assertThat(copy.getId()).isNull();
assertThat(copy.getVersionGroupId()).isEqualTo(100L);
assertThat(copy.getScheduleVersion().getId()).isEqualTo(2L);
assertThat(copy.getSubject()).isSameAs(source.getSubject());
});
assertThat(published.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
}
@Test
void publicationArchivesCurrentVersionAndActivatesWholeDraft() {
Semester semester = semester();
ScheduleVersion current = version(1L, 1, ScheduleVersionStatus.PUBLISHED, semester);
ScheduleVersion draft = version(2L, 2, ScheduleVersionStatus.DRAFT, semester);
ScheduleRule draftRule = rule(22L, 100L, draft);
when(versionRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(draft));
when(versionRepository.findByIdWithDetails(2L)).thenReturn(Optional.of(draft));
when(versionRepository.findBySemesterIdForUpdate(semester.getId())).thenReturn(List.of(current, draft));
when(ruleRepository.findActiveByVersionIdWithDetails(2L, null)).thenReturn(List.of(draftRule));
when(ruleService.validateVersion(2L)).thenReturn(List.of());
when(versionRepository.saveAndFlush(any(ScheduleVersion.class))).thenAnswer(invocation -> invocation.getArgument(0));
service.publish(2L, "Утверждена новая сетка");
assertThat(current.getStatus()).isEqualTo(ScheduleVersionStatus.ARCHIVED);
assertThat(draft.getStatus()).isEqualTo(ScheduleVersionStatus.PUBLISHED);
assertThat(draft.getChangeReason()).isEqualTo("Утверждена новая сетка");
assertThat(draft.getPublishedAt()).isNotNull();
verify(generatorService).clearCache();
ArgumentCaptor<ScheduleVersionHistory> history = ArgumentCaptor.forClass(ScheduleVersionHistory.class);
verify(historyRepository, org.mockito.Mockito.times(2)).save(history.capture());
assertThat(history.getAllValues()).extracting(ScheduleVersionHistory::getAction)
.containsExactly("ARCHIVED", "PUBLISHED");
}
private Semester semester() {
AcademicYear year = new AcademicYear();
year.setId(5L);
year.setTitle("2026-2027");
Semester semester = new Semester();
semester.setId(7L);
semester.setAcademicYear(year);
semester.setSemesterType(SemesterType.autumn);
return semester;
}
private ScheduleVersion version(Long id,
int number,
ScheduleVersionStatus status,
Semester semester) {
ScheduleVersion version = new ScheduleVersion();
version.setId(id);
version.setSemester(semester);
version.setVersionNumber(number);
version.setName("Версия " + number);
version.setStatus(status);
return version;
}
private ScheduleRule rule(Long id, Long versionGroupId, ScheduleVersion version) {
Subject subject = new Subject();
subject.setId(11L);
subject.setName("Математика");
ScheduleRule rule = new ScheduleRule();
rule.setId(id);
rule.setSubject(subject);
rule.setSemester(version.getSemester());
rule.setScheduleVersion(version);
rule.setVersionGroupId(versionGroupId);
rule.setLectureAcademicHours(2);
rule.setLaboratoryAcademicHours(0);
rule.setPracticeAcademicHours(0);
rule.setLectureStartWeek(1);
rule.setLaboratoryStartWeek(1);
rule.setPracticeStartWeek(1);
ScheduleRuleSlot slot = new ScheduleRuleSlot();
slot.setScheduleRule(rule);
rule.setSlots(new LinkedHashSet<>(List.of(slot)));
return rule;
}
}

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