Compare commits
10 Commits
main
...
855d191517
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
855d191517 | ||
|
|
491e373cc3 | ||
|
|
18a97b293a | ||
|
|
519864b962 | ||
|
|
5276729a69 | ||
|
|
28268a38c0 | ||
|
|
8f8ce0240b | ||
|
|
840554c223 | ||
|
|
cc422255a2 | ||
|
|
87f3d98621 |
@@ -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
|
||||
|
||||
1578
AI_ASSISTANT_IMPLEMENTATION_PLAN.md
Normal file
1578
AI_ASSISTANT_IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
292
DEVOPS.md
292
DEVOPS.md
@@ -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.
|
||||
|
||||
Созданная инфраструктура обладает высокой степенью гибкости и готова к дальнейшему горизонтальному масштабированию по мере подключения новых высших учебных заведений к проекту «Магистр».
|
||||
160
STARTUP_GUIDE.md
160
STARTUP_GUIDE.md
@@ -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 и удаление БД я не выполняю без отдельного явного разрешения: это меняет
|
||||
внешнюю систему и может прервать работу либо уничтожить данные.
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,8 @@ 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,7 +27,7 @@ import java.util.stream.Collectors;
|
||||
public class AcademicCalendarController {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
private final SpecialtyProfileRepository profileRepository;
|
||||
@@ -37,7 +39,7 @@ public class AcademicCalendarController {
|
||||
private final AcademicStructureService academicStructureService;
|
||||
|
||||
public AcademicCalendarController(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
SpecialtyProfileRepository profileRepository,
|
||||
@@ -48,7 +50,7 @@ public class AcademicCalendarController {
|
||||
ScheduleGeneratorService scheduleGeneratorService,
|
||||
AcademicStructureService academicStructureService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
this.profileRepository = profileRepository;
|
||||
@@ -108,8 +110,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());
|
||||
}
|
||||
|
||||
@@ -254,15 +258,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(),
|
||||
|
||||
@@ -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", "Такой слот правила расписания уже существует"),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("Выберите версию расписания");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public record ScheduleRuleDto(
|
||||
Integer practiceStartWeek,
|
||||
List<Long> groupIds,
|
||||
List<String> groupNames,
|
||||
List<ScheduleRuleSlotDto> slots
|
||||
List<ScheduleRuleSlotDto> slots,
|
||||
Long scheduleVersionId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionActionDto(String reason) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record ScheduleVersionCreateDto(
|
||||
Long semesterId,
|
||||
String name,
|
||||
Long basedOnVersionId
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record TeacherAbsenceRequest(
|
||||
Long teacherId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherAbsenceReviewRequest(
|
||||
Boolean approved,
|
||||
String comment
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherChangeReviewRequest(Boolean approved, String comment) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TeacherPreferenceMetaDto(
|
||||
List<SemesterDto> semesters,
|
||||
List<TimeSlotDto> timeSlots
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.magistr.app.dto;
|
||||
|
||||
public record TeacherPreferenceReviewRequest(Boolean approved, String comment) {
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal file
166
backend/src/main/java/com/magistr/app/model/ScheduleVersion.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.magistr.app.model;
|
||||
|
||||
public enum ScheduleVersionStatus {
|
||||
DRAFT,
|
||||
PUBLISHED,
|
||||
ARCHIVED
|
||||
}
|
||||
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal file
141
backend/src/main/java/com/magistr/app/model/TeacherAbsence.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
@@ -35,7 +35,7 @@ import java.util.NoSuchElementException;
|
||||
public class AcademicStructureService {
|
||||
|
||||
private final AcademicCalendarRepository calendarRepository;
|
||||
private final AcademicCalendarDayRepository calendarDayRepository;
|
||||
private final AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
private final AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
private final AcademicYearRepository academicYearRepository;
|
||||
private final SpecialtiesRepository specialtiesRepository;
|
||||
@@ -47,7 +47,7 @@ public class AcademicStructureService {
|
||||
private final ScheduleGeneratorService scheduleGeneratorService;
|
||||
|
||||
public AcademicStructureService(AcademicCalendarRepository calendarRepository,
|
||||
AcademicCalendarDayRepository calendarDayRepository,
|
||||
AcademicCalendarPeriodRepository calendarPeriodRepository,
|
||||
AcademicCalendarSubjectRepository calendarSubjectRepository,
|
||||
AcademicYearRepository academicYearRepository,
|
||||
SpecialtiesRepository specialtiesRepository,
|
||||
@@ -58,7 +58,7 @@ public class AcademicStructureService {
|
||||
SubgroupRepository subgroupRepository,
|
||||
ScheduleGeneratorService scheduleGeneratorService) {
|
||||
this.calendarRepository = calendarRepository;
|
||||
this.calendarDayRepository = calendarDayRepository;
|
||||
this.calendarPeriodRepository = calendarPeriodRepository;
|
||||
this.calendarSubjectRepository = calendarSubjectRepository;
|
||||
this.academicYearRepository = academicYearRepository;
|
||||
this.specialtiesRepository = specialtiesRepository;
|
||||
@@ -106,7 +106,7 @@ public class AcademicStructureService {
|
||||
"Нельзя изменить график: сохранённые назначения групп станут несовместимыми"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
if (calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
id, request.courseCount())) {
|
||||
throw new ScheduleConflictException(
|
||||
"Нельзя уменьшить количество курсов: в сетке есть данные старших курсов"
|
||||
@@ -118,7 +118,7 @@ public class AcademicStructureService {
|
||||
"Нельзя уменьшить количество курсов: есть дисциплины старших семестров"
|
||||
);
|
||||
}
|
||||
if (calendarDayRepository.existsOutsideDateRange(
|
||||
if (calendarPeriodRepository.existsOutsideDateRange(
|
||||
id,
|
||||
candidate.academicYear().getStartDate(),
|
||||
candidate.academicYear().getEndDate())) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 'Неизменяемый журнал создания, публикации, архивации и восстановления версий';
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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;
|
||||
@@ -20,6 +23,7 @@ import com.magistr.app.service.ScheduleGeneratorService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -60,7 +64,7 @@ class AcademicCalendarControllerTest {
|
||||
|
||||
AcademicCalendarController controller = new AcademicCalendarController(
|
||||
calendarRepository,
|
||||
mock(AcademicCalendarDayRepository.class),
|
||||
mock(AcademicCalendarPeriodRepository.class),
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
profileRepository,
|
||||
@@ -95,4 +99,75 @@ class AcademicCalendarControllerTest {
|
||||
assertThat(((AcademicCalendarDto) response.getBody()).title())
|
||||
.isEqualTo("09.03.04 — Разработка ПО — Очная — 2026-2027");
|
||||
}
|
||||
|
||||
@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(AcademicYearRepository.class),
|
||||
mock(SpecialtiesRepository.class),
|
||||
mock(SpecialtyProfileRepository.class),
|
||||
mock(EducationFormRepository.class),
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.AcademicCalendarPeriodRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarRepository;
|
||||
import com.magistr.app.repository.AcademicCalendarSubjectRepository;
|
||||
import com.magistr.app.repository.AcademicYearRepository;
|
||||
@@ -54,7 +54,7 @@ class AcademicStructureServiceTest {
|
||||
@Mock
|
||||
private AcademicCalendarRepository calendarRepository;
|
||||
@Mock
|
||||
private AcademicCalendarDayRepository calendarDayRepository;
|
||||
private AcademicCalendarPeriodRepository calendarPeriodRepository;
|
||||
@Mock
|
||||
private AcademicCalendarSubjectRepository calendarSubjectRepository;
|
||||
@Mock
|
||||
@@ -87,7 +87,7 @@ class AcademicStructureServiceTest {
|
||||
void setUp() {
|
||||
service = new AcademicStructureService(
|
||||
calendarRepository,
|
||||
calendarDayRepository,
|
||||
calendarPeriodRepository,
|
||||
calendarSubjectRepository,
|
||||
academicYearRepository,
|
||||
specialtiesRepository,
|
||||
@@ -142,7 +142,7 @@ class AcademicStructureServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsCourseCountBelowExistingGrid() {
|
||||
when(calendarDayRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
when(calendarPeriodRepository.existsByAcademicCalendarIdAndCourseNumberGreaterThan(
|
||||
CALENDAR_ID, 3)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateCalendar(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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.TeacherAbsenceDto;
|
||||
import com.magistr.app.dto.TeacherAbsenceRequest;
|
||||
import com.magistr.app.dto.TeacherAbsenceResolutionRequest;
|
||||
import com.magistr.app.model.Role;
|
||||
import com.magistr.app.model.ScheduleParity;
|
||||
import com.magistr.app.model.ScheduleRuleSlot;
|
||||
import com.magistr.app.model.TeacherAbsence;
|
||||
import com.magistr.app.model.TeacherAbsenceDecision;
|
||||
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.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TeacherAbsenceServiceTest {
|
||||
|
||||
private static final LocalDate START = LocalDate.of(2026, 9, 10);
|
||||
private static final LocalDate END = LocalDate.of(2026, 9, 12);
|
||||
private static final Instant NOW = Instant.parse("2026-08-06T10:00:00Z");
|
||||
|
||||
@Mock
|
||||
private TeacherAbsenceRepository absenceRepository;
|
||||
@Mock
|
||||
private TeacherAbsenceDecisionRepository decisionRepository;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private TeacherSubjectRepository teacherSubjectRepository;
|
||||
@Mock
|
||||
private TeacherDepartmentAssignmentRepository assignmentRepository;
|
||||
@Mock
|
||||
private ClassroomRepository classroomRepository;
|
||||
@Mock
|
||||
private TimeSlotRepository timeSlotRepository;
|
||||
@Mock
|
||||
private ScheduleRuleSlotRepository scheduleRuleSlotRepository;
|
||||
@Mock
|
||||
private ScheduleOverrideRepository scheduleOverrideRepository;
|
||||
@Mock
|
||||
private ScheduleQueryService scheduleQueryService;
|
||||
@Mock
|
||||
private ScheduleOverrideService scheduleOverrideService;
|
||||
|
||||
private TeacherAbsenceService service;
|
||||
private User teacher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
teacher = new User();
|
||||
teacher.setId(11L);
|
||||
teacher.setUsername("teacher");
|
||||
teacher.setFullName("Иванов Иван Иванович");
|
||||
teacher.setRole(Role.TEACHER);
|
||||
teacher.setDepartmentId(7L);
|
||||
|
||||
BusinessTimeService time = new BusinessTimeService(
|
||||
Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
BusinessTimeService.DEFAULT_ZONE
|
||||
);
|
||||
service = new TeacherAbsenceService(
|
||||
absenceRepository,
|
||||
decisionRepository,
|
||||
userRepository,
|
||||
teacherSubjectRepository,
|
||||
assignmentRepository,
|
||||
classroomRepository,
|
||||
timeSlotRepository,
|
||||
scheduleRuleSlotRepository,
|
||||
scheduleOverrideRepository,
|
||||
scheduleQueryService,
|
||||
scheduleOverrideService,
|
||||
time
|
||||
);
|
||||
when(userRepository.findByIdForUpdate(teacher.getId())).thenReturn(Optional.of(teacher));
|
||||
when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(false);
|
||||
when(absenceRepository.saveAndFlush(any(TeacherAbsence.class))).thenAnswer(invocation -> {
|
||||
TeacherAbsence absence = invocation.getArgument(0);
|
||||
ReflectionTestUtils.setField(absence, "id", 101L);
|
||||
return absence;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherCreatesPendingRequestOnlyForSelf() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L));
|
||||
|
||||
TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest(
|
||||
null,
|
||||
START,
|
||||
END,
|
||||
" Болезнь "
|
||||
));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PENDING");
|
||||
assertThat(result.teacherId()).isEqualTo(11L);
|
||||
assertThat(result.reason()).isEqualTo("Болезнь");
|
||||
assertThat(result.requestedBy()).isEqualTo(11L);
|
||||
verify(userRepository).findByIdForUpdate(11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void responsibleEmployeeCreatesApprovedAbsence() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
|
||||
TeacherAbsenceDto result = service.create(new TeacherAbsenceRequest(
|
||||
11L,
|
||||
START,
|
||||
END,
|
||||
"Командировка"
|
||||
));
|
||||
|
||||
assertThat(result.status()).isEqualTo("APPROVED");
|
||||
assertThat(result.reviewedBy()).isEqualTo(1L);
|
||||
assertThat(result.reviewedAt()).isEqualTo(NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOverlappingActiveAbsence() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
when(absenceRepository.existsOverlapping(any(), anyCollection(), any(), any())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(new TeacherAbsenceRequest(
|
||||
11L,
|
||||
START,
|
||||
END,
|
||||
"Командировка"
|
||||
)))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessageContaining("пересекающееся");
|
||||
}
|
||||
|
||||
@Test
|
||||
void teacherCannotResolveReplacementWizard() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(11L, "teacher", Role.TEACHER, 7L));
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(
|
||||
101L,
|
||||
new TeacherAbsenceResolutionRequest(List.of())
|
||||
))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("не может применять решения");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absenceWithoutAffectedLessonsCanBeCompleted() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
TeacherAbsence absence = absence("APPROVED");
|
||||
when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence));
|
||||
when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence));
|
||||
when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of());
|
||||
when(scheduleQueryService.search(
|
||||
any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(List.of());
|
||||
|
||||
var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of()));
|
||||
|
||||
assertThat(result.absence().status()).isEqualTo("RESOLVED");
|
||||
verify(absenceRepository).saveAndFlush(absence);
|
||||
}
|
||||
|
||||
@Test
|
||||
void previousRejectedDecisionCountsWhenRemainingLessonsAreResolved() {
|
||||
AuthContext.setCurrentUser(new AuthenticatedUser(1L, "admin", Role.ADMIN, null));
|
||||
TeacherAbsence absence = absence("APPROVED");
|
||||
ScheduleRuleSlot rejectedSlot = new ScheduleRuleSlot();
|
||||
rejectedSlot.setId(201L);
|
||||
TeacherAbsenceDecision rejected = new TeacherAbsenceDecision();
|
||||
rejected.setBaseRuleSlot(rejectedSlot);
|
||||
rejected.setLessonDate(START);
|
||||
rejected.setResolution("REJECT");
|
||||
rejected.setDecisionStatus("REJECTED");
|
||||
rejected.setDecidedAt(NOW);
|
||||
ScheduleRuleSlot remainingSlot = new ScheduleRuleSlot();
|
||||
remainingSlot.setId(202L);
|
||||
|
||||
when(absenceRepository.findByIdForUpdate(101L)).thenReturn(Optional.of(absence));
|
||||
when(absenceRepository.findByIdWithTeacher(101L)).thenReturn(Optional.of(absence));
|
||||
when(decisionRepository.findByAbsenceIdWithDetails(101L)).thenReturn(List.of(rejected));
|
||||
when(scheduleRuleSlotRepository.findById(202L)).thenReturn(Optional.of(remainingSlot));
|
||||
when(scheduleQueryService.search(
|
||||
any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
|
||||
)).thenReturn(List.of(lesson(201L, START), lesson(202L, END)));
|
||||
|
||||
var result = service.resolve(101L, new TeacherAbsenceResolutionRequest(List.of(
|
||||
new TeacherAbsenceResolutionRequest.Decision(
|
||||
202L,
|
||||
END,
|
||||
"REJECT",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"Решение будет принято вручную"
|
||||
)
|
||||
)));
|
||||
|
||||
assertThat(result.absence().status()).isEqualTo("RESOLVED");
|
||||
verify(decisionRepository).saveAndFlush(any(TeacherAbsenceDecision.class));
|
||||
}
|
||||
|
||||
private RenderedLessonDto lesson(Long slotId, LocalDate date) {
|
||||
return new RenderedLessonDto(
|
||||
301L,
|
||||
slotId,
|
||||
date,
|
||||
date.getDayOfWeek().getValue(),
|
||||
"День недели",
|
||||
2,
|
||||
ScheduleParity.BOTH,
|
||||
401L,
|
||||
1,
|
||||
LocalTime.of(9, 0),
|
||||
LocalTime.of(10, 30),
|
||||
501L,
|
||||
"Математика",
|
||||
teacher.getId(),
|
||||
teacher.getFullName(),
|
||||
601L,
|
||||
"101",
|
||||
701L,
|
||||
"Лекция",
|
||||
"Очно",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(801L),
|
||||
List.of("ИВТ-101"),
|
||||
"EDUCATION",
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
ScheduleParity.BOTH
|
||||
);
|
||||
}
|
||||
|
||||
private TeacherAbsence absence(String status) {
|
||||
TeacherAbsence absence = new TeacherAbsence();
|
||||
ReflectionTestUtils.setField(absence, "id", 101L);
|
||||
absence.setTeacher(teacher);
|
||||
absence.setStartDate(START);
|
||||
absence.setEndDate(END);
|
||||
absence.setReason("Болезнь");
|
||||
absence.setStatus(status);
|
||||
absence.setRequestedBy(1L);
|
||||
absence.setCreatedAt(NOW);
|
||||
absence.setUpdatedAt(NOW);
|
||||
return absence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
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.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TeacherAvailabilityPolicyServiceTest {
|
||||
|
||||
@Mock
|
||||
private TeacherPreferenceRepository preferenceRepository;
|
||||
@Mock
|
||||
private TimeSlotRepository timeSlotRepository;
|
||||
|
||||
private TeacherAvailabilityPolicyService service;
|
||||
private TimeSlot candidateSlot;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new TeacherAvailabilityPolicyService(preferenceRepository, timeSlotRepository);
|
||||
candidateSlot = new TimeSlot();
|
||||
candidateSlot.setId(3L);
|
||||
candidateSlot.setOrderNumber(2);
|
||||
when(timeSlotRepository.findById(3L)).thenReturn(Optional.of(candidateSlot));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedHardDateBlocksCandidate() {
|
||||
TeacherPreference preference = preference("HARD_UNAVAILABLE");
|
||||
preference.setPreferenceDate(LocalDate.of(2026, 9, 14));
|
||||
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
|
||||
.thenReturn(List.of(preference));
|
||||
|
||||
assertThatThrownBy(() -> service.assertLessonAvailable(
|
||||
11L, 5L, LocalDate.of(2026, 9, 14), 3L
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessageContaining("полную недоступность");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weeklyHardIntervalMatchesSlotOrderAcrossTimeGrids() {
|
||||
TeacherPreference preference = preference("HARD_UNAVAILABLE");
|
||||
preference.setDayOfWeek(1);
|
||||
TimeSlot baseSlot = new TimeSlot();
|
||||
baseSlot.setId(8L);
|
||||
baseSlot.setOrderNumber(2);
|
||||
preference.setTimeSlot(baseSlot);
|
||||
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
|
||||
.thenReturn(List.of(preference));
|
||||
|
||||
assertThatThrownBy(() -> service.assertLessonAvailable(
|
||||
11L, 5L, LocalDate.of(2026, 9, 14), 3L
|
||||
))
|
||||
.isInstanceOf(ScheduleConflictException.class)
|
||||
.hasMessageContaining("день и время");
|
||||
}
|
||||
|
||||
@Test
|
||||
void softPreferenceDoesNotBlockCandidate() {
|
||||
TeacherPreference preference = preference("SOFT_UNWANTED");
|
||||
preference.setDayOfWeek(1);
|
||||
preference.setTimeSlot(candidateSlot);
|
||||
when(preferenceRepository.findApprovedForTeacherAndSemester(11L, 5L))
|
||||
.thenReturn(List.of(preference));
|
||||
|
||||
assertThatCode(() -> service.assertLessonAvailable(
|
||||
11L, 5L, LocalDate.of(2026, 9, 14), 3L
|
||||
)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private TeacherPreference preference(String type) {
|
||||
TeacherPreference preference = new TeacherPreference();
|
||||
preference.setPreferenceType(type);
|
||||
preference.setStatus("APPROVED");
|
||||
return preference;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
retries: 5
|
||||
networks:
|
||||
- magistr
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Назначение
|
||||
|
||||
Календарный учебный график хранит правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. Динамическое расписание использует этот график как фильтр: обычные пары выводятся только в дни теоретического обучения.
|
||||
Календарный учебный график представляет правила учебного года в той же логике, что и исходные Excel-таблицы: для каждого курса и каждой даты указывается код активности. В базе последовательные даты с одинаковой активностью хранятся одним периодом. Динамическое расписание использует график как фильтр: обычные пары выводятся только в дни теоретического обучения.
|
||||
|
||||
График нужен, чтобы разные образовательные программы могли иметь разные периоды теории, экзаменов, практики, каникул и нерабочих дней. Несколько групп могут использовать один и тот же график, если они учатся по одной календарной модели.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей.
|
||||
- `academic_calendars` хранит график профиля, формы обучения и учебного года.
|
||||
- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет.
|
||||
- `academic_calendar_days` хранит дневную сетку графика по курсу и дате.
|
||||
- `academic_calendar_periods` хранит непересекающиеся периоды активности по курсу с включительными границами дат.
|
||||
- `academic_calendar_subjects` хранит дисциплины графика по номерам учебных семестров; дисциплины выбираются из справочника `subjects`.
|
||||
- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения.
|
||||
- `student_group_calendar_assignments` назначает группе конкретный график на учебный год.
|
||||
@@ -52,7 +52,7 @@
|
||||
1. Определяется учебный год и семестр даты.
|
||||
2. Для группы находится назначение графика на этот учебный год.
|
||||
3. По году начала обучения группы вычисляется текущий курс.
|
||||
4. В `academic_calendar_days` ищется ячейка для курса и даты.
|
||||
4. В `academic_calendar_periods` ищется период курса, содержащий дату.
|
||||
5. Если код ячейки разрешает обычные пары, применяются правила `schedule_rules`.
|
||||
6. Если код не разрешает пары, день пропускается и академические часы не списываются.
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
|
||||
- Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке.
|
||||
- Дни вне учебного года или пустые периоды Excel хранятся кодом `=`.
|
||||
- Дневной REST-контракт сохранён: при чтении backend разворачивает периоды в ячейки, а при полной замене объединяет соседние даты с одинаковой активностью в одну строку `academic_calendar_periods`.
|
||||
- Отсутствие периода для даты означает отсутствие разрешённой активности; периоды одного курса и графика не могут пересекаться.
|
||||
- График привязан к профилю обучения, а не только к коду специальности.
|
||||
- Форма обучения графика должна совпадать с формой обучения группы при назначении графика.
|
||||
- Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры.
|
||||
|
||||
291
docs/API.md
291
docs/API.md
@@ -529,6 +529,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
Повторное название года, повторный тип семестра или пересечение возвращаются как
|
||||
`409 Conflict`; неверный порядок дат и выход семестра за границы года — как `400 Bad Request`.
|
||||
При сужении учебного года все существующие семестры должны оставаться внутри новых границ.
|
||||
Создание семестра также создаёт для него пустую версию 1 со статусом `PUBLISHED` и названием
|
||||
`Основное расписание`, поэтому конструктор сразу может работать в обычном режиме.
|
||||
|
||||
**Тело создания/обновления кода активности:**
|
||||
```json
|
||||
@@ -551,8 +553,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
| `POST` | `/api/admin/academic-calendars` | Создать график |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график |
|
||||
| `DELETE` | `/api/admin/academic-calendars/{id}` | Удалить график |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/grid` | Дневная сетка графика, развёрнутая из периодов |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/grid` | Полное сохранение дневной сетки с объединением периодов |
|
||||
| `GET` | `/api/admin/academic-calendars/{id}/subjects` | Дисциплины графика по номерам учебных семестров |
|
||||
| `PUT` | `/api/admin/academic-calendars/{id}/subjects` | Полная замена привязок дисциплин графика |
|
||||
|
||||
@@ -574,7 +576,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
Отдельного пользовательского названия у календарного графика нет.
|
||||
|
||||
При `PUT /api/admin/academic-calendars/{id}` backend блокирует график и повторно проверяет
|
||||
все сохранённые назначения, дневную сетку и дисциплины. Изменение года, специальности,
|
||||
все сохранённые назначения, периоды активности и дисциплины. Изменение года, специальности,
|
||||
профиля, формы или количества курсов, которое сделает данные несовместимыми, возвращает
|
||||
`409 Conflict` с русским сообщением и не изменяет график. В частности, нельзя уменьшить
|
||||
`courseCount`, пока существуют строки старших курсов или дисциплины семестров выше
|
||||
@@ -599,8 +601,12 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
`понедельник–воскресенье`. Неделя 1 содержит дату начала учебного года; дни этой недели
|
||||
до начала года не входят в JSON и отображаются пустыми ячейками. Ключ
|
||||
`(courseNumber, date)` не должен повторяться, каждый
|
||||
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
|
||||
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||||
`activityTypeId` или `activityCode` должен существовать. После проверки соседние даты
|
||||
одного курса с одинаковой активностью объединяются в один период с включительными
|
||||
границами. Разрыв хотя бы в один день или смена активности начинает новый период.
|
||||
При `GET` сохранённые периоды разворачиваются обратно в тот же дневной JSON-контракт,
|
||||
а `weekNumber` и `dayOfWeek` рассчитываются по дате. При любом `400` старая сетка остаётся
|
||||
без изменений; `calendarId` из строки не переопределяет ID в URL.
|
||||
|
||||
**Привязка дисциплин к графику:**
|
||||
```json
|
||||
@@ -619,6 +625,7 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduleVersionId": 7,
|
||||
"subjectId": 1,
|
||||
"semesterId": 1,
|
||||
"lectureAcademicHours": 32,
|
||||
@@ -648,7 +655,7 @@ CRUD доступен по:
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId` |
|
||||
| `GET` | `/api/admin/schedule-rules` | Список правил, фильтры `semesterId`, `groupId`, `versionId` |
|
||||
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
|
||||
| `POST` | `/api/admin/schedule-rules` | Создать правило |
|
||||
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
|
||||
@@ -656,6 +663,14 @@ CRUD доступен по:
|
||||
|
||||
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
|
||||
|
||||
Создание, изменение и архивирование правил разрешены внутри версии со статусом `PUBLISHED`
|
||||
или `DRAFT`; `scheduleVersionId` обязателен в payload. Версия должна относиться к указанному
|
||||
семестру. Изменения `PUBLISHED`-версии сразу влияют на публичное расписание, изменения
|
||||
`DRAFT` остаются изолированными до публикации, а `ARCHIVED` доступна только для чтения.
|
||||
Без `versionId` список возвращает только правила текущей опубликованной версии. При update
|
||||
ID существующих слотов сохраняются; удаление слота, на который ссылаются точечные изменения,
|
||||
заявки преподавателей или решения по отсутствиям, отклоняется явной ошибкой `400`.
|
||||
|
||||
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
|
||||
|
||||
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит
|
||||
@@ -837,6 +852,270 @@ API возвращает `409 Conflict`; соседние интервалы и
|
||||
}
|
||||
```
|
||||
|
||||
### Версии и публикация расписания
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/edu-office/schedule/versions?semesterId=` | Версии семестра с авторами, датами и количеством правил |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}` | Одна версия |
|
||||
| `POST` | `/api/edu-office/schedule/versions` | Создать пустой черновик или копию выбранной версии |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}/validate` | Полная проверка правил черновика |
|
||||
| `GET` | `/api/edu-office/schedule/versions/{id}/diff?baseVersionId=` | Сравнение правил и сформированных занятий |
|
||||
| `POST` | `/api/edu-office/schedule/versions/{id}/publish` | Атомарно опубликовать проверенный черновик |
|
||||
| `POST` | `/api/edu-office/schedule/versions/{id}/restore` | Восстановить ранее опубликованную архивную версию |
|
||||
| `GET` | `/api/edu-office/schedule/versions/history?semesterId=` | Хронология создания, публикации, архивации и восстановления |
|
||||
|
||||
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. Для семестра допускается ровно одна
|
||||
версия со статусом `PUBLISHED`; остальные имеют статус `DRAFT` или `ARCHIVED`. Создание
|
||||
копии может использовать опубликованную версию или другой черновик и переносит правила,
|
||||
группы, слоты и подгруппы, сохраняя `versionGroupId` для diff.
|
||||
|
||||
```json
|
||||
{
|
||||
"semesterId": 3,
|
||||
"name": "Расписание после распределения аудиторий",
|
||||
"basedOnVersionId": 11
|
||||
}
|
||||
```
|
||||
|
||||
Публикация и восстановление принимают обязательную причину:
|
||||
|
||||
```json
|
||||
{ "reason": "Согласовано учебным отделом 11.08.2026" }
|
||||
```
|
||||
|
||||
Перед публикацией backend блокирует версию, повторно проверяет весь набор активных правил,
|
||||
архивирует текущую публикацию и переводит черновик в `PUBLISHED` одной транзакцией. При
|
||||
ошибке ни один статус не меняется. Обычные endpoints просмотра и генерации выбирают только
|
||||
опубликованную версию; overrides архивных версий не попадают в актуальное расписание и
|
||||
операционный реестр.
|
||||
|
||||
### Анализ качества расписания
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/edu-office/schedule/quality?semesterId=&versionId=` | Итоговая оценка, метрики и объяснимый список проблемных правил выбранной версии |
|
||||
| `GET` | `/api/edu-office/schedule/quality/recommendations?semesterId=&versionId=&scheduleRuleSlotId=&lessonDate=` | Проверенные локальные варианты улучшения опубликованного занятия |
|
||||
|
||||
Доступ имеют только `ADMIN` и `EDUCATION_OFFICE`. `versionId` необязателен: без него
|
||||
анализируется текущая публикация. Для опубликованной версии анализатор рассматривает
|
||||
фактическое расписание всего семестра, включая уже применённые `schedule_overrides`, и учитывает окна
|
||||
групп и преподавателей, перегруженные дни, вместимость и избыточный размер аудитории,
|
||||
согласованные пожелания и недоступность преподавателей, а также равномерность нагрузки.
|
||||
Длинный семестр загружается внутренними интервалами не более 120 дней, поэтому публичный
|
||||
запрос не требует диапазона дат.
|
||||
|
||||
Черновик и архивная версия генерируются напрямую по собственным правилам, без подмешивания
|
||||
override текущей публикации. Для них доступны оценка, метрики и проблемы, но не локальные
|
||||
рекомендации: исправления черновика выполняются в конструкторе до публикации, архив остаётся
|
||||
только для чтения.
|
||||
|
||||
```json
|
||||
{
|
||||
"semesterId": 3,
|
||||
"semesterLabel": "2026/2027 · осенний семестр",
|
||||
"scheduleVersionId": 12,
|
||||
"scheduleVersionNumber": 2,
|
||||
"scheduleVersionName": "Расписание после распределения аудиторий",
|
||||
"scheduleVersionStatus": "DRAFT",
|
||||
"score": 84,
|
||||
"rawPenalty": 17,
|
||||
"lessonCount": 36,
|
||||
"totalProblemCount": 1,
|
||||
"problemsTruncated": false,
|
||||
"metrics": [
|
||||
{
|
||||
"code": "GROUP_GAPS",
|
||||
"label": "Окна у групп",
|
||||
"value": 3,
|
||||
"unit": "окон",
|
||||
"penalty": 6,
|
||||
"explanation": "Промежутки между занятиями одной группы"
|
||||
}
|
||||
],
|
||||
"problems": [
|
||||
{
|
||||
"id": "RULE:24",
|
||||
"type": "GROUP_GAP",
|
||||
"severity": "MEDIUM",
|
||||
"title": "Правило «Архитектура систем» снижает качество",
|
||||
"description": "3 занятия этого правила снижают оценку: Окно в расписании группы; Избыточно крупная аудитория",
|
||||
"penalty": 8,
|
||||
"scheduleRuleId": 24,
|
||||
"affectedLessonCount": 3,
|
||||
"problemTypes": ["GROUP_GAP", "ROOM_OVERSIZED"],
|
||||
"issueLabels": ["Окно в расписании группы", "Избыточно крупная аудитория"],
|
||||
"scheduleRuleSlotId": 31,
|
||||
"lessonDate": "2026-09-14",
|
||||
"optimizable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`score` находится в диапазоне от 0 до 100 и нормализует сумму объяснимых штрафов
|
||||
относительно числа занятий. Нарушения сначала рассчитываются для каждого фактического
|
||||
занятия, затем объединяются по `scheduleRuleId`: `penalty` хранит сумму всех штрафов правила,
|
||||
`affectedLessonCount` — число затронутых занятий, `problemTypes` и `issueLabels` — все его
|
||||
категории и объяснения. Поле `totalProblemCount` содержит число проблемных правил, а массив
|
||||
`problems` ограничен 300 элементами; `problemsTruncated` сообщает об усечении.
|
||||
|
||||
Рекомендации подбирают другой эффективный временной слот того же учебного дня либо
|
||||
подходящую активную аудиторию. Занятия, уже закреплённые ручным override, исключаются.
|
||||
Каждый кандидат до выдачи проходит `ScheduleOverrideService`, содержит готовый payload
|
||||
`override`, ожидаемые `scoreDelta`, `penaltyDelta`, улучшения и компромиссы. API ничего не
|
||||
изменяет автоматически: после подтверждения пользователь применяет payload обычным
|
||||
`POST /api/edu-office/schedule/overrides`.
|
||||
|
||||
### Отсутствия преподавателей и мастер замены
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/teacher-absences?status=` | Доступный текущей роли реестр отсутствий |
|
||||
| `POST` | `/api/teacher-absences` | Зарегистрировать отсутствие или отправить преподавательскую заявку |
|
||||
| `POST` | `/api/teacher-absences/{id}/review` | Подтвердить или отклонить заявку преподавателя |
|
||||
| `DELETE` | `/api/teacher-absences/{id}` | Отменить ожидающее или ещё не обработанное отсутствие |
|
||||
| `GET` | `/api/teacher-absences/{id}/wizard` | Затронутые занятия, проверенные кандидаты и журнал решений |
|
||||
| `POST` | `/api/teacher-absences/{id}/resolve` | Атомарно применить выбранные решения |
|
||||
|
||||
Доступ имеют `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT` и `TEACHER`. Преподаватель видит и
|
||||
создаёт только собственные заявки; они получают статус `PENDING`. Кафедра работает только с
|
||||
преподавателями, назначенными ей на период отсутствия. Ответственный сотрудник создаёт
|
||||
сразу согласованную запись `APPROVED`, а кафедра, учебный отдел или администратор могут
|
||||
перевести преподавательскую заявку в `APPROVED` либо `REJECTED`.
|
||||
|
||||
```json
|
||||
{
|
||||
"teacherId": 15,
|
||||
"startDate": "2026-09-10",
|
||||
"endDate": "2026-09-12",
|
||||
"reason": "Командировка"
|
||||
}
|
||||
```
|
||||
|
||||
Для роли `TEACHER` поле `teacherId` игнорируется в пользу ID текущего пользователя. Период
|
||||
включительный, не длиннее 120 дней и не может пересекаться с другой активной записью того
|
||||
же преподавателя.
|
||||
|
||||
`GET /{id}/wizard` ищет фактические занятия через `ScheduleQueryService`. Кандидат на замену
|
||||
должен иметь связь с дисциплиной; если для этой связи настроены `teacher_lesson_types`, тип
|
||||
занятия также обязан совпасть. Из списка исключаются архивные, занятые и отсутствующие
|
||||
преподаватели. Варианты переноса выбираются среди ближайших учебных дат вне периода
|
||||
отсутствия, аудитории — среди активных и свободных. Каждый кандидат заранее проходит тот же
|
||||
валидатор `ScheduleOverrideService`, что и обычная разовая правка.
|
||||
|
||||
Пример группового подтверждения:
|
||||
|
||||
```json
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"baseRuleSlotId": 31,
|
||||
"lessonDate": "2026-09-10",
|
||||
"resolution": "REPLACE_TEACHER",
|
||||
"newTeacherId": 28,
|
||||
"comment": "Согласовано кафедрой"
|
||||
},
|
||||
{
|
||||
"baseRuleSlotId": 44,
|
||||
"lessonDate": "2026-09-11",
|
||||
"resolution": "MOVE_TIME",
|
||||
"targetLessonDate": "2026-09-14",
|
||||
"newTimeSlotId": 3
|
||||
},
|
||||
{
|
||||
"baseRuleSlotId": 52,
|
||||
"lessonDate": "2026-09-12",
|
||||
"resolution": "REJECT"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Допустимые решения: `REPLACE_TEACHER`, `MOVE_TIME`, `CHANGE_CLASSROOM`, `CANCEL`, `REJECT`.
|
||||
Первые четыре создают обычный `schedule_override`; `REJECT` фиксирует осознанное отклонение
|
||||
без изменения расписания. Весь пакет выполняется в одной транзакции: конфликт любого
|
||||
выбранного действия возвращает `409` и откатывает остальные. Незаполненные занятия не
|
||||
меняются. Когда обработаны все оставшиеся занятия, отсутствие получает статус `RESOLVED`.
|
||||
|
||||
### Пожелания преподавателей на семестр
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/teacher-preferences/meta` | Семестры и базовая сетка времени для календаря доступности |
|
||||
| `GET` | `/api/teacher-preferences?semesterId=&status=` | Доступные текущей роли пожелания с фильтрами по семестру и статусу |
|
||||
| `POST` | `/api/teacher-preferences` | Создать ограничение или мягкое пожелание |
|
||||
| `POST` | `/api/teacher-preferences/{id}/review` | Согласовать или отклонить ожидающее пожелание |
|
||||
| `DELETE` | `/api/teacher-preferences/{id}` | Отозвать ожидающее либо отменить действующее пожелание |
|
||||
|
||||
Типы пожеланий: `HARD_UNAVAILABLE` — строгая недоступность по паре недели или целой дате,
|
||||
`SOFT_PREFERRED` — предпочтительный интервал, `SOFT_UNWANTED` — нежелательный интервал,
|
||||
`CONSECUTIVE` — пары подряд, `NO_GAPS` — расписание без окон. Интервальные пожелания
|
||||
используют `dayOfWeek` от 1 до 7 и `timeSlotId` базовой сетки; полная недоступность
|
||||
использует только `preferenceDate` внутри выбранного семестра.
|
||||
|
||||
```json
|
||||
{
|
||||
"semesterId": 4,
|
||||
"preferenceType": "HARD_UNAVAILABLE",
|
||||
"dayOfWeek": 5,
|
||||
"timeSlotId": 3,
|
||||
"comment": "Методический день"
|
||||
}
|
||||
```
|
||||
|
||||
Преподаватель создаёт и видит только собственные пожелания; новая запись получает статус
|
||||
`PENDING`. `ADMIN` и `EDUCATION_OFFICE` работают со всеми преподавателями, а `DEPARTMENT` —
|
||||
только с преподавателями своей кафедры по истории назначений на период семестра. Запись,
|
||||
добавленная ответственным сотрудником, сразу получает статус `APPROVED`. При отклонении
|
||||
поле `comment` в запросе согласования обязательно:
|
||||
|
||||
```json
|
||||
{
|
||||
"approved": false,
|
||||
"comment": "Уточните день полной недоступности"
|
||||
}
|
||||
```
|
||||
|
||||
Согласованная строгая недоступность блокирует сохранение конфликтующего правила и разовой
|
||||
правки. Мягкие пожелания и требования компактности показываются в конструкторе правил как
|
||||
подсказки и не изменяют опубликованное расписание автоматически.
|
||||
|
||||
### Заявки преподавателей на изменение занятия
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|-------|-----|------------|
|
||||
| `GET` | `/api/teacher-change-requests?status=` | Доступный текущей роли журнал заявок и история решений |
|
||||
| `GET` | `/api/teacher-change-requests/candidates?baseRuleSlotId=&lessonDate=&targetDate=` | Предварительно проверенные даты, пары и аудитории |
|
||||
| `POST` | `/api/teacher-change-requests` | Отправить заявку по собственному занятию |
|
||||
| `POST` | `/api/teacher-change-requests/{id}/review` | Применить или отклонить заявку |
|
||||
| `DELETE` | `/api/teacher-change-requests/{id}` | Отозвать собственную ожидающую заявку |
|
||||
|
||||
Преподаватель может выбрать только фактически существующее собственное занятие без уже
|
||||
созданного override. Поддерживаются `MOVE`, `CHANGE_CLASSROOM` и `CANCEL`; обоснование
|
||||
обязательно. Для `MOVE` передаются новая пара и, при необходимости, новая дата, для
|
||||
`CHANGE_CLASSROOM` — только новая аудитория. Кандидаты заранее проходят тот же валидатор
|
||||
ресурсных конфликтов, календаря, эффективной сетки времени, отсутствий и строгой
|
||||
недоступности, который используется при применении разовой правки.
|
||||
|
||||
```json
|
||||
{
|
||||
"baseRuleSlotId": 31,
|
||||
"lessonDate": "2026-09-10",
|
||||
"requestType": "MOVE",
|
||||
"targetLessonDate": "2026-09-11",
|
||||
"requestedTimeSlotId": 4,
|
||||
"reason": "Участие в конференции"
|
||||
}
|
||||
```
|
||||
|
||||
`ADMIN` и `EDUCATION_OFFICE` могут принять заявку; backend повторно валидирует её и
|
||||
транзакционно создаёт обычный `schedule_override`, записывает `appliedOverrideId` и событие
|
||||
`APPROVED` в историю. Кафедра видит заявки своих преподавателей, но не применяет изменения
|
||||
расписания. При отклонении комментарий обязателен. Статусы: `PENDING`, `APPROVED`,
|
||||
`REJECTED`, `CANCELLED`; по одному занятию одновременно допускается только одна ожидающая
|
||||
заявка.
|
||||
|
||||
## Загруженность
|
||||
|
||||
| Метод | URL | Назначение |
|
||||
|
||||
@@ -317,10 +317,12 @@ pod не сохраняют два конфликтующих правила п
|
||||
|
||||
`AcademicCalendarController` делегирует полную замену дневной сетки
|
||||
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
|
||||
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются
|
||||
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает
|
||||
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется
|
||||
только после успешного commit.
|
||||
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем соседние
|
||||
даты одного курса с одинаковой активностью сжимаются в периоды и выполняются
|
||||
`delete → flush → saveAll → flush`. При чтении контроллер разворачивает интервалы обратно
|
||||
в дневной REST-контракт и вычисляет номер недели и ISO-день. Исключение не перехватывается
|
||||
внутри сервиса и вызывает rollback. Инвалидация кэша зарегистрирована через transaction
|
||||
synchronization и выполняется только после успешного commit.
|
||||
|
||||
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
|
||||
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
|
||||
@@ -339,7 +341,8 @@ lost update или проверке устаревшего снимка.
|
||||
`endDate - startDate + 1` одинаково в query- и generator-слоях.
|
||||
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
||||
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
|
||||
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
|
||||
назначений календарей и пересекающиеся периоды активности от начала затронутого семестра
|
||||
до конца диапазона.
|
||||
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
|
||||
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
|
||||
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.
|
||||
|
||||
@@ -143,7 +143,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
| `specialty_profiles` | Профили обучения внутри специальности |
|
||||
| `academic_calendars` | Календарный учебный график профиля, формы обучения и учебного года |
|
||||
| `academic_calendar_activity_types` | Коды Excel-графика: `Т`, `Э`, `К`, `У`, `П`, `Пд`, `Н`, `Г`, `Д`, `ПА`, `С`, `*`, `=` |
|
||||
| `academic_calendar_days` | Дневная сетка по курсу и дате |
|
||||
| `academic_calendar_periods` | Непересекающиеся непрерывные периоды активности по курсу |
|
||||
| `academic_calendar_subjects` | Дисциплины графика по номерам учебных семестров |
|
||||
| `student_group_calendar_assignments` | Назначение конкретного графика группе на учебный год |
|
||||
| `time_slots` | Настраиваемая сетка пар для тенанта |
|
||||
@@ -165,12 +165,15 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
глобальные конкурентные гонки между разными backend-pod. Благодаря отсутствию пересечений
|
||||
поиск семестра для даты возвращает не более одного результата и не зависит от порядка строк.
|
||||
|
||||
Полная замена `academic_calendar_days` выполняется через транзакционный
|
||||
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
|
||||
Полная замена дневной сетки выполняется через транзакционный
|
||||
`AcademicCalendarGridService`. Сервис сначала проверяет весь дневной payload, включая
|
||||
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
|
||||
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
|
||||
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
|
||||
расписания очищается только после commit. Недели дневной сетки выровнены по ISO-неделе
|
||||
и разрешает все коды активностей. Затем соседние даты одного курса с одинаковой активностью
|
||||
объединяются в один включительный период `academic_calendar_periods`. Только после этого
|
||||
прежние периоды удаляются и новый набор записывается одной транзакцией. Ошибка любой строки
|
||||
сохраняет прежнюю сетку целиком, а кэш расписания очищается только после commit. База
|
||||
дополнительно запрещает пересекающиеся периоды одного курса и графика через exclusion
|
||||
constraint. При чтении API разворачивает периоды обратно в дневные ячейки. Недели дневной сетки выровнены по ISO-неделе
|
||||
`понедельник–воскресенье`: неделя 1 содержит первый день учебного года, а позиции до него
|
||||
остаются пустыми. Поэтому при старте года во вторник следующий понедельник относится уже
|
||||
к неделе 2.
|
||||
@@ -189,10 +192,10 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
11. Применяет точечные изменения из `schedule_overrides` в расширенном поиске и отчётах.
|
||||
|
||||
Перед обходом дат генератор создаёт снимок на один запрос. Семестры диапазона, назначения
|
||||
календарей всех выбранных групп, дневная сетка календарей, правила и эффективные сетки
|
||||
звонков загружаются batch-запросами и индексируются по дате и идентификаторам. Поиск
|
||||
семестра, проверка разрешённого дня и подстановка времени внутри циклов выполняются только
|
||||
по lookup-картам. `ScheduleQueryService` передаёт все группы в один
|
||||
календарей всех выбранных групп, пересекающиеся периоды активности, правила и эффективные
|
||||
сетки звонков загружаются batch-запросами. Периоды группируются по календарю и курсу, а
|
||||
поиск периода для даты выполняется бинарным поиском. Поиск семестра и подстановка времени
|
||||
внутри циклов выполняются только по снимку запроса. `ScheduleQueryService` передаёт все группы в один
|
||||
`buildScheduleForGroups()`, поэтому число запросов не растёт как
|
||||
`дни × группы × правила`; singleton-кэш и общее между запросами состояние не используются.
|
||||
|
||||
@@ -262,8 +265,36 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
|
||||
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
|
||||
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
|
||||
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
|
||||
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание сначала блокирует строку семестра, а update блокирует правило и старый/новый семестры в стабильном порядке, поэтому два backend-pod не могут одновременно пройти проверку одного семестра по устаревшему снимку.
|
||||
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила той же версии. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
|
||||
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание блокирует строку семестра и выбранную версию, а update — правило, версию и старый/новый семестры в стабильном порядке. Публикация блокирует версию до завершения полной проверки, поэтому другой backend-pod не может дописать правило после валидации черновика.
|
||||
- **Связанные слоты:** update сохраняет ID переданных существующих слотов, чтобы не разрывать точечные изменения и операционный аудит. Удаление слота отклоняется, если на него ссылаются override, заявка преподавателя или решение по отсутствию.
|
||||
|
||||
### Черновики, версии и публикация
|
||||
|
||||
Правила каждого семестра принадлежат явной версии расписания. Жизненный цикл версии:
|
||||
|
||||
1. При создании семестра автоматически появляется пустая версия 1 `PUBLISHED` — основное расписание.
|
||||
2. Конструктор по умолчанию открывает опубликованное расписание: изменения его правил сразу
|
||||
видны конечным пользователям. Пользователь может переключиться на существующий `DRAFT`.
|
||||
3. `DRAFT` создаётся пустым через API или прямо в конструкторе как полная копия выбранной
|
||||
опубликованной либо черновой версии; после создания он сразу становится текущим.
|
||||
4. Полная проверка выявляет внутренние конфликты правил; diff сопоставляет правила по
|
||||
стабильному `version_group_id` и отдельно сравнивает сформированные занятия семестра.
|
||||
5. Публикация требует причины и в одной транзакции архивирует прежнюю публикацию, затем
|
||||
переводит проверенный черновик в `PUBLISHED`.
|
||||
6. Ранее опубликованная версия получает `ARCHIVED` и может быть восстановлена такой же
|
||||
атомарной операцией с обязательной причиной.
|
||||
|
||||
На уровне БД частичный уникальный индекс допускает только одну `PUBLISHED`-версию на
|
||||
семестр. Блокировки версии и набора версий семестра не позволяют публикации пересечься с
|
||||
редактированием расписания или конкурентной публикацией. Каждое создание, архивирование,
|
||||
публикация и восстановление записывается в неизменяемый журнал с автором, временем и
|
||||
причиной.
|
||||
|
||||
Обычная генерация для студентов, преподавателей и кабинетов просмотра всегда выбирает
|
||||
только опубликованные правила. Точечные изменения привязаны к слотам конкретной версии:
|
||||
после новой публикации overrides прежней версии сохраняются как аудит, но не влияют на
|
||||
актуальное расписание и не показываются в его операционном реестре.
|
||||
|
||||
### Точечные изменения расписания
|
||||
|
||||
@@ -309,8 +340,118 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
для backend-pod, поэтому два конкурентных переноса с разных исходных дат на одну целевую
|
||||
дату и одинаковые ресурсы проверяются последовательно.
|
||||
|
||||
### Отсутствия преподавателей и мастер замены
|
||||
|
||||
Отсутствие хранится отдельно от базовых правил расписания и проходит жизненный цикл
|
||||
`PENDING → APPROVED → RESOLVED`; отклонённые и отменённые записи получают соответственно
|
||||
`REJECTED` и `CANCELLED`. Преподаватель создаёт заявку только для себя со статусом
|
||||
`PENDING`. Администратор, учебный отдел и кафедра могут регистрировать согласованное
|
||||
отсутствие сразу. Кафедра видит и подтверждает только преподавателей, назначенных ей на
|
||||
выбранный период через `teacher_department_assignments`.
|
||||
|
||||
Для согласованного отсутствия `ScheduleQueryService` строит фактические занятия
|
||||
преподавателя в пределах периода. Мастер предлагает не более пяти проверенных вариантов
|
||||
каждого типа:
|
||||
|
||||
- преподавателей со связью по дисциплине и допустимому типу занятия;
|
||||
- ближайшие учебные даты вне периода отсутствия и эффективные временные слоты;
|
||||
- активные свободные аудитории;
|
||||
- отмену занятия или явное отклонение предложений.
|
||||
|
||||
Если у связи преподавателя с дисциплиной нет настроенных строк `teacher_lesson_types`, она
|
||||
считается разрешающей все типы; при наличии настроек требуется точное совпадение типа.
|
||||
Кандидаты-замены исключаются, если архивированы, заняты или сами отсутствуют в дату пары.
|
||||
Каждый вариант до показа проходит read-only проверку `ScheduleOverrideService`, а выбранный
|
||||
пакет повторно валидируется и сохраняется в одной транзакции. Поэтому время, аудитория,
|
||||
группы и подгруппы проверяются тем же механизмом, что и ручные точечные изменения.
|
||||
|
||||
Применённые действия создают обычные `schedule_overrides`; базовое правило семестра не
|
||||
изменяется. Таблица `teacher_absence_decisions` фиксирует как применённые, так и отклонённые
|
||||
решения. Незаполненные строки мастер не меняет, и к ним можно вернуться позже.
|
||||
|
||||
---
|
||||
|
||||
### Пожелания преподавателей на семестр
|
||||
|
||||
Преподаватель формирует набор пожеланий отдельно для каждого семестра. Интервальные записи
|
||||
привязаны к дню недели и паре базовой сетки времени, полная строгая недоступность — к
|
||||
конкретной дате семестра. Поддерживаются:
|
||||
|
||||
- `HARD_UNAVAILABLE` — строго запрещённый интервал либо полностью недоступная дата;
|
||||
- `SOFT_PREFERRED` и `SOFT_UNWANTED` — предпочтительный и нежелательный интервалы;
|
||||
- `CONSECUTIVE` и `NO_GAPS` — пожелания к компактности расписания.
|
||||
|
||||
Запись преподавателя сначала имеет статус `PENDING`. Кафедра может рассматривать только
|
||||
пожелания преподавателей, относившихся к ней в период семестра; учебный отдел и
|
||||
администратор работают со всеми записями. Ответственный сотрудник также может сразу создать
|
||||
согласованную запись. Отклонение требует комментария, а преподаватель может отозвать только
|
||||
собственную ожидающую запись.
|
||||
|
||||
Только согласованные строгие ограничения влияют на валидацию. `ScheduleRuleService`
|
||||
проверяет каждую активную неделю нового или изменённого правила, а `ScheduleOverrideService`
|
||||
— фактическую дату результата разовой правки. Поэтому строгая недоступность одинаково
|
||||
учитывается конструктором правил, мастером замены и заявками преподавателей. Мягкие
|
||||
пожелания и компактность подсвечиваются в конструкторе, но не меняют опубликованное
|
||||
расписание автоматически.
|
||||
|
||||
### Заявки преподавателей на изменение занятия
|
||||
|
||||
Заявку можно создать только по собственному фактическому занятию, для которого ещё нет
|
||||
разовой правки. Поддерживаются перенос даты/времени (`MOVE`), смена аудитории
|
||||
(`CHANGE_CLASSROOM`) и отмена (`CANCEL`). Одновременно по одной паре допускается только одна
|
||||
заявка `PENDING`.
|
||||
|
||||
До отправки интерфейс получает список ближайших учебных дат, временных слотов и аудиторий.
|
||||
Каждый вариант проходит `ScheduleOverrideService`: проверяются принадлежность семестру,
|
||||
календарный график групп, эффективная сетка времени, жизненный цикл ресурсов, пересечения
|
||||
преподавателя, аудитории, групп и подгрупп, подтверждённые отсутствия и строгая
|
||||
недоступность преподавателя. При создании заявки проверка выполняется повторно.
|
||||
|
||||
Кафедра видит заявки своих преподавателей, но применять изменение вправе только
|
||||
`ADMIN` или `EDUCATION_OFFICE`. При одобрении в одной транзакции повторно проверяется и
|
||||
создаётся обычный `schedule_override`, его ID сохраняется в заявке, а в неизменяемую
|
||||
историю добавляется статус `APPROVED`. Отклонение требует комментария; преподаватель может
|
||||
отозвать только собственную ожидающую заявку. История содержит автора, время, статус и
|
||||
комментарий каждого перехода.
|
||||
|
||||
### Анализ качества и локальная оптимизация расписания
|
||||
|
||||
Анализатор качества работает без собственной таблицы и не меняет правила расписания.
|
||||
Пользователь выбирает конкретную версию семестра. Для `PUBLISHED` он строит фактические
|
||||
занятия через `ScheduleQueryService`, поэтому в расчёт входят переносы, замены и отмены из
|
||||
`schedule_overrides`. `DRAFT` и `ARCHIVED` генерируются напрямую по собственным правилам,
|
||||
без точечных изменений текущей публикации. Семестр загружается частями не более 120 дней,
|
||||
но оценка рассчитывается единообразно по всему периоду.
|
||||
|
||||
Итоговая оценка от 0 до 100 формируется из объяснимых штрафов:
|
||||
|
||||
- окна групп и преподавателей между занятиями одного дня;
|
||||
- пятая и последующие пары группы или преподавателя за день;
|
||||
- нехватка мест и заметно избыточная вместимость аудитории;
|
||||
- занятие в подтверждённый строго недоступный или нежелательный интервал;
|
||||
- занятие вне предпочтительного интервала преподавателя на выбранный день недели;
|
||||
- нарушение пожеланий `NO_GAPS` и `CONSECUTIVE`.
|
||||
|
||||
Неравномерность дневной нагрузки выводится отдельной диагностической метрикой в процентах
|
||||
и не добавляет скрытого штрафа к итоговой оценке.
|
||||
|
||||
Внутренне штраф рассчитывается для каждого фактического занятия, поэтому повторяющиеся
|
||||
пары одного правила увеличивают его вклад в оценку. Перед выдачей нарушения объединяются
|
||||
по правилу: одна карточка содержит суммарный штраф, число затронутых занятий, все типы и
|
||||
краткие причины. Это сохраняет воспроизводимость оценки, но направляет пользователя к
|
||||
изменению базового правила вместо ручного исправления каждой пары.
|
||||
|
||||
Для проблемы опубликованной версии помощник перебирает другие слоты эффективной сетки того же дня и
|
||||
активные аудитории достаточной вместимости. Каждый вариант повторно проходит
|
||||
`ScheduleOverrideService`, затем анализатор моделирует его влияние на общую оценку и
|
||||
показывает улучшения и компромиссы. Занятия с ручным override считаются закреплёнными и не
|
||||
получают рекомендаций. Применение возможно только после подтверждения пользователя через
|
||||
обычный механизм `schedule_overrides`; автоматической публикации и полного solver в MVP
|
||||
нет. Интерфейс анализа для опубликованной версии и черновика открывает выбранное правило
|
||||
в конструкторе соответствующей версии; черновик остаётся изолированным до публикации.
|
||||
Архив анализируется только для чтения. API локальных рекомендаций сохраняется для
|
||||
совместимости и точечных сценариев, но реестр качества работает на уровне правил.
|
||||
|
||||
## Привязка преподаватель ↔ дисциплина
|
||||
|
||||
Связь Many-to-Many через таблицу `teacher_subjects`:
|
||||
@@ -332,6 +473,9 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
- **Вместимость:** Суммарная численность всех групп в слоте не должна превышать вместимость аудитории
|
||||
|
||||
### Управление инцидентами
|
||||
- Регистрация отсутствия преподавателя (болезнь, командировка) с указанием периода
|
||||
- Автоматическая подсветка конфликтующих пар (Red Zone)
|
||||
- Resolution Wizard: предложение замены преподавателя или переноса занятия
|
||||
|
||||
Регистрация отсутствий и Resolution Wizard реализованы. Дальнейшее развитие этого контура:
|
||||
|
||||
- автоматическая рассылка уведомлений группам и преподавателям;
|
||||
- пакетная обработка нескольких одновременных отсутствий;
|
||||
- ранжирование вариантов по окнам и дополнительной нагрузке.
|
||||
|
||||
305
docs/DATABASE.md
305
docs/DATABASE.md
@@ -5,7 +5,7 @@
|
||||
- **СУБД:** PostgreSQL (локально `postgres:16.3-alpine` с закреплённым digest, продакшн — managed PostgreSQL)
|
||||
- **Управление схемой:** Flyway (программный запуск)
|
||||
- **Hibernate DDL:** Отключён (`ddl-auto=none`)
|
||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraint временных слотов)
|
||||
- **Расширения:** `pgcrypto` (bcrypt-хеширование паролей), `btree_gist` (exclusion constraints временных слотов и календарных периодов)
|
||||
- **Мультитенантность:** Каждый тенант = отдельная БД
|
||||
- **Абсолютное время:** `TIMESTAMPTZ`, Hibernate читает и записывает значения как UTC `Instant`
|
||||
- **Календарные бизнес-даты:** `DATE`, интерпретируются в зоне `Europe/Moscow`
|
||||
@@ -262,13 +262,12 @@ erDiagram
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
academic_calendar_days {
|
||||
academic_calendar_periods {
|
||||
BIGSERIAL id PK
|
||||
BIGINT calendar_id FK
|
||||
INT course_number
|
||||
DATE date
|
||||
INT week_number
|
||||
INT day_of_week
|
||||
DATE start_date
|
||||
DATE end_date
|
||||
BIGINT activity_type_id FK
|
||||
}
|
||||
|
||||
@@ -287,10 +286,37 @@ erDiagram
|
||||
BIGINT calendar_id FK
|
||||
}
|
||||
|
||||
schedule_versions {
|
||||
BIGSERIAL id PK
|
||||
BIGINT semester_id FK
|
||||
INT version_number
|
||||
VARCHAR name
|
||||
VARCHAR status
|
||||
BIGINT based_on_version_id FK
|
||||
BIGINT restored_from_version_id FK
|
||||
TEXT change_reason
|
||||
BIGINT created_by FK
|
||||
TIMESTAMPTZ created_at
|
||||
BIGINT published_by FK
|
||||
TIMESTAMPTZ published_at
|
||||
TIMESTAMPTZ archived_at
|
||||
}
|
||||
|
||||
schedule_version_history {
|
||||
BIGSERIAL id PK
|
||||
BIGINT version_id FK
|
||||
VARCHAR action
|
||||
BIGINT actor_id FK
|
||||
TEXT reason
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
schedule_rules {
|
||||
BIGSERIAL id PK
|
||||
BIGINT subject_id FK
|
||||
BIGINT semester_id FK
|
||||
BIGINT schedule_version_id FK
|
||||
BIGINT version_group_id
|
||||
VARCHAR status
|
||||
DATE valid_from
|
||||
DATE valid_to
|
||||
@@ -335,6 +361,78 @@ erDiagram
|
||||
TEXT comment
|
||||
}
|
||||
|
||||
teacher_absences {
|
||||
BIGSERIAL id PK
|
||||
BIGINT teacher_id FK
|
||||
DATE start_date
|
||||
DATE end_date
|
||||
TEXT reason
|
||||
VARCHAR status
|
||||
BIGINT requested_by FK
|
||||
BIGINT reviewed_by FK
|
||||
TEXT review_comment
|
||||
TIMESTAMPTZ reviewed_at
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
teacher_absence_decisions {
|
||||
BIGSERIAL id PK
|
||||
BIGINT absence_id FK
|
||||
BIGINT base_rule_slot_id FK
|
||||
DATE lesson_date
|
||||
VARCHAR resolution
|
||||
VARCHAR decision_status
|
||||
BIGINT schedule_override_id FK
|
||||
TEXT comment
|
||||
BIGINT decided_by FK
|
||||
TIMESTAMPTZ decided_at
|
||||
}
|
||||
|
||||
teacher_preferences {
|
||||
BIGSERIAL id PK
|
||||
BIGINT teacher_id FK
|
||||
BIGINT semester_id FK
|
||||
VARCHAR preference_type
|
||||
INT day_of_week
|
||||
BIGINT time_slot_id FK
|
||||
DATE preference_date
|
||||
TEXT comment
|
||||
VARCHAR status
|
||||
BIGINT requested_by FK
|
||||
BIGINT reviewed_by FK
|
||||
TEXT review_comment
|
||||
TIMESTAMPTZ reviewed_at
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
teacher_change_requests {
|
||||
BIGSERIAL id PK
|
||||
BIGINT teacher_id FK
|
||||
BIGINT base_rule_slot_id FK
|
||||
DATE lesson_date
|
||||
VARCHAR request_type
|
||||
DATE target_lesson_date
|
||||
BIGINT requested_time_slot_id FK
|
||||
BIGINT requested_classroom_id FK
|
||||
TEXT reason
|
||||
VARCHAR status
|
||||
BIGINT reviewed_by FK
|
||||
BIGINT applied_override_id FK
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
teacher_change_request_history {
|
||||
BIGSERIAL id PK
|
||||
BIGINT request_id FK
|
||||
VARCHAR status
|
||||
TEXT comment
|
||||
BIGINT actor_id FK
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
schedule_rule_slot_subgroups {
|
||||
BIGINT schedule_rule_slot_id FK,PK
|
||||
BIGINT subgroup_id FK,PK
|
||||
@@ -371,14 +469,20 @@ erDiagram
|
||||
academic_years ||--o{ semesters : "academic_year_id"
|
||||
academic_years ||--o{ academic_calendars : "academic_year_id"
|
||||
academic_years ||--o{ student_group_calendar_assignments : "academic_year_id"
|
||||
semesters ||--o{ schedule_versions : "semester_id"
|
||||
schedule_versions ||--o{ schedule_rules : "schedule_version_id"
|
||||
schedule_versions ||--o{ schedule_version_history : "version_id"
|
||||
schedule_versions o|--o{ schedule_versions : "based_on/restored_from"
|
||||
users ||--o{ schedule_versions : "created_by/published_by"
|
||||
users ||--o{ schedule_version_history : "actor_id"
|
||||
semesters ||--o{ schedule_rules : "semester_id"
|
||||
specialties ||--o{ academic_calendars : "specialty_id"
|
||||
specialty_profiles ||--o{ academic_calendars : "specialty_profile_id"
|
||||
education_forms ||--o{ academic_calendars : "study_form_id"
|
||||
academic_calendars ||--o{ academic_calendar_days : "calendar_id"
|
||||
academic_calendars ||--o{ academic_calendar_periods : "calendar_id"
|
||||
academic_calendars ||--o{ academic_calendar_subjects : "calendar_id"
|
||||
academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id"
|
||||
academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id"
|
||||
academic_calendar_activity_types ||--o{ academic_calendar_periods : "activity_type_id"
|
||||
schedule_rules ||--o{ schedule_rule_groups : "schedule_rule_id"
|
||||
schedule_rules ||--o{ schedule_rule_slots : "schedule_rule_id"
|
||||
schedule_rule_slots ||--o{ schedule_rule_slot_subgroups : "schedule_rule_slot_id"
|
||||
@@ -386,6 +490,21 @@ erDiagram
|
||||
time_slots ||--o{ schedule_overrides : "new_time_slot_id"
|
||||
classrooms ||--o{ schedule_overrides : "new_classroom_id"
|
||||
users ||--o{ schedule_overrides : "new_teacher_id"
|
||||
users ||--o{ teacher_absences : "teacher_id/requested_by/reviewed_by"
|
||||
teacher_absences ||--o{ teacher_absence_decisions : "absence_id"
|
||||
schedule_rule_slots ||--o{ teacher_absence_decisions : "base_rule_slot_id"
|
||||
schedule_overrides ||--o{ teacher_absence_decisions : "schedule_override_id"
|
||||
users ||--o{ teacher_absence_decisions : "decided_by"
|
||||
users ||--o{ teacher_preferences : "teacher_id/requested_by/reviewed_by"
|
||||
semesters ||--o{ teacher_preferences : "semester_id"
|
||||
time_slots ||--o{ teacher_preferences : "time_slot_id"
|
||||
users ||--o{ teacher_change_requests : "teacher_id/reviewed_by"
|
||||
schedule_rule_slots ||--o{ teacher_change_requests : "base_rule_slot_id"
|
||||
time_slots ||--o{ teacher_change_requests : "requested_time_slot_id"
|
||||
classrooms ||--o{ teacher_change_requests : "requested_classroom_id"
|
||||
schedule_overrides ||--o{ teacher_change_requests : "applied_override_id"
|
||||
teacher_change_requests ||--o{ teacher_change_request_history : "request_id"
|
||||
users ||--o{ teacher_change_request_history : "actor_id"
|
||||
time_slot_scopes ||--o{ time_slots : "time_slot_scope_id"
|
||||
time_slot_scopes ||--o{ time_slot_date_assignments : "time_slot_scope_id"
|
||||
time_slots ||--o{ schedule_rule_slots : "time_slot_id"
|
||||
@@ -768,19 +887,22 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
группа стала несовместимой. Уменьшение `course_count` также запрещается, если в сетке
|
||||
остаются строки старших курсов или дисциплины старших семестров.
|
||||
|
||||
#### `academic_calendar_days` — Дневная сетка календарного графика
|
||||
#### `academic_calendar_periods` — Периоды активности календарного графика
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `calendar_id` | BIGINT FK → academic_calendars (CASCADE) | Календарный график |
|
||||
| `course_number` | INT | Номер курса |
|
||||
| `date` | DATE | Дата учебного года |
|
||||
| `week_number` | INT | Номер периода `понедельник–воскресенье`; неделя 1 содержит начало учебного года |
|
||||
| `day_of_week` | INT CHECK(1–7) | День недели ISO |
|
||||
| `start_date` | DATE | Первая дата периода, включительно |
|
||||
| `end_date` | DATE | Последняя дата периода, включительно |
|
||||
| `activity_type_id` | BIGINT FK → academic_calendar_activity_types | Код активности |
|
||||
|
||||
Триггер `trg_calendar_days_dimensions` требует, чтобы `course_number` не превышал
|
||||
`academic_calendars.course_count`, а дата находилась внутри учебного года графика.
|
||||
Триггер `trg_calendar_periods_dimensions` требует, чтобы `course_number` не превышал
|
||||
`academic_calendars.course_count`, обе границы находились внутри учебного года графика,
|
||||
а `end_date` была не раньше `start_date`. Exclusion constraint
|
||||
`ex_calendar_periods_no_overlap` запрещает пересечение периодов одного курса и графика.
|
||||
Соседние даты с одинаковой активностью объединяет backend; номер недели и ISO-день
|
||||
вычисляются при разворачивании периода в дневной REST-ответ и в таблице не дублируются.
|
||||
|
||||
#### `academic_calendar_subjects` — Дисциплины календарного графика
|
||||
| Колонка | Тип | Описание |
|
||||
@@ -807,12 +929,52 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
триггеры защищают назначение при изменении группы, графика и границ учебного года; блокировки
|
||||
ссылочных строк закрывают конкурентные записи между несколькими backend-pod.
|
||||
|
||||
#### `schedule_versions` — Версии расписания семестра
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID версии |
|
||||
| `semester_id` | BIGINT FK → semesters (CASCADE) | Семестр |
|
||||
| `version_number` | INT CHECK(> 0) | Последовательный номер внутри семестра |
|
||||
| `name` | VARCHAR(160) | Пользовательское название |
|
||||
| `status` | VARCHAR(20) | `DRAFT`, `PUBLISHED` или `ARCHIVED` |
|
||||
| `based_on_version_id` | BIGINT FK → schedule_versions | Версия-основа черновика |
|
||||
| `restored_from_version_id` | BIGINT FK → schedule_versions | Публикация, которую заменили при восстановлении |
|
||||
| `change_reason` | TEXT | Причина последней публикации или восстановления |
|
||||
| `created_by` | BIGINT FK → users | Автор черновика |
|
||||
| `created_at` | TIMESTAMPTZ | Время создания |
|
||||
| `published_by` | BIGINT FK → users | Автор публикации |
|
||||
| `published_at` | TIMESTAMPTZ | Время последней публикации |
|
||||
| `archived_at` | TIMESTAMPTZ | Время архивирования |
|
||||
|
||||
Пара `semester_id + version_number` уникальна. Частичный индекс
|
||||
`uq_schedule_versions_published_semester` запрещает более одной строки `PUBLISHED` на
|
||||
семестр. Начальная загрузка V1 создаёт опубликованную версию 1 для каждого семестра и
|
||||
привязывает к ней существующие seed-правила. Для семестров, создаваемых через API после
|
||||
запуска, `AcademicPeriodService` в той же транзакции создаёт пустую опубликованную версию 1
|
||||
`Основное расписание` и две записи истории: создание и публикацию.
|
||||
|
||||
#### `schedule_version_history` — Аудит версий расписания
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID события |
|
||||
| `version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания |
|
||||
| `action` | VARCHAR(30) | `CREATED`, `PUBLISHED`, `ARCHIVED` или `RESTORED` |
|
||||
| `actor_id` | BIGINT FK → users | Автор действия; `NULL` для системной инициализации |
|
||||
| `reason` | TEXT | Причина или описание события, до 2000 символов |
|
||||
| `created_at` | TIMESTAMPTZ | Время события |
|
||||
|
||||
Журнал добавляется при каждом переходе версии и выводится в обратной хронологии. Он не
|
||||
заменяет данные версии, а сохраняет отдельные факты аудита.
|
||||
|
||||
#### `schedule_rules` — Правила расписания
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID |
|
||||
| `subject_id` | BIGINT FK → subjects | Дисциплина |
|
||||
| `semester_id` | BIGINT FK → semesters | Семестр |
|
||||
| `schedule_version_id` | BIGINT FK → schedule_versions (CASCADE) | Версия расписания |
|
||||
| `lecture_academic_hours` | INT | Лимит академических часов лекций |
|
||||
| `laboratory_academic_hours` | INT | Лимит академических часов лабораторных работ |
|
||||
| `practice_academic_hours` | INT | Лимит академических часов практик |
|
||||
@@ -825,7 +987,7 @@ V1 создаёт GiST exclusion constraint `ex_academic_years_no_overlap` дл
|
||||
| `version_group_id` | BIGINT | Группа версий одного правила |
|
||||
| `change_reason` | TEXT | Причина изменения |
|
||||
|
||||
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
|
||||
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`. Публичная генерация дополнительно требует статус `PUBLISHED` у связанной версии. Уникальный индекс по `schedule_version_id + version_group_id` не позволяет дважды скопировать одну логическую линию правила в одну версию.
|
||||
|
||||
Базовая схема V1 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
|
||||
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
|
||||
@@ -903,6 +1065,107 @@ V1 добавляет `uq_schedule_rule_slots_exact_payload`: в одном пр
|
||||
lifecycle ресурсов, эффективная сетка целевого дня, реальность изменения и ресурсные
|
||||
конфликты проверяются транзакционным сервисом, а не SQL CHECK.
|
||||
|
||||
#### `teacher_absences` — Отсутствия преподавателей
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID отсутствия |
|
||||
| `teacher_id` | BIGINT FK → users | Отсутствующий преподаватель |
|
||||
| `start_date`, `end_date` | DATE | Включительные границы периода |
|
||||
| `reason` | TEXT | Причина длиной 1–2000 символов |
|
||||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `RESOLVED`, `CANCELLED` |
|
||||
| `requested_by` | BIGINT FK → users | Автор регистрации или заявки |
|
||||
| `reviewed_by` | BIGINT FK → users | Подтвердивший или отклонивший сотрудник |
|
||||
| `review_comment` | TEXT | Комментарий согласования |
|
||||
| `reviewed_at` | TIMESTAMPTZ | Время согласования |
|
||||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||||
|
||||
CHECK запрещает обратный период, пустую или слишком длинную причину и неизвестный статус.
|
||||
Индекс `(teacher_id, start_date, end_date)` ускоряет поиск пересекающихся отсутствий и
|
||||
проверку кандидатов мастера.
|
||||
|
||||
#### `teacher_absence_decisions` — Журнал мастера замены
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID записи журнала |
|
||||
| `absence_id` | BIGINT FK → teacher_absences | Инцидент отсутствия |
|
||||
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия |
|
||||
| `lesson_date` | DATE | Исходная дата занятия |
|
||||
| `resolution` | VARCHAR(30) | Замена, перенос, аудитория, отмена или отклонение |
|
||||
| `decision_status` | VARCHAR(20) | `APPLIED` либо `REJECTED` |
|
||||
| `schedule_override_id` | BIGINT FK → schedule_overrides | Созданная разовая правка, если есть |
|
||||
| `comment` | TEXT | Комментарий ответственного сотрудника |
|
||||
| `decided_by` | BIGINT FK → users | Автор решения |
|
||||
| `decided_at` | TIMESTAMPTZ | Время решения |
|
||||
|
||||
Уникальность `(absence_id, base_rule_slot_id, lesson_date)` не позволяет повторно обработать
|
||||
одно занятие одного инцидента. При удалении override ссылка обнуляется, но аудиторская запись
|
||||
сохраняется.
|
||||
|
||||
#### `teacher_preferences` — Пожелания преподавателей на семестр
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID пожелания |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель |
|
||||
| `semester_id` | BIGINT FK → semesters | Семестр действия |
|
||||
| `preference_type` | VARCHAR(30) | `HARD_UNAVAILABLE`, `SOFT_PREFERRED`, `SOFT_UNWANTED`, `CONSECUTIVE` или `NO_GAPS` |
|
||||
| `day_of_week` | SMALLINT NULL | День недели 1–7 для интервального пожелания |
|
||||
| `time_slot_id` | BIGINT FK → time_slots NULL | Пара базовой сетки времени |
|
||||
| `preference_date` | DATE NULL | Полностью недоступная дата при `HARD_UNAVAILABLE` |
|
||||
| `comment` | TEXT | Комментарий преподавателя |
|
||||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` |
|
||||
| `requested_by` | BIGINT FK → users | Автор записи |
|
||||
| `reviewed_by` | BIGINT FK → users | Автор решения |
|
||||
| `review_comment` | TEXT | Комментарий согласования |
|
||||
| `reviewed_at` | TIMESTAMPTZ | Время решения |
|
||||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||||
|
||||
CHECK-ограничение разделяет три допустимые формы записи: строгая недоступность по целой
|
||||
дате, строгое или мягкое пожелание по паре недели, либо пожелание компактности без даты и
|
||||
пары. Частичные индексы ускоряют проверку согласованной недоступности по интервалу и дате.
|
||||
Семантическая уникальность активных пожеланий проверяется сервисом, поскольку PostgreSQL
|
||||
считает значения `NULL` различными.
|
||||
|
||||
#### `teacher_change_requests` — Заявки на изменение занятия
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID заявки |
|
||||
| `teacher_id` | BIGINT FK → users | Преподаватель — автор заявки |
|
||||
| `base_rule_slot_id` | BIGINT FK → schedule_rule_slots | Базовый слот занятия |
|
||||
| `lesson_date` | DATE | Исходная дата занятия |
|
||||
| `request_type` | VARCHAR(30) | `MOVE`, `CHANGE_CLASSROOM` или `CANCEL` |
|
||||
| `target_lesson_date` | DATE NULL | Целевая дата переноса |
|
||||
| `requested_time_slot_id` | BIGINT FK → time_slots NULL | Запрошенная пара при переносе |
|
||||
| `requested_classroom_id` | BIGINT FK → classrooms NULL | Запрошенная аудитория |
|
||||
| `reason` | TEXT | Обязательное обоснование длиной 1–2000 символов |
|
||||
| `status` | VARCHAR(20) | `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED` |
|
||||
| `reviewed_by` | BIGINT FK → users | Сотрудник учебного отдела |
|
||||
| `review_comment` | TEXT | Комментарий решения |
|
||||
| `reviewed_at` | TIMESTAMPTZ | Время решения |
|
||||
| `applied_override_id` | BIGINT FK → schedule_overrides NULL | Разовая правка одобренной заявки |
|
||||
| `created_at`, `updated_at` | TIMESTAMPTZ | Аудит жизненного цикла |
|
||||
|
||||
CHECK фиксирует допустимую форму каждого типа заявки. Частичный уникальный индекс по
|
||||
`(base_rule_slot_id, lesson_date)` запрещает две одновременно ожидающие заявки на одну пару.
|
||||
При удалении применённого override ссылка обнуляется, но заявка и история сохраняются.
|
||||
|
||||
#### `teacher_change_request_history` — История решений по заявке
|
||||
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID события |
|
||||
| `request_id` | BIGINT FK → teacher_change_requests (CASCADE) | Заявка |
|
||||
| `status` | VARCHAR(20) | Зафиксированный статус заявки |
|
||||
| `comment` | TEXT | Обоснование или комментарий решения |
|
||||
| `actor_id` | BIGINT FK → users | Автор перехода |
|
||||
| `created_at` | TIMESTAMPTZ | Время события |
|
||||
|
||||
Записи добавляются при создании, принятии, отклонении и отзыве заявки. Индекс
|
||||
`(request_id, created_at, id)` обеспечивает стабильный хронологический вывод журнала.
|
||||
|
||||
---
|
||||
|
||||
## Flyway миграции
|
||||
@@ -921,13 +1184,19 @@ lifecycle ресурсов, эффективная сетка целевого
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики с нумерацией недель `понедельник–воскресенье`, динамическое расписание, точечные изменения с переносом даты, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||
| `V1__init.sql` | Полная baseline-схема: справочники, роли, refresh-сессии JWT, PostgreSQL rate limit и аудит входа, lifecycle-поля, история кафедр, календарные графики, динамическое расписание, версии/черновики и аудит публикаций, точечные изменения, отсутствия и журнал замен, пожелания преподавателей, заявки на изменение занятий и их история, seed, CHECK/UNIQUE/GiST-ограничения, конкурентно безопасные триггеры и комментарии |
|
||||
|
||||
### Этап разработки
|
||||
|
||||
По прямому решению владельца проекта разработческие миграции V2–V7 объединены в baseline
|
||||
`V1`. Правильная нумерация недель календарного графика также входит непосредственно в V1.
|
||||
Перед применением этой редакции требуется полностью пустая tenant-схема.
|
||||
Исторические разработческие миграции V2–V7, а затем повторно созданные V2 с отсутствиями
|
||||
и мастером замены и V3 с пожеланиями и заявками преподавателей по прямому решению владельца
|
||||
проекта объединены в baseline `V1`. В каталоге миграций остаётся один файл
|
||||
`V1__init.sql`.
|
||||
Интервальное хранение активностей и правильная нумерация недель календарного графика входят
|
||||
непосредственно в V1.
|
||||
Перед применением этой редакции требуется полностью пустая tenant-схема: для базы, где
|
||||
предыдущая V1 уже записана в `flyway_schema_history`, изменённая контрольная сумма вызовет
|
||||
ошибку проверки.
|
||||
|
||||
### Полный сброс БД (локально)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user